From 066bc59a987907616fbd45efa2aa43b74798d99a Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Fri, 4 Sep 2026 04:24:19 +1000 Subject: [PATCH 01/12] feat(09-14): pin UltiTools-API to 6.3.0-SNAPSHOT, resolve central-snapshots - Bump the UltiTools-API dependency from the released 6.2.2 to the 6.3.0-SNAPSHOT lineage (phase 9, MOD-01/MOD-02), ahead of the 6.3.0 release. - Add the central-snapshots repository entry; without it the snapshot coordinate cannot be resolved at all. - Record, in the pom near the build section, that this repository is deliberately out of scope for the module coverage gate: it has no CI workflow to run one in, and it demonstrates the External Plugin API rather than shipping as a UltiToolsPlugin module. - Module version and line endings unchanged. (cherry picked from commit 1cbbd5581a3f045341527fcebfa2e1bb06c78b18) --- pom.xml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index bd15d3b..55d32af 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,11 @@ UTF-8 + @@ -49,6 +54,19 @@ spigotmc-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + + @@ -61,7 +79,7 @@ com.ultikits UltiTools-API - 6.2.2 + 6.3.0-SNAPSHOT provided From ce949cb9a84560ddd8196702e70f95a0d6289250 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:33:20 +1000 Subject: [PATCH 02/12] build(p17-w0): build on Paper and add the modules' test dependencies (#4) - Replace spigot-api 1.20.4 and the SpigotMC repository with paper-api 1.21.11-R0.1-SNAPSHOT from the PaperMC repository, the same pin and repository the UltiKits modules use. UltiTools-API 6.3.0 is a Paper framework (Adventure types throughout), so Spigot is not a supported build target. - Add JUnit 5.10.0, Mockito 5.5.0, AssertJ 3.24.2 and MockBukkit (org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.101.0) as test dependencies, and maven-surefire-plugin 3.1.2, with the coordinates and versions the modules already declare. - Correct the phase-9 scope comment: it said the repository has no CI workflow, which stops being true in this change set. The coverage gate stays out of scope. - Project version unchanged; line endings preserved (CRLF). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- pom.xml | 59 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/pom.xml b/pom.xml index 55d32af..c534b28 100644 --- a/pom.xml +++ b/pom.xml @@ -10,18 +10,20 @@ 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 + subject to the JaCoCo coverage gate applied to the sixteen product modules. It + demonstrates the External Plugin API rather than shipping as a UltiToolsPlugin + module. Phase 17 (D-13) adds a test suite and the modules' maven-ci.yml, whose + `mvn -B verify` runs those tests and packages the jar; there is still no coverage + check, by the same decision. --> @@ -40,6 +42,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + @@ -51,8 +58,8 @@ - spigotmc-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + papermc + https://repo.papermc.io/repository/maven-public/ + + 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 + From b422f68b1ff59586e6dd5486ceef9a79aa124eab Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:33:41 +1000 Subject: [PATCH 03/12] test(p17-w0): prove /ultiext registers and dispatches on UltiTools-API 6.3.0 (#4) GreetCommandTest covers the three things an external plugin's command needs on 6.3.0: - Registration contract read by CommandManager#registerAllExternal: the class is a Bukkit CommandExecutor (BaseCommandExecutor) and carries @CmdExecutor with aliases ultiext/uext, permission ultiext.greet, and manualRegister=false; getMappings() holds all five sub-commands. - Dispatch through BaseCommandExecutor#onCommand on a MockBukkit server (permitted player and console), including parameter binding. - Each mapped method's sent messages and DataOperator calls (insert, update, failed update, getAll, del, uninitialised operator), plus help. RED at this commit: GreetCommand still extends the AbstractCommandExecutor that 6.3.0 deleted, so the main sources do not compile. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- .../commands/GreetCommandTest.java | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java 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..eb1477a --- /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); + } +} From 9cb2317769e94497f02b16972f64dbb31775e7c0 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Tue, 1 Sep 2026 22:34:42 +1000 Subject: [PATCH 04/12] feat(GEN-02): migrate GreetCommand to BaseCommandExecutor Swap the deprecated AbstractCommandExecutor for the current BaseCommandExecutor (import + extends only). All @CmdTarget, @CmdExecutor, @CmdMapping and @CmdParam annotations are preserved verbatim, and the existing handleHelp(CommandSender) override is kept unchanged, satisfying BaseCommandExecutor's protected abstract contract with no stub. Compiles against the existing released UltiTools-API 6.2.2 pin, no snapshot repository added (D-10 round 1). (cherry picked from commit e128f2f63bd4ba768f84655139f422b46e8d66f5) --- .../java/com/example/ultitoolsext/commands/GreetCommand.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java b/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java index 63f0fe4..9ab7338 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; @@ -23,7 +23,7 @@ description = "External plugin example command", alias = {"ultiext", "uext"} ) -public class GreetCommand extends AbstractCommandExecutor { +public class GreetCommand extends BaseCommandExecutor { @Autowired private GreetService greetService; From b34ad6216cdd1f237c286e6a542ea9f1a2548918 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:36:07 +1000 Subject: [PATCH 05/12] docs(p17-w0): explain the External Plugin API command path in GreetCommand (#4) - Add an English-first class javadoc (Chinese supplement) walking a reader through the path this example exists to teach: UltiToolsAPI.connect -> package scan and @Autowired injection -> CommandManager#registerAllExternal -> BaseCommandExecutor#onCommand (mapping match, @CmdTarget and permission checks, parameter binding, next-tick execution), and why commands extend BaseCommandExecutor now that 6.3.0 removed AbstractCommandExecutor. - FEATURES.md: shift the GreetCommand positive-control line numbers by the 25 javadoc lines (@CmdExecutor 21->46; @CmdMapping 31/37/44/74/93 -> 56/62/69/99/118); replace the claim that no comment names the repository's own annotations - already false on master, where the connect comment in UltiToolsExtExample names @CmdExecutor and @EventListener - with the measured unanchored counts; say AbstractDataEntity was removed in 6.3.0, not deprecated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- FEATURES.md | 19 +++++++------- .../ultitoolsext/commands/GreetCommand.java | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) 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/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java b/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java index 9ab7338..884b635 100644 --- a/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java +++ b/src/main/java/com/example/ultitoolsext/commands/GreetCommand.java @@ -17,6 +17,31 @@ 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", From 9a0e24a1fed25449d746036a4f91605d876378c9 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:36:45 +1000 Subject: [PATCH 06/12] docs(p17-w0): record the 6.3.0 migration in CHANGELOG and the UAT checklist (#4) Follow-up to the reused migration commit (cherry-picked from PR #1), which cannot carry these files without rewriting it: - CHANGELOG.md (new, Keep a Changelog 1.1.0, English first): ### Fixed - /ultiext sub-commands register again on UltiTools-API 6.3.0 (symptom as measured in #4: plugin and join listener work, every sub-command gets Bukkit's unknown-command reply); ### Changed - builds on UltiTools-API 6.3.0-SNAPSHOT and paper-api 1.21.11 instead of 6.2.2 and spigot-api 1.20.4. - UAT-CHECKLIST.md: a "Build under test" convention. No row names the removed base class or a 6.2.x API, so no row changes; the twelve rows Phase 10 could not run need only a jar built after this migration, and the convention makes a stale jar a setup error rather than a row failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ UAT-CHECKLIST.md | 7 +++++++ 2 files changed, 36 insertions(+) create mode 100644 CHANGELOG.md 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/UAT-CHECKLIST.md b/UAT-CHECKLIST.md index 27a668b..82b9f75 100644 --- a/UAT-CHECKLIST.md +++ b/UAT-CHECKLIST.md @@ -12,6 +12,13 @@ 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. Confirm the + installed jar's source commit before executing any row; a stale jar is a setup error, not a + row failure. - **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 From 5e602ce8a6484ce58a1ffae98a5edc7c786e88b7 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:37:06 +1000 Subject: [PATCH 07/12] ci(p17-w0): add the modules' maven-ci workflow (#4) - .github/workflows/maven-ci.yml copied byte-for-byte from UltiWorlds (same pinned actions/checkout and actions/setup-java SHAs, JDK 21 temurin, `mvn -B verify` on every push and pull request), so this repository's pull requests get a real build-and-test check. The repository had no workflow before this. - No publish or release workflow is added. - The copied job name says "coverage gate"; this repository has no JaCoCo check (phase-9 scope decision in pom.xml), so here the job runs the tests and packages the jar. Kept verbatim so the file stays identical to the modules' copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- .github/workflows/maven-ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/maven-ci.yml diff --git a/.github/workflows/maven-ci.yml b/.github/workflows/maven-ci.yml new file mode 100644 index 0000000..54a9135 --- /dev/null +++ b/.github/workflows/maven-ci.yml @@ -0,0 +1,28 @@ +name: Maven CI +on: + push: + branches: ['**'] + pull_request: +permissions: + contents: read +jobs: + verify: + name: Test, coverage gate & 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 test, then jacoco:check, then package -- one command replaces the shared + # workflow's separate test and package steps, and is the only phase at which the gate runs. + run: mvn -B verify From 48227ea4aa8c6999b1fd471fd9c001a4c643c63a Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:52:54 +1000 Subject: [PATCH 08/12] test(p17-w0): run GreetCommand through the framework's external registration (#4) Gate-1 review WR-01: GreetCommandTest proved the registration contract by reading annotations, but never ran the code that registers the command. - GreetCommandRegistrationTest repeats the container steps PluginManager#registerExternal performs for UltiToolsAPI.connect (plugin class loader, JavaPlugin type, package scan, refresh), calls the real package-private PluginManager.validateCommandExecutorContracts, then the real CommandManager#registerAllExternal against a real Bukkit SimpleCommandMap. It asserts /ultiext and /uext resolve to one PluginCommand with permission ultiext.greet whose executor is the scanned GreetCommand with GreetService injected, and that unregisterAllExternal removes both. Only UltiTools.getInstance() and Bukkit's static accessors are stubbed. - Behaviour-level RED: in a throwaway copy where GreetCommand does not extend BaseCommandExecutor, registerAllExternal skips it without error and the /ultiext assertion fails (a test failure, not a compile error). - Test sources converted to CRLF to match every src/main/java file (review IN-06); GreetCommandTest's content is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- .../GreetCommandRegistrationTest.java | 138 ++++ .../commands/GreetCommandTest.java | 610 +++++++++--------- 2 files changed, 443 insertions(+), 305 deletions(-) create mode 100644 src/test/java/com/example/ultitoolsext/commands/GreetCommandRegistrationTest.java 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 index eb1477a..0c8d686 100644 --- a/src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java +++ b/src/test/java/com/example/ultitoolsext/commands/GreetCommandTest.java @@ -1,305 +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); - } -} +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); + } +} From 6354f3ab71b16aebdef352ad8112d3d49ecb0529 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:53:14 +1000 Subject: [PATCH 09/12] ci(p17-w0): name the check for what it runs, and correct the template's no-CI claim (#4) - maven-ci.yml (gate-1 review WR-02): the job copied from the modules was named "Test, coverage gate & package" and its comment described a jacoco:check step. This repository has no JaCoCo plugin and no coverage threshold, so a green check promised a gate that does not exist. The job is now "Test & package (JDK ${{ matrix.java-version }})", rendered as "Test & package (JDK 21)", and the comment says only what `mvn -B verify` runs. Pinned actions, triggers and the command are unchanged; the file is no longer byte-identical to the modules' copy, deliberately. - pull_request_template.md (WR-03): the Issue closure comment said the repository has no workflows and no CI. It now says the only workflow is maven-ci.yml, which builds and tests and closes nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- .github/pull_request_template.md | 6 +++--- .github/workflows/maven-ci.yml | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) 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 index 54a9135..8c6e917 100644 --- a/.github/workflows/maven-ci.yml +++ b/.github/workflows/maven-ci.yml @@ -7,7 +7,7 @@ permissions: contents: read jobs: verify: - name: Test, coverage gate & package (JDK ${{ matrix.java-version }}) + name: Test & package (JDK ${{ matrix.java-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -23,6 +23,5 @@ jobs: java-version: ${{ matrix.java-version }} cache: maven - name: Verify - # verify runs test, then jacoco:check, then package -- one command replaces the shared - # workflow's separate test and package steps, and is the only phase at which the gate runs. + # verify runs the tests, then packages the jar. This repository has no coverage check. run: mvn -B verify From 9f8b84353e3879faad3b4eb631283c402387a1cf Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:53:38 +1000 Subject: [PATCH 10/12] build(p17-w0): state the pom's scope comments in words (#4) Gate-1 review IN-04: - The coverage-gate comment said "sixteen product modules"; there are fifteen active UltiKits product modules. It also carried internal planning codes, which mean nothing to a reader of this public example; the comment now states the facts in words. - The central-snapshots comment carried planning codes and said the other repositories "serve releases only", which stopped being true when the PaperMC repository started serving the paper-api snapshot; it now says the PaperMC repository does not host UltiTools-API. Comments only; no dependency, repository or version change; CRLF kept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- pom.xml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index c534b28..76acbb4 100644 --- a/pom.xml +++ b/pom.xml @@ -18,12 +18,11 @@ 1.21.11-R0.1-SNAPSHOT - + @@ -61,9 +60,9 @@ papermc https://repo.papermc.io/repository/maven-public/ - + central-snapshots https://central.sonatype.com/repository/maven-snapshots/ From b1a4380e77ecb5f16fe7fac16be434be7a4fa81c Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 21:55:26 +1000 Subject: [PATCH 11/12] docs(p17-w0): make the UAT rows hold against join-created and persisted records (#4) Gate-1 review WR-04, WR-05 and the same-class sweep: - Build under test (WR-04): the jar records no source commit (manifest and plugin.yml version are identical before and after the migration), so the convention now has the executor compare the installed jar's SHA-256 with the build hand-off's. - data.visitors.neg-empty (WR-05): JoinListener creates a record for every joining player, including the executor, and records survive restarts, so "delete what earlier rows created" left records behind and a correct build failed the row. The empty store is now established and checked from the console, the step runs from the console, and a follow-up visit/visitors pair proves the store is readable, so the empty reply cannot pass on a broken store; the row cleans up after itself. - Sweep, same class (an expectation or precondition that ignores state the session or an earlier dispatch creates): - greet.hello: the join greeting is the identical line, so the row could pass with no command registered at all - exactly #4's broken build. It now requires a new line after the command and no unknown-command reply. - data.visitors: " matches the actual stored count" was not observable; now must equal the lines printed, which must include probe and the executor's own join record. - data.visit, data.delvisitor.neg-not-found, data.persistence, join.on-join: preconditions now check the record state with /ultiext visitors and say how to reach it, instead of assuming no earlier dispatch left a record. - join.on-join.neg-rejoin: says when to read N. - A convention bullet states why, and the permission convention no longer claims only one row uses the console. - Unchanged: greet.hello.neg-console, greet.info, data.delvisitor, data.visit.neg-repeat. Row order check: no precondition cites a later row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- UAT-CHECKLIST.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/UAT-CHECKLIST.md b/UAT-CHECKLIST.md index 82b9f75..0275cb4 100644 --- a/UAT-CHECKLIST.md +++ b/UAT-CHECKLIST.md @@ -16,9 +16,17 @@ for real-machine verification, not user-facing documentation. 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. Confirm the - installed jar's source commit before executing any row; a stale jar is a setup error, not a - row failure. + 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 @@ -62,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 | | @@ -79,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 | | From c90aa13a8fc262142ee0ff430bd528a3dc237bd4 Mon Sep 17 00:00:00 2001 From: Ling Bao Date: Thu, 17 Sep 2026 22:01:10 +1000 Subject: [PATCH 12/12] build(p17-w0): correct the central-snapshots comment against the CI log (#4) The comment rewritten in 9f8b843 said the PaperMC repository does not host UltiTools-API and that the snapshot could not be resolved without central-snapshots. The first CI run on this pull request disproves both: Maven downloaded UltiTools-API-6.3.0-20260917.005350-20.pom and .jar from repo.papermc.io/repository/maven-public, whose group proxies Central's snapshots. The comment now says what the entry is (the repository the framework snapshot is published to) and that the build declares it rather than relying on PaperMC's proxy. Comment only; CRLF kept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL --- pom.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 76acbb4..be1f557 100644 --- a/pom.xml +++ b/pom.xml @@ -60,9 +60,10 @@ papermc https://repo.papermc.io/repository/maven-public/ - + central-snapshots https://central.sonatype.com/repository/maven-snapshots/