From 0f6610ccc427f503cba6c461524378f16211be37 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sun, 19 Apr 2026 22:15:30 +0200 Subject: [PATCH 1/2] docs: update documentation for v2.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: version 2.0.3 → 2.1.0 throughout; update developer API section to describe the paper-free API artifact, onSeasonReset callback, and the distinction between API JAR and plugin JAR for events - docs/index.md: version 2.0.3 → 2.1.0; improve Developer API feature row - docs/getting-started.md: update download filename to 2.1.0 - docs/api.md: already updated in the refactor commit --- README.md | 10 ++++++---- docs/getting-started.md | 2 +- docs/index.md | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5732b01..cf7b735 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It runs timed season resets, sends reminder broadcasts, and provides `/season` s - Optional companion plugin: [EzLifesteal](https://modrinth.com/plugin/ezlifesteal) - Supported server software: Paper / Bukkit-compatible server - Requires: Java 25, Minecraft 26.1+ -- Plugin version: 2.0.3 +- Plugin version: 2.1.0 ## Quick setup (server owners) @@ -92,7 +92,9 @@ season: ## For developers (optional) -EzSeasons exposes a Bukkit service API (`SeasonsApi`) for integrations. +EzSeasons exposes a Bukkit service API (`SeasonsApi`) for integrations. The API artifact is **pure Java** — it has no Bukkit/Paper dependency, so it stays compatible regardless of server version. + +Integrated plugins receive lifecycle callbacks (`onRegister`, `onUnregister`, `onSeasonReset`) via the `SeasonsIntegration` interface, and can also listen to Bukkit events (`SeasonResetEvent`, etc.) which are included in the plugin JAR. ### Dependency options @@ -115,7 +117,7 @@ You can consume the API artifact in two ways: com.skyblockexp.lifesteal ezseasons-api - 2.0.3 + 2.1.0 provided @@ -135,7 +137,7 @@ You can consume the API artifact in two ways: com.github.ez-plugins.EzSeasons ezseasons-api - v2.0.3 + v2.1.0 provided diff --git a/docs/getting-started.md b/docs/getting-started.md index 75ba02c..07722bf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,7 +29,7 @@ EzSeasons does **not** require EzLifesteal. It is fully standalone. ## Installation 1. Stop your server. -2. Download `EzSeasons-2.0.3.jar` from [Modrinth](https://modrinth.com/plugin/ezseasons). +2. Download `EzSeasons-2.1.0.jar` from [Modrinth](https://modrinth.com/plugin/ezseasons). 3. Copy the jar into your `plugins/` folder. 4. *(Optional)* Install [EzLifesteal](https://modrinth.com/plugin/ezlifesteal) if you want Lifesteal heart-reset behavior on each season reset. 5. Start the server once. EzSeasons generates `plugins/EzSeasons/config.yml` and the `messages/` folder. diff --git a/docs/index.md b/docs/index.md index bab26fc..fa193f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ It schedules timed season resets, broadcasts announcements, sends configurable c | `/season` command | Players can check the time remaining until the next reset | | Admin subcommands | `reload`, `reset`, `setnext`, `clear-next`, `status` | | Multi-language support | `en`, `es`, `fr`, `zh`, `ru`, `nl` | -| Developer API | Bukkit service + Bukkit events; no runtime class-loading tricks | +| Developer API | Pure-Java API artifact; `SeasonsIntegration` callbacks (`onRegister`, `onUnregister`, `onSeasonReset`); Bukkit events in plugin JAR | --- @@ -34,7 +34,7 @@ It schedules timed season resets, broadcasts announcements, sends configurable c |---|---| | Minecraft / Paper | 26.1 or later | | Java | 25 or later | -| Plugin version | 2.0.3 | +| Plugin version | 2.1.0 | --- From d36bff24343ae26f49edcd490765190fcc05356a Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sun, 19 Apr 2026 22:22:22 +0200 Subject: [PATCH 2/2] test(api): cover onSeasonReset callback path; add Codecov SeasonResetCallbackUnitTest (11 tests): - singleIntegrationReceivesOnSeasonResetAfterTrigger - allRegisteredIntegrationsReceiveOnSeasonReset - unregisteredIntegrationNoLongerReceivesCallback - noRegisteredIntegrationDoesNotThrowAndEventStillFires - nullReasonNormalisedToUnspecifiedForCallback - blankReasonPassedThroughWithoutNormalisationForCallback - onSeasonResetReceivesTimestampsThatMatchTheBukkitEvent - bukkitEventFiresBeforeIntegrationCallback - nullPluginReferenceInSeasonManagerSkipsCallbackGracefully - nullApiFromPluginSkipsCallbacksWithoutNpe - defaultOnSeasonResetIsNoOp Total: 97 -> 108 tests passing. Codecov: - .codecov.yml: coverage thresholds, comment layout, test/ ignore - tests.yml: add codecov/codecov-action@v5 step after JaCoCo report in unit-test-coverage job; uploads plugin/target/site/jacoco/jacoco.xml --- .codecov.yml | 22 ++ .github/workflows/tests.yml | 7 + .../seasons/SeasonResetCallbackUnitTest.java | 299 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 .codecov.yml create mode 100644 plugin/src/test/java/com/skyblockexp/lifesteal/seasons/SeasonResetCallbackUnitTest.java diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..c6d1cf6 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,22 @@ +coverage: + precision: 2 + round: down + range: "60...100" + status: + project: + default: + target: auto + threshold: 2% + patch: + default: + target: auto + threshold: 1% + +comment: + layout: "condensed_header, condensed_tree, condensed_diff" + behavior: default + require_changes: yes + require_base: yes + +ignore: + - "src/test/**" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45f1d78..ff75d8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,6 +84,13 @@ jobs: name: unit-test-coverage-report path: plugin/target/site/jacoco if-no-files-found: error + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: plugin/target/site/jacoco/jacoco.xml + flags: unittests + fail_ci_if_error: true translation-tests: name: Translation coverage diff --git a/plugin/src/test/java/com/skyblockexp/lifesteal/seasons/SeasonResetCallbackUnitTest.java b/plugin/src/test/java/com/skyblockexp/lifesteal/seasons/SeasonResetCallbackUnitTest.java new file mode 100644 index 0000000..efa882a --- /dev/null +++ b/plugin/src/test/java/com/skyblockexp/lifesteal/seasons/SeasonResetCallbackUnitTest.java @@ -0,0 +1,299 @@ +package com.skyblockexp.lifesteal.seasons; + +import com.skyblockexp.lifesteal.seasons.api.SeasonsApi; +import com.skyblockexp.lifesteal.seasons.api.SeasonsIntegration; +import com.skyblockexp.lifesteal.seasons.api.events.SeasonResetEvent; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Covers the API-to-plugin integration-callback path for + * {@link SeasonsIntegration#onSeasonReset} introduced when paper-api was removed + * from the API module. + */ +class SeasonResetCallbackUnitTest { + + private ServerMock server; + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + // ------------------------------------------------------------------------- + // Basic invocation + // ------------------------------------------------------------------------- + + @Test + void singleIntegrationReceivesOnSeasonResetAfterTrigger() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + RecordingIntegration integration = new RecordingIntegration(); + api.registerIntegration(integration); + + api.triggerSeasonReset("seasonal"); + + assertEquals(1, integration.resetCallCount); + assertEquals("seasonal", integration.lastReason); + } + + @Test + void allRegisteredIntegrationsReceiveOnSeasonReset() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + RecordingIntegration first = new RecordingIntegration(); + RecordingIntegration second = new RecordingIntegration(); + RecordingIntegration third = new RecordingIntegration(); + api.registerIntegration(first); + api.registerIntegration(second); + api.registerIntegration(third); + + api.triggerSeasonReset("multi"); + + assertEquals(1, first.resetCallCount); + assertEquals(1, second.resetCallCount); + assertEquals(1, third.resetCallCount); + } + + @Test + void unregisteredIntegrationNoLongerReceivesCallback() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + RecordingIntegration integration = new RecordingIntegration(); + api.registerIntegration(integration); + api.unregisterIntegration(integration); + + api.triggerSeasonReset("after-unregister"); + + assertEquals(0, integration.resetCallCount); + } + + @Test + void noRegisteredIntegrationDoesNotThrowAndEventStillFires() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + ResetCapture capture = new ResetCapture(); + server.getPluginManager().registerEvents(capture, MockBukkit.createMockPlugin()); + + assertDoesNotThrow(() -> api.triggerSeasonReset("no-integrations")); + + assertNotNull(capture.event); + } + + // ------------------------------------------------------------------------- + // Reason normalisation + // ------------------------------------------------------------------------- + + @Test + void nullReasonNormalisedToUnspecifiedForCallback() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + RecordingIntegration integration = new RecordingIntegration(); + api.registerIntegration(integration); + + api.triggerSeasonReset(null); + + assertEquals("unspecified", integration.lastReason); + } + + @Test + void blankReasonPassedThroughWithoutNormalisationForCallback() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + RecordingIntegration integration = new RecordingIntegration(); + api.registerIntegration(integration); + + api.triggerSeasonReset(" "); + + assertEquals(" ", integration.lastReason); + } + + // ------------------------------------------------------------------------- + // Timestamp accuracy + // ------------------------------------------------------------------------- + + @Test + void onSeasonResetReceivesTimestampsThatMatchTheBukkitEvent() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + ResetCapture capture = new ResetCapture(); + server.getPluginManager().registerEvents(capture, MockBukkit.createMockPlugin()); + RecordingIntegration integration = new RecordingIntegration(); + api.registerIntegration(integration); + + long beforeTrigger = System.currentTimeMillis(); + api.triggerSeasonReset("ts-check"); + + assertNotNull(capture.event); + assertEquals(capture.event.getPreviousResetMillis(), integration.lastPreviousResetMillis, + "previousResetMillis must match between Bukkit event and callback"); + assertEquals(capture.event.getResetMillis(), integration.lastResetMillis, + "resetMillis must match between Bukkit event and callback"); + assertEquals(capture.event.getNextResetMillis(), integration.lastNextResetMillis, + "nextResetMillis must match between Bukkit event and callback"); + assertTrue(integration.lastResetMillis >= beforeTrigger, + "resetMillis must be at or after when triggerSeasonReset was called"); + } + + // ------------------------------------------------------------------------- + // Ordering guarantee: Bukkit event fires before callback + // ------------------------------------------------------------------------- + + @Test + void bukkitEventFiresBeforeIntegrationCallback() { + server = MockBukkit.mock(); + EzSeasonsPlugin plugin = MockBukkit.load(EzSeasonsPlugin.class); + SeasonsApi api = requireApi(server); + + List order = new ArrayList<>(); + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onReset(SeasonResetEvent e) { + order.add("event"); + } + }, MockBukkit.createMockPlugin()); + + api.registerIntegration(new SeasonsIntegration() { + @Override + public void onRegister(SeasonsApi a) { + } + + @Override + public void onUnregister() { + } + + @Override + public void onSeasonReset(long prev, long at, long next, String reason) { + order.add("callback"); + } + }); + + api.triggerSeasonReset("order-test"); + + assertEquals(List.of("event", "callback"), order, + "SeasonResetEvent must fire before onSeasonReset callbacks"); + } + + // ------------------------------------------------------------------------- + // Null-guard paths + // ------------------------------------------------------------------------- + + @Test + void nullPluginReferenceInSeasonManagerSkipsCallbackGracefully() { + // No Bukkit server — Bukkit.getServer() is null here; verified by existing tests. + // This test focuses on the null-plugin guard in SeasonManager. + SeasonManager manager = new SeasonManager(null, new MemoryConfiguration(), null); + RecordingIntegration integration = new RecordingIntegration(); + + assertDoesNotThrow(() -> manager.triggerSeasonReset("null-plugin"), + "null plugin must not throw"); + assertEquals(0, integration.resetCallCount, + "callback must not fire when plugin is null"); + } + + @Test + void nullApiFromPluginSkipsCallbacksWithoutNpe() { + server = MockBukkit.mock(); + // A plugin mock that returns null for getSeasonsApi() simulates a partially + // initialised plugin (e.g. Bootstrap has not yet registered the API). + EzSeasonsPlugin mockPlugin = mock(EzSeasonsPlugin.class); + when(mockPlugin.getSeasonsApi()).thenReturn(null); + RecordingIntegration integration = new RecordingIntegration(); + + SeasonManager manager = new SeasonManager(mockPlugin, new MemoryConfiguration(), null); + + assertDoesNotThrow(() -> manager.triggerSeasonReset("null-api"), + "null SeasonsApi must not throw"); + assertEquals(0, integration.resetCallCount, + "callback must not fire when api is null"); + } + + // ------------------------------------------------------------------------- + // Default interface method + // ------------------------------------------------------------------------- + + @Test + void defaultOnSeasonResetIsNoOp() { + // Verify that the default method compiles and does not throw. + SeasonsIntegration anon = new SeasonsIntegration() { + @Override + public void onRegister(SeasonsApi api) { + } + + @Override + public void onUnregister() { + } + // onSeasonReset intentionally NOT overridden — exercises the default + }; + + assertDoesNotThrow(() -> anon.onSeasonReset(100L, 200L, 300L, "default-noop")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static SeasonsApi requireApi(ServerMock server) { + RegisteredServiceProvider rsp = + server.getServicesManager().getRegistration(SeasonsApi.class); + assertNotNull(rsp, "SeasonsApi service must be registered after plugin load"); + return rsp.getProvider(); + } + + private static final class RecordingIntegration implements SeasonsIntegration { + int resetCallCount; + long lastPreviousResetMillis; + long lastResetMillis; + long lastNextResetMillis; + String lastReason; + + @Override + public void onRegister(SeasonsApi api) { + } + + @Override + public void onUnregister() { + } + + @Override + public void onSeasonReset(long previousResetMillis, long resetMillis, long nextResetMillis, String reason) { + resetCallCount++; + lastPreviousResetMillis = previousResetMillis; + lastResetMillis = resetMillis; + lastNextResetMillis = nextResetMillis; + lastReason = reason; + } + } + + private static final class ResetCapture implements Listener { + SeasonResetEvent event; + + @EventHandler + public void onReset(SeasonResetEvent event) { + this.event = event; + } + } +}