diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 45c4568..ee62494 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,9 +18,9 @@ Why this section exists: `master` is this repository's own default branch (unlik framework, which merges feature work into `alpha` first), so a `Closes #1234` written outside this comment DOES act automatically on merge here -- GitHub only honours closing keywords on a merge into the default branch, and a pull request into `master` already is one. There is no -`phase-closeout.yml` or any other workflow in this repository at all -- `.github/workflows/` -does not exist here, and this repository has no configured CI of any kind -- this section's -declarations are what closes the issue, not a follow-up automation step. State `None` explicitly +`phase-closeout.yml` in this repository -- its only workflow is `maven-ci.yml`, which builds and +tests and closes nothing -- so this section's declarations are what closes the issue, not a +follow-up automation step. State `None` explicitly rather than leaving the section blank, so a reviewer can tell "closes nothing" apart from "forgot to fill this in". diff --git a/.github/workflows/maven-ci.yml b/.github/workflows/maven-ci.yml new file mode 100644 index 0000000..8c6e917 --- /dev/null +++ b/.github/workflows/maven-ci.yml @@ -0,0 +1,27 @@ +name: Maven CI +on: + push: + branches: ['**'] + pull_request: +permissions: + contents: read +jobs: + verify: + name: Test & package (JDK ${{ matrix.java-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java-version: ['21'] + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + cache: maven + - name: Verify + # verify runs the tests, then packages the jar. This repository has no coverage check. + run: mvn -B verify diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ede5291 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project are documented in this file. +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +本文件记录本项目的所有重要更改,格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。 + +## [Unreleased] + +### Fixed + +- The `/ultiext` (alias `/uext`) sub-commands `hello`, `info`, `visit`, `visitors` and `delvisitor` + register again on UltiTools-API 6.3.0. `GreetCommand` now extends `BaseCommandExecutor`; + it previously extended `AbstractCommandExecutor`, which 6.3.0 removed, so the plugin loaded and + its join listener worked, but every `/ultiext` sub-command got Bukkit's + `Unknown or incomplete command` reply (UltiKits/UltiTools-External-Example#4). +- `/ultiext`(别名 `/uext`)的 `hello`、`info`、`visit`、`visitors`、`delvisitor` 子命令在 + UltiTools-API 6.3.0 上重新注册。`GreetCommand` 现继承 `BaseCommandExecutor`;此前它继承的 + `AbstractCommandExecutor` 已在 6.3.0 中移除,因此插件虽能加载、进服监听器也正常,但所有 `/ultiext` + 子命令都只返回 Bukkit 的 `Unknown or incomplete command` 提示(UltiKits/UltiTools-External-Example#4)。 + +### Changed + +- Migrated to UltiTools-API 6.3.0 and Paper: the example now builds against + `UltiTools-API 6.3.0-SNAPSHOT` and `paper-api 1.21.11-R0.1-SNAPSHOT` instead of UltiTools-API 6.2.2 + and `spigot-api 1.20.4-R0.1-SNAPSHOT` (UltiKits/UltiTools-External-Example#4). +- 迁移到 UltiTools-API 6.3.0 与 Paper:示例改为基于 `UltiTools-API 6.3.0-SNAPSHOT` 与 + `paper-api 1.21.11-R0.1-SNAPSHOT` 构建,不再使用 UltiTools-API 6.2.2 与 `spigot-api 1.20.4-R0.1-SNAPSHOT` + (UltiKits/UltiTools-External-Example#4)。 diff --git a/FEATURES.md b/FEATURES.md index c5aea69..0b599e8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -72,19 +72,20 @@ find -path '*/src/main/java/*' -name '*.java' -not -path '*/target/* -not -path '*/.worktrees/*' -print0 | xargs -0 grep -nE '^[[:space:]]*@AnnotationName\b' | wc -l ``` -This repository is a single-root Maven project (`src/main/java` only, 5 source files total), -carries no git worktree directory, and has no javadoc or string-literal mention of any of its own -annotation names — the naive (unanchored) and line-start counts are identical for every kind -measured below, but the anchored `find`/`grep` form is used regardless, so the same command is -trustworthy unmodified against every repository in the fan-out. +This repository is a single-root Maven project (`src/main/java` only, 5 source files total) and +carries no git worktree directory. Its comments do name some of its own annotations — the +`connect` comment in `UltiToolsExtExample.java` names `@CmdExecutor` and `@EventListener`, and +`GreetCommand`'s class javadoc names `@CmdExecutor` (twice) and `@CmdMapping` — so an unanchored +count over-counts those two kinds (`@CmdExecutor` 4, `@CmdMapping` 6, `@EventListener` 2). The +anchored `find`/`grep` form above counts only annotation sites and is the one used below. **Positive controls**, each confirmed by reading the cited line directly, not by trusting the count alone: | Annotation | Sites | Positive control | |---|---|---| -| `@CmdExecutor` | 1 | `GreetCommand.java:21`, class-level, `alias = {"ultiext", "uext"}` | -| `@CmdMapping` | 5 | `GreetCommand.java:31` (`hello`), `:37` (`info`), `:44` (`visit `), `:74` (`visitors`), `:93` (`delvisitor `) | +| `@CmdExecutor` | 1 | `GreetCommand.java:46`, class-level, `alias = {"ultiext", "uext"}` | +| `@CmdMapping` | 5 | `GreetCommand.java:56` (`hello`), `:62` (`info`), `:69` (`visit `), `:99` (`visitors`), `:118` (`delvisitor `) | | `@EventListener` | 1 | `JoinListener.java:16`, class-level, on `PlayerJoinEvent`'s handler class | | `@Scheduled` | 0 | no background task exists anywhere in this repository's 5 source files — confirmed by reading all 5 in full | | `@ConfigEntity` | 0 | this repository ships no configuration of its own, by design — it demonstrates the External Plugin API from a plain Bukkit plugin, not a configuration surface. Zero written, not omitted: this line is the phase's own live test of that rule | @@ -115,8 +116,8 @@ plain `JavaPlugin` connected through `UltiToolsAPI.connect(this)`, exactly as it `GreetCommand`'s three data-manipulation sub-commands, explicitly labeled "Data Storage Tests" in this class's own code comment — they exist to exercise `UltiToolsAPI.getDataOperator(this, VisitorRecord.class)`'s CRUD surface by hand, not to serve real gameplay. `VisitorRecord` -(`@Table("visitor_records")`, extends `BaseDataEntity`, current-generation API, not the -deprecated `AbstractDataEntity`) is this repository's one persisted entity, tracking a player +(`@Table("visitor_records")`, extends `BaseDataEntity`, current-generation API, not +`AbstractDataEntity`, which UltiTools-API 6.3.0 removed) is this repository's one persisted entity, tracking a player name, a visit count, and a last-visit timestamp. | ID | Feature | Kind | How to reach | Permission | Target | Tier | Manual | Source | diff --git a/UAT-CHECKLIST.md b/UAT-CHECKLIST.md index 27a668b..0275cb4 100644 --- a/UAT-CHECKLIST.md +++ b/UAT-CHECKLIST.md @@ -12,6 +12,21 @@ for real-machine verification, not user-facing documentation. ## Conventions - **Columns:** `ID`, `Preconditions`, `Steps`, `Expected`, `Layer`, `Covers`. +- **Build under test:** every row assumes a server running UltiTools-API 6.3.0 and this plugin's + jar built from a commit that includes the 6.3.0 migration of `GreetCommand` onto + `BaseCommandExecutor` (UltiKits/UltiTools-External-Example#4). A jar built before that migration + still loads, and its `JoinListener` still runs, but it registers no `/ultiext` sub-command, so + every row that runs `/ultiext` gets Bukkit's `Unknown or incomplete command` reply. The jar + records no source commit (its manifest and `plugin.yml` version are identical before and after + the migration), so before executing any row compute the SHA-256 of the installed jar + (`sha256sum plugins/UltiTools-External-Example-1.0.0.jar`) and confirm it equals the SHA-256 in + this run's build hand-off (jar path, version, byte size, SHA-256, source commit). A mismatch is a + setup error, not a row failure. +- **Visitor records outlive the row that made them:** `JoinListener#onJoin` creates or increments + a record for every player who joins, the executor included, and the store persists across + restarts and earlier dispatches. A row that needs a record to exist, or not to exist, therefore + states in its own Preconditions how to check and reach that state with `/ultiext visitors` and + `/ultiext delvisitor`, rather than assuming it from earlier rows. - **ID:** cites its `FEATURES.md` ID verbatim. A negative case suffixes the checklist ID only, as `.neg-` — a negative case still tests the same feature, so the base ID is unchanged. - **Layer**, copied verbatim from Laojun's own `ultitools-real-client-uat` skill so no @@ -55,15 +70,17 @@ for real-machine verification, not user-facing documentation. a non-console player therefore states that player must be OP or hold `ultiext.greet`, stated in each row's own Preconditions cell rather than only here — an ordinary non-OP player dispatching any of these commands is rejected before the command body ever runs, which a Precondition that - merely says "none" would let an executor discover only as a false failure. The one row that - dispatches from the server console (`ultitools-example.greet.hello.neg-console`) is unaffected: - Bukkit's console `CommandSender` always passes every permission check. + merely says "none" would let an executor discover only as a false failure. Steps and + Preconditions that run a command from the server console (for example + `ultitools-example.greet.hello.neg-console`, `ultitools-example.data.visitors.neg-empty`, and the + record checks in the `## Player Join` rows) need no such precondition: Bukkit's console + `CommandSender` always passes every permission check. ## Greeting | ID | Preconditions | Steps | Expected | Layer | Covers | |---|---|---|---|---|---| -| ultitools-example.greet.hello | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only | Run `/ultiext hello` as a player named `Tester` | Chat line reads exactly `Hello Tester! This message is from an external plugin using UltiTools-API.` | server | | +| ultitools-example.greet.hello | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; the player is already online (joined before this row starts) | Run `/ultiext hello` as a player named `Tester` | Immediately after the command is sent, a NEW chat line reads exactly `Hello Tester! This message is from an external plugin using UltiTools-API.`, and no `Unknown or incomplete command` reply appears — `JoinListener#onJoin` already sent this identical line when `Tester` joined, so that earlier join line does not count: the row passes only on the line that appears after the command | server | | | ultitools-example.greet.hello.neg-console | none | Run `/ultiext hello` from the server console | Console line reads exactly `Hello Console! This message is from an external plugin using UltiTools-API.` — `GreetCommand#hello` substitutes the literal string `Console` for any non-`Player` sender, proving `@CmdTarget(BOTH)` genuinely admits console execution rather than merely declaring it | server | | | ultitools-example.greet.info | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only | Run `/ultiext info` as a player | Chat/console line reads exactly `UltiTools External Example v1.0.0 - verifying @Service injection works!` | server | | @@ -72,16 +89,16 @@ for real-machine verification, not user-facing documentation. | ID | Preconditions | Steps | Expected | Layer | Covers | |---|---|---|---|---|---| | ultitools-example.data.delvisitor | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `probe` exists — run `/ultiext visit probe` first if none does | Run `/ultiext delvisitor probe` as a player, then run `/ultiext visitors` | Chat/console line reads `[DATA] Deleted visitor record for probe`; the subsequent `/ultiext visitors` no longer lists `probe` | server | | -| ultitools-example.data.delvisitor.neg-not-found | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; no visitor record named `ghost` exists (delete it first via `ultitools-example.data.delvisitor` if a prior dispatch created one, or use a name never visited in this session) | Run `/ultiext delvisitor ghost` as a player | Chat/console line STILL reads `[DATA] Deleted visitor record for ghost` — `GreetCommand#delVisitor` calls `DataOperator#del` unconditionally and reports success regardless of whether a matching row ever existed; this is the row's actual assertion, not a bug workaround | server | | -| ultitools-example.data.visit | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; no visitor record named `probe` currently exists (run `ultitools-example.data.delvisitor` first if one does) | Run `/ultiext visit probe` as a player | Chat/console line reads `[DATA] Created record for probe — first visit!` | server | | +| ultitools-example.data.delvisitor.neg-not-found | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; `/ultiext visitors` does not list `ghost` — records persist across restarts and earlier dispatches, so check rather than assume, and if `ghost` is listed run `/ultiext delvisitor ghost` once and check again | Run `/ultiext delvisitor ghost` as a player | Chat/console line STILL reads `[DATA] Deleted visitor record for ghost` — `GreetCommand#delVisitor` calls `DataOperator#del` unconditionally and reports success regardless of whether a matching row ever existed; this is the row's actual assertion, not a bug workaround | server | | +| ultitools-example.data.visit | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; `/ultiext visitors` does not list `probe` — records persist across restarts and earlier dispatches, so check rather than assume, and if `probe` is listed run `ultitools-example.data.delvisitor` first | Run `/ultiext visit probe` as a player | Chat/console line reads `[DATA] Created record for probe — first visit!` | server | | | ultitools-example.data.visit.neg-repeat | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `probe` exists with visit count 1 (run `ultitools-example.data.visit` immediately before this row, in the same dispatch) | Run `/ultiext visit probe` as a player a second time, then run `/ultiext visitors` | The first command's chat/console line reads `[DATA] Updated probe — visit #2`; the subsequent `/ultiext visitors` lists `probe` EXACTLY ONCE with `2 visits` — proving the SAME record's count incremented rather than a second, duplicate `probe` row being inserted alongside the first | server | | -| ultitools-example.data.visitors | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; at least one visitor record exists (run `ultitools-example.data.visit` first) | Run `/ultiext visitors` as a player | Chat/console starts with `[DATA] === Visitor Records () ===` where `` matches the actual stored count, followed by one ` — visits` line per record | server | | -| ultitools-example.data.visitors.neg-empty | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; zero visitor records exist (delete every record created earlier in this dispatch via `ultitools-example.data.delvisitor`) | Run `/ultiext visitors` as a player | Chat/console shows exactly `[DATA] No visitor records.` — no header line, no record lines | server | | -| ultitools-example.data.persistence | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; a visitor record named `restarttest` exists with visit count 1 (run `ultitools-example.data.visit` with `restarttest` first); the player who runs the post-restart query below is named something OTHER than `restarttest` (or the query is run from the server console) — `JoinListener#onJoin` auto-increments a visitor record matching the JOINING player's own name, so a player named `restarttest` rejoining after the restart would increment the very record this row checks BEFORE `/ultiext visitors` ever runs, producing a false persistence failure | Stop the server completely (a full clean shutdown, not `/ul reload`), start it again, then run `/ultiext visitors` as a player NOT named `restarttest` (or from the console) | `restarttest` is still listed with visit count 1, unchanged by the restart or by any other player's own join — `UltiToolsAPI.getDataOperator`'s backing store persisted the record to disk (JSON/SQLite/MySQL per the framework's own `datasource.type`), independent of this plugin's own in-memory state, which is rebuilt from scratch on every `onEnable` | server | | +| ultitools-example.data.visitors | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; `probe` exists (run `ultitools-example.data.visit` first); the executing player joined while this plugin was loaded, so `JoinListener#onJoin` has also created or incremented a record under that player's own name | Run `/ultiext visitors` as a player | Chat starts with `[DATA] === Visitor Records () ===`, followed by exactly `` lines of the form ` — visits`; among them are `probe` and the executing player's own name — the count `` is checked against the lines printed, not against a number the executor has to know in advance | server | | +| ultitools-example.data.visitors.neg-empty | the store is empty, established from the server console: run `/ultiext visitors`; for every name it lists — including records `JoinListener#onJoin` created for players who joined, and records left by earlier dispatches, which survive restarts — run `/ultiext delvisitor `; repeat until `/ultiext visitors` lists no name. No player joins between this check and the Steps, because every join creates a record again | From the server console, run `/ultiext visitors`; then run `/ultiext visit probe` and `/ultiext visitors` again; then run `/ultiext delvisitor probe` to restore the empty store | The first `/ultiext visitors` prints exactly `[DATA] No visitor records.` — no header line, no record lines; the second prints `[DATA] === Visitor Records (1) ===` followed by ` probe — 1 visits`, proving the store was readable and the empty reply was not a store failure | server | | +| ultitools-example.data.persistence | the executing player is OP, or has been granted `ultiext.greet` — this plugin declares no `permissions:` section in `plugin.yml`, so Bukkit's own undeclared-node default is OP-only; `/ultiext visitors` lists `restarttest` with visit count 1 — if it is not listed, run `/ultiext visit restarttest`; if it is listed with any other count (records persist from earlier dispatches), run `/ultiext delvisitor restarttest` and then `/ultiext visit restarttest`; the player who runs the post-restart query below is named something OTHER than `restarttest` (or the query is run from the server console) — `JoinListener#onJoin` auto-increments a visitor record matching the JOINING player's own name, so a player named `restarttest` rejoining after the restart would increment the very record this row checks BEFORE `/ultiext visitors` ever runs, producing a false persistence failure | Stop the server completely (a full clean shutdown, not `/ul reload`), start it again, then run `/ultiext visitors` as a player NOT named `restarttest` (or from the console) | `restarttest` is still listed with visit count 1, unchanged by the restart or by any other player's own join — `UltiToolsAPI.getDataOperator`'s backing store persisted the record to disk (JSON/SQLite/MySQL per the framework's own `datasource.type`), independent of this plugin's own in-memory state, which is rebuilt from scratch on every `onEnable` | server | | ## Player Join | ID | Preconditions | Steps | Expected | Layer | Covers | |---|---|---|---|---|---| -| ultitools-example.join.on-join | no visitor record exists yet for the joining player's name | The player joins the server for the first time in this session | The player receives the chat line `Hello ! This message is from an external plugin using UltiTools-API.` (identical to `/ultiext hello`'s own line, substituting the real player name), and a subsequent `/ultiext visitors` (run by any sender holding `ultiext.greet`) lists that player's name with visit count 1, without the player having run any command themselves | server | | -| ultitools-example.join.on-join.neg-rejoin | a visitor record already exists for the joining player's name, with visit count `N` (the player joined once already earlier in this same dispatch, per `ultitools-example.join.on-join`) | The same player quits and rejoins | The player again receives the join greeting; a subsequent `/ultiext visitors` shows that player's visit count as `N + 1`, NOT reset to 1 and NOT a second entry for the same name — `JoinListener#onJoin` looks up the existing record by name and increments it, mirroring `/ultiext visit`'s own repeat-visit behaviour | server | | +| ultitools-example.join.on-join | the joining player is offline, and `/ultiext visitors` run from the server console does not list that player's name — every earlier join by that name, in this dispatch or an earlier one, left a record that survives restarts; if the name is listed, run `/ultiext delvisitor ` from the console and check again | The player joins the server | The player receives the chat line `Hello ! This message is from an external plugin using UltiTools-API.` (identical to `/ultiext hello`'s own line, substituting the real player name), and a subsequent `/ultiext visitors` from the console lists that player's name with visit count 1, without the player having run any command themselves | server | | +| ultitools-example.join.on-join.neg-rejoin | a visitor record already exists for the joining player's name (the player joined once already, per `ultitools-example.join.on-join`); read its visit count `N` from `/ultiext visitors` on the server console immediately before the player quits | The same player quits and rejoins | The player again receives the join greeting; a subsequent `/ultiext visitors` from the console shows that player's visit count as `N + 1`, NOT reset to 1 and NOT a second entry for the same name — `JoinListener#onJoin` looks up the existing record by name and increments it, mirroring `/ultiext visit`'s own repeat-visit behaviour | server | | diff --git a/pom.xml b/pom.xml index bd15d3b..be1f557 100644 --- a/pom.xml +++ b/pom.xml @@ -10,13 +10,19 @@ jar UltiTools-External-Example - Example external Bukkit plugin using UltiTools-API v6.2.2 + Example external Bukkit plugin using UltiTools-API 6.3.0 1.8 UTF-8 + 1.21.11-R0.1-SNAPSHOT + @@ -35,6 +41,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + @@ -46,22 +57,36 @@ - spigotmc-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + papermc + https://repo.papermc.io/repository/maven-public/ + + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + - org.spigotmc - spigot-api - 1.20.4-R0.1-SNAPSHOT + io.papermc.paper + paper-api + ${paper.version} provided com.ultikits UltiTools-API - 6.2.2 + 6.3.0-SNAPSHOT provided @@ -70,5 +95,37 @@ 1.18.30 provided + + + + org.junit.jupiter + junit-jupiter-engine + 5.10.0 + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.0 + test + + + org.mockito + mockito-core + 5.5.0 + test + + + org.assertj + assertj-core + 3.24.2 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.101.0 + test + diff --git a/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java b/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java index 63f0fe4..884b635 100644 --- a/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java +++ b/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java @@ -3,7 +3,7 @@ import com.example.ultitoolsext.UltiToolsExtExample; import com.example.ultitoolsext.data.VisitorRecord; import com.example.ultitoolsext.services.GreetService; -import com.ultikits.ultitools.abstracts.AbstractCommandExecutor; +import com.ultikits.ultitools.abstracts.command.BaseCommandExecutor; import com.ultikits.ultitools.annotations.Autowired; import com.ultikits.ultitools.annotations.command.CmdExecutor; import com.ultikits.ultitools.annotations.command.CmdMapping; @@ -17,13 +17,38 @@ import java.util.List; +/** + * Example command for a plain Bukkit {@code JavaPlugin} that uses UltiTools through the External + * Plugin API rather than extending {@code UltiToolsPlugin}. + *

+ * How it is wired: {@code UltiToolsAPI.connect(this)} in {@link UltiToolsExtExample#onEnable()} + * scans this plugin's package into its own IoC container, which creates this class as a bean and + * injects {@link GreetService} through {@code @Autowired}. The framework's + * {@code CommandManager#registerAllExternal} then registers every bean that is a Bukkit + * {@code CommandExecutor} carrying {@code @CmdExecutor}, under that annotation's aliases + * ({@code /ultiext}, {@code /uext}). When a player or the console runs the command, Bukkit calls + * {@link BaseCommandExecutor#onCommand}, which picks the {@code @CmdMapping} whose format matches + * the arguments, checks {@code @CmdTarget} and the {@code ultiext.greet} permission, binds + * {@code @CmdSender}/{@code @CmdParam} parameters, and runs the method on the next server tick. + *

+ * A command class extends {@link BaseCommandExecutor} and implements + * {@link #handleHelp(CommandSender)}. The older {@code AbstractCommandExecutor} base class was + * removed in UltiTools-API 6.3.0; while this example still extended it, none of the + * {@code /ultiext} sub-commands registered (UltiKits/UltiTools-External-Example#4). + *

+ * 通过外部插件 API(而非继承 {@code UltiToolsPlugin})使用 UltiTools 的普通 Bukkit 插件的示例指令。 + * {@code UltiToolsAPI.connect(this)} 扫描本插件的包并将本类创建为 Bean, + * {@code CommandManager#registerAllExternal} 再把带有 {@code @CmdExecutor} 的指令注册到 Bukkit。 + * 指令类需继承 {@link BaseCommandExecutor} 并实现 {@link #handleHelp(CommandSender)}; + * 旧的 {@code AbstractCommandExecutor} 已在 UltiTools-API 6.3.0 中移除。 + */ @CmdTarget(CmdTarget.CmdTargetType.BOTH) @CmdExecutor( permission = "ultiext.greet", description = "External plugin example command", alias = {"ultiext", "uext"} ) -public class GreetCommand extends AbstractCommandExecutor { +public class GreetCommand extends BaseCommandExecutor { @Autowired private GreetService greetService; diff --git a/src/test/java/com/example/ultitoolsext/commands/GreetCommandRegistrationTest.java b/src/test/java/com/example/ultitoolsext/commands/GreetCommandRegistrationTest.java new file mode 100644 index 0000000..219c352 --- /dev/null +++ b/src/test/java/com/example/ultitoolsext/commands/GreetCommandRegistrationTest.java @@ -0,0 +1,138 @@ +package com.example.ultitoolsext.commands; + +import com.example.ultitoolsext.UltiToolsExtExample; +import com.example.ultitoolsext.services.GreetService; +import com.ultikits.ultitools.UltiTools; +import com.ultikits.ultitools.api.ExternalPluginAdapter; +import com.ultikits.ultitools.context.SimpleContainer; +import com.ultikits.ultitools.manager.CommandManager; +import com.ultikits.ultitools.manager.PluginManager; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.HashMap; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Runs {@link GreetCommand} through the framework's own external-plugin registration code rather + * than asserting its annotations by hand (UltiKits/UltiTools-External-Example#4). + *

+ * The test repeats the container steps {@code PluginManager#registerExternal} performs for + * {@code UltiToolsAPI.connect(this)} (plugin class loader, {@code JavaPlugin} type, package scan, + * {@code refresh()}), then calls the real package-private contract check + * {@code PluginManager.validateCommandExecutorContracts} and the real + * {@link CommandManager#registerAllExternal(ExternalPluginAdapter)} against a real Bukkit + * {@link SimpleCommandMap}. Only {@code UltiTools.getInstance()} and {@code Bukkit}'s static + * accessors are stubbed, because the framework plugin itself is not running. + *

+ * On 6.3.0 a command class that is not a {@code BaseCommandExecutor} (and so not a Bukkit + * {@link CommandExecutor}) is skipped by {@code registerAllExternal} without an error, which is the + * symptom of issue #4: no {@code /ultiext} command exists afterwards. + */ +@DisplayName("GreetCommand through the framework's external registration") +class GreetCommandRegistrationTest { + + private static final String PLUGIN_NAME = "UltiToolsExtExample"; + + @Test + @DisplayName("scan + registerAllExternal register /ultiext and /uext; unregisterAllExternal removes them") + @SuppressWarnings("deprecation") + void registersAndUnregistersUltiext() throws Exception { + Server server = mock(Server.class); + when(server.getLogger()).thenReturn(Logger.getLogger(PLUGIN_NAME)); + SimpleCommandMap commandMap = new SimpleCommandMap(server, new HashMap()); + org.bukkit.plugin.SimplePluginManager pluginManager = + new org.bukkit.plugin.SimplePluginManager(server, commandMap); + + UltiTools ultiTools = mock(UltiTools.class); + when(ultiTools.getDescription()).thenReturn( + new PluginDescriptionFile("UltiTools", "6.3.0", "com.ultikits.ultitools.UltiTools")); + + UltiToolsExtExample example = mock(UltiToolsExtExample.class); + when(example.getName()).thenReturn(PLUGIN_NAME); + when(example.getDescription()).thenReturn(new PluginDescriptionFile( + PLUGIN_NAME, "1.0.0", UltiToolsExtExample.class.getName())); + when(example.getDataFolder()).thenReturn(new File("target", PLUGIN_NAME)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic ulti = mockStatic(UltiTools.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getLogger(PLUGIN_NAME)); + ulti.when(UltiTools::getInstance).thenReturn(ultiTools); + + ExternalPluginAdapter adapter = new ExternalPluginAdapter(example); + SimpleContainer context = new SimpleContainer(); + context.setClassLoader(mainClassesFirst(adapter.getPluginClassLoader())); + context.registerType(JavaPlugin.class, example); + context.scanComponents(adapter.getScanPackage()); + context.refresh(); + adapter.setContext(context); + + Method contractCheck = PluginManager.class + .getDeclaredMethod("validateCommandExecutorContracts", SimpleContainer.class); + contractCheck.setAccessible(true); + contractCheck.invoke(null, context); + + CommandManager commandManager = new CommandManager(); + commandManager.registerAllExternal(adapter); + + Command registered = commandMap.getCommand("ultiext"); + assertThat(registered).isInstanceOf(PluginCommand.class); + assertThat(commandMap.getCommand("uext")).isSameAs(registered); + assertThat(registered.getPermission()).isEqualTo("ultiext.greet"); + CommandExecutor executor = ((PluginCommand) registered).getExecutor(); + assertThat(executor).isInstanceOf(GreetCommand.class); + assertThat(readField(executor, "greetService")).isInstanceOf(GreetService.class); + + commandManager.unregisterAllExternal(PLUGIN_NAME); + + assertThat(commandMap.getCommand("ultiext")).isNull(); + assertThat(commandMap.getCommand("uext")).isNull(); + } + } + + /** + * On a server the plugin's class loader resolves the package to the plugin's own jar. Under + * Surefire the application class loader resolves {@code com/example/ultitoolsext} to + * {@code target/test-classes} first, which holds only test classes, so the scan would find no + * component. Resolve resources against the directory {@link GreetCommand} was loaded from. + */ + private static ClassLoader mainClassesFirst(ClassLoader parent) { + final URL mainClasses = GreetCommand.class.getProtectionDomain().getCodeSource().getLocation(); + return new ClassLoader(parent) { + @Override + public URL getResource(String name) { + try { + URL url = new URL(mainClasses, name); + return new File(url.toURI()).exists() ? url : super.getResource(name); + } catch (Exception e) { + return super.getResource(name); + } + } + }; + } + + private static Object readField(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java b/src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java new file mode 100644 index 0000000..0c8d686 --- /dev/null +++ b/src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java @@ -0,0 +1,305 @@ +package com.example.ultitoolsext.commands; + +import com.example.ultitoolsext.UltiToolsExtExample; +import com.example.ultitoolsext.data.VisitorRecord; +import com.example.ultitoolsext.services.GreetService; +import com.ultikits.ultitools.abstracts.command.BaseCommandExecutor; +import com.ultikits.ultitools.annotations.command.CmdExecutor; +import com.ultikits.ultitools.entities.WhereCondition; +import com.ultikits.ultitools.interfaces.DataOperator; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link GreetCommand} against the UltiTools-API 6.3.0 command API. + *

+ * Before 6.3.0 this command extended {@code AbstractCommandExecutor}, which 6.3.0 deleted, so + * {@code UltiToolsAPI.connect(this)} could not register any {@code /ultiext} subcommand + * (UltiKits/UltiTools-External-Example#4). These tests do not compile against that shape: they + * use {@link BaseCommandExecutor#getMappings()} and dispatch through + * {@link BaseCommandExecutor#onCommand}, the same entry point Bukkit calls once + * {@code CommandManager#registerAllExternal} has registered the command. + */ +@DisplayName("GreetCommand on the UltiTools-API 6.3.0 command API") +class GreetCommandTest { + + private static final String HELLO_SUFFIX = + "! This message is from an external plugin using UltiTools-API."; + + private ServerMock server; + private GreetCommand command; + private DataOperator visitorData; + private Object previousInstance; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + server = MockBukkit.mock(); + command = new GreetCommand(); + setField(command, "greetService", new GreetService()); + + visitorData = mock(DataOperator.class); + UltiToolsExtExample plugin = mock(UltiToolsExtExample.class); + when(plugin.getVisitorData()).thenReturn(visitorData); + previousInstance = swapPluginInstance(plugin); + } + + @AfterEach + void tearDown() throws Exception { + swapPluginInstance(previousInstance); + MockBukkit.unmock(); + } + + @Nested + @DisplayName("Registration contract read by CommandManager#registerAllExternal") + class Registration { + + @Test + @DisplayName("is a Bukkit CommandExecutor, the bean type registerAllExternal collects") + void isCommandExecutorBean() { + assertThat(command).isInstanceOf(CommandExecutor.class); + assertThat(command).isInstanceOf(BaseCommandExecutor.class); + } + + @Test + @DisplayName("carries @CmdExecutor with its aliases and permission, not manually registered") + void carriesCmdExecutor() { + CmdExecutor annotation = GreetCommand.class.getAnnotation(CmdExecutor.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.alias()).containsExactly("ultiext", "uext"); + assertThat(annotation.permission()).isEqualTo("ultiext.greet"); + assertThat(annotation.manualRegister()).isFalse(); + } + + @Test + @DisplayName("maps every documented sub-command") + void mapsEverySubCommand() { + assertThat(command.getMappings().keySet()).containsExactlyInAnyOrder( + "hello", "info", "visit ", "visitors", "delvisitor "); + } + } + + @Nested + @DisplayName("Dispatch through onCommand") + class Dispatch { + + @Test + @DisplayName("/ultiext hello from a permitted player greets that player by name") + void helloFromPlayer() { + PlayerMock player = server.addPlayer("Steve"); + player.setOp(true); + + boolean handled = dispatch(player, "hello"); + + assertThat(handled).isTrue(); + assertThat(player.nextMessage()).isEqualTo("Hello Steve" + HELLO_SUFFIX); + assertThat(player.nextMessage()).isNull(); + } + + @Test + @DisplayName("/ultiext hello from the console greets \"Console\"") + void helloFromConsole() { + ConsoleCommandSenderMock console = (ConsoleCommandSenderMock) server.getConsoleSender(); + + dispatch(console, "hello"); + + assertThat(console.nextMessage()).isEqualTo("Hello Console" + HELLO_SUFFIX); + } + + @Test + @DisplayName("/ultiext info reports the example's info line") + void info() { + PlayerMock player = server.addPlayer("Steve"); + player.setOp(true); + + dispatch(player, "info"); + + assertThat(player.nextMessage()) + .isEqualTo("UltiTools External Example v1.0.0 - verifying @Service injection works!"); + } + + @Test + @DisplayName("/ultiext visit binds the parameter and inserts a first visit") + void visitCreatesRecord() { + PlayerMock player = server.addPlayer("Steve"); + player.setOp(true); + when(visitorData.getAll(any(WhereCondition.class))).thenReturn(Collections.emptyList()); + + dispatch(player, "visit", "Alex"); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(VisitorRecord.class); + verify(visitorData).insert(inserted.capture()); + assertThat(inserted.getValue().getPlayerName()).isEqualTo("Alex"); + assertThat(inserted.getValue().getVisitCount()).isEqualTo(1); + assertThat(player.nextMessage()).isEqualTo("[DATA] Created record for Alex — first visit!"); + } + } + + @Nested + @DisplayName("Mapped methods") + class MappedMethods { + + private PlayerMock player; + + @BeforeEach + void addPlayer() { + player = server.addPlayer("Steve"); + } + + @Test + @DisplayName("visit filters on player_name and increments an existing record") + void visitUpdatesExistingRecord() throws Exception { + VisitorRecord existing = new VisitorRecord("Alex"); + when(visitorData.getAll(any(WhereCondition.class))).thenReturn(listOf(existing)); + + command.visit(player, "Alex"); + + ArgumentCaptor where = ArgumentCaptor.forClass(WhereCondition.class); + verify(visitorData).getAll(where.capture()); + assertThat(where.getValue().getColumn()).isEqualTo("player_name"); + assertThat(where.getValue().getValue()).isEqualTo("Alex"); + verify(visitorData).update(existing); + verify(visitorData, never()).insert(any(VisitorRecord.class)); + assertThat(existing.getVisitCount()).isEqualTo(2); + assertThat(player.nextMessage()).isEqualTo("[DATA] Updated Alex — visit #2"); + } + + @Test + @DisplayName("visit reports a failed update instead of a success line") + void visitReportsFailedUpdate() throws Exception { + VisitorRecord existing = new VisitorRecord("Alex"); + when(visitorData.getAll(any(WhereCondition.class))).thenReturn(listOf(existing)); + org.mockito.Mockito.doThrow(new IllegalAccessException("denied")).when(visitorData).update(existing); + + command.visit(player, "Alex"); + + assertThat(player.nextMessage()).isEqualTo("[ERROR] Update failed: denied"); + assertThat(player.nextMessage()).isNull(); + } + + @Test + @DisplayName("visitors reports an empty store") + void visitorsEmpty() { + when(visitorData.getAll()).thenReturn(Collections.emptyList()); + + command.visitors(player); + + assertThat(player.nextMessage()).isEqualTo("[DATA] No visitor records."); + } + + @Test + @DisplayName("visitors lists every record with its visit count") + void visitorsLists() { + VisitorRecord alex = new VisitorRecord("Alex"); + VisitorRecord sam = new VisitorRecord("Sam"); + sam.incrementVisit(); + when(visitorData.getAll()).thenReturn(Arrays.asList(alex, sam)); + + command.visitors(player); + + assertThat(player.nextMessage()).isEqualTo("[DATA] === Visitor Records (2) ==="); + assertThat(player.nextMessage()).isEqualTo(" Alex — 1 visits"); + assertThat(player.nextMessage()).isEqualTo(" Sam — 2 visits"); + } + + @Test + @DisplayName("delvisitor deletes by player_name and confirms") + void delVisitor() { + command.delVisitor(player, "Alex"); + + ArgumentCaptor where = ArgumentCaptor.forClass(WhereCondition.class); + verify(visitorData).del(where.capture()); + assertThat(where.getValue().getColumn()).isEqualTo("player_name"); + assertThat(where.getValue().getValue()).isEqualTo("Alex"); + assertThat(player.nextMessage()).isEqualTo("[DATA] Deleted visitor record for Alex"); + } + + @Test + @DisplayName("data sub-commands report an uninitialised DataOperator") + void dataOperatorMissing() throws Exception { + UltiToolsExtExample withoutData = mock(UltiToolsExtExample.class); + swapPluginInstance(withoutData); + + command.visit(player, "Alex"); + command.visitors(player); + command.delVisitor(player, "Alex"); + + for (int i = 0; i < 3; i++) { + assertThat(player.nextMessage()).isEqualTo("[ERROR] DataOperator not initialized!"); + } + } + + @Test + @DisplayName("help lists every sub-command") + void help() { + command.handleHelp(player); + + List lines = new ArrayList<>(); + String line; + while ((line = player.nextMessage()) != null) { + lines.add(line); + } + assertThat(lines).anySatisfy(l -> assertThat(l).startsWith("/ultiext hello")); + assertThat(lines).anySatisfy(l -> assertThat(l).startsWith("/ultiext info")); + assertThat(lines).anySatisfy(l -> assertThat(l).startsWith("/ultiext visit ")); + assertThat(lines).anySatisfy(l -> assertThat(l).startsWith("/ultiext visitors")); + assertThat(lines).anySatisfy(l -> assertThat(l).startsWith("/ultiext delvisitor")); + } + } + + /** + * Calls {@code onCommand} the way Bukkit does for {@code /ultiext }, then runs the + * scheduler tick on which BaseCommandExecutor executes synchronous command bodies. + */ + private boolean dispatch(org.bukkit.command.CommandSender sender, String... args) { + Command bukkitCommand = mock(Command.class); + when(bukkitCommand.getName()).thenReturn("ultiext"); + boolean handled = command.onCommand(sender, bukkitCommand, "ultiext", args); + server.getScheduler().performOneTick(); + return handled; + } + + private static List listOf(VisitorRecord record) { + List list = new ArrayList<>(); + list.add(record); + return list; + } + + private static Object swapPluginInstance(Object value) throws Exception { + Field field = UltiToolsExtExample.class.getDeclaredField("instance"); + field.setAccessible(true); + Object previous = field.get(null); + field.set(null, value); + return previous; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +}