diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d72813..63cb3e3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,66 +5,6 @@ on:
branches: [ main, master ]
jobs:
- unit-tests:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- include:
- - paper-version: "1.21.11-R0.1-SNAPSHOT"
- mockbukkit-artifactId: "mockbukkit-v1.21"
- mockbukkit-version: "4.101.0"
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Set up JDK 21
- uses: actions/setup-java@v5
- with:
- distribution: temurin
- java-version: '21'
-
- - name: Cache Maven local repository
- uses: actions/cache@v5
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-${{ matrix.paper-version }}-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-m2-${{ matrix.paper-version }}-
-
- - name: Run unit tests
- run: mvn -B -Dpaper.version=${{ matrix.paper-version }} -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }} -Dmockbukkit.version=${{ matrix.mockbukkit-version }} test
-
- feature-tests:
- runs-on: ubuntu-latest
- needs: unit-tests
- strategy:
- matrix:
- include:
- - paper-version: "1.21.11-R0.1-SNAPSHOT"
- mockbukkit-artifactId: "mockbukkit-v1.21"
- mockbukkit-version: "4.101.0"
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Set up JDK 21
- uses: actions/setup-java@v5
- with:
- distribution: temurin
- java-version: '21'
-
- - name: Cache Maven local repository
- uses: actions/cache@v5
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-feature-${{ matrix.paper-version }}-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-m2-feature-${{ matrix.paper-version }}-
-
- - name: Run feature tests
- run: mvn -B -Dpaper.version=${{ matrix.paper-version }} -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }} -Dmockbukkit.version=${{ matrix.mockbukkit-version }} -Pfeature-tests -Dtest=*FeatureTest test
# ─────────────────────────────────────────────────────────────────────────
# Smoke tests — Paper & Folia (downloaded from api.papermc.io)
@@ -73,7 +13,7 @@ jobs:
smoke-papermc:
name: "Smoke · ${{ matrix.platform }} / MC ${{ matrix.mc-prefix }} / Java ${{ matrix.java }}"
runs-on: ubuntu-latest
- needs: [unit-tests, feature-tests]
+ needs: []
strategy:
fail-fast: false
matrix:
@@ -205,7 +145,7 @@ jobs:
smoke-spigot:
name: "Smoke · spigot / MC ${{ matrix.mc-prefix }} / Java ${{ matrix.java }}"
runs-on: ubuntu-latest
- needs: [unit-tests, feature-tests]
+ needs: []
strategy:
fail-fast: false
matrix:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..be904a1
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,104 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [ main, master ]
+ push:
+ branches: [ main, master ]
+
+permissions:
+ contents: read
+
+jobs:
+ unit-tests-coverage:
+ name: Unit Tests + Coverage
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - paper-version: "1.21.11-R0.1-SNAPSHOT"
+ mockbukkit-artifactId: "mockbukkit-v1.21"
+ mockbukkit-version: "4.101.0"
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Run unit tests with JaCoCo
+ run: >
+ mvn -B -ntp
+ -Dpaper.version=${{ matrix.paper-version }}
+ -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }}
+ -Dmockbukkit.version=${{ matrix.mockbukkit-version }}
+ jacoco:prepare-agent test jacoco:report
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: unit-coverage-report
+ path: target/site/jacoco/jacoco.xml
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Upload unit coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: target/site/jacoco/jacoco.xml
+ flags: unit-tests
+ name: unit-tests
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ feature-tests-coverage:
+ name: Feature Tests + Coverage
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - paper-version: "1.21.11-R0.1-SNAPSHOT"
+ mockbukkit-artifactId: "mockbukkit-v1.21"
+ mockbukkit-version: "4.101.0"
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Run feature tests with JaCoCo
+ run: >
+ mvn -B -ntp
+ -Dpaper.version=${{ matrix.paper-version }}
+ -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }}
+ -Dmockbukkit.version=${{ matrix.mockbukkit-version }}
+ -Pfeature-tests -Dtest=*FeatureTest
+ jacoco:prepare-agent test jacoco:report
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: feature-coverage-report
+ path: target/site/jacoco/jacoco.xml
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Upload feature coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: target/site/jacoco/jacoco.xml
+ flags: feature-tests
+ name: feature-tests
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 144be2a..5c08866 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,7 +29,10 @@ pom.xml.next
*.iml
*.ipr
*.iws
-/.vscode/
+/.vscode/*
+!/.vscode/settings.json
+!/.vscode/tasks.json
+!/.vscode/extensions.json
/out/
# Eclipse
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..67ef0e4
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "ryanluker.vscode-coverage-gutters"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..026848b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+ "java.debug.settings.onBuildFailureProceed": true,
+ "java.configuration.updateBuildConfiguration": "automatic",
+ "coverage-gutters.coverageBaseDir": "${workspaceFolder}",
+ "coverage-gutters.coverageFileNames": [
+ "jacoco.xml"
+ ],
+ "coverage-gutters.showLineCoverage": true,
+ "coverage-gutters.showRulerCoverage": true,
+ "coverage-gutters.showGutterCoverage": true
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..b1eb56d
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,35 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Tests: Unit Coverage (JaCoCo)",
+ "type": "shell",
+ "command": "mvn",
+ "args": [
+ "-B",
+ "-ntp",
+ "jacoco:prepare-agent",
+ "test",
+ "jacoco:report"
+ ],
+ "group": "test",
+ "problemMatcher": []
+ },
+ {
+ "label": "Tests: Feature Coverage (JaCoCo)",
+ "type": "shell",
+ "command": "mvn",
+ "args": [
+ "-B",
+ "-ntp",
+ "-Pfeature-tests",
+ "-Dtest=*FeatureTest",
+ "jacoco:prepare-agent",
+ "test",
+ "jacoco:report"
+ ],
+ "group": "test",
+ "problemMatcher": []
+ }
+ ]
+}
diff --git a/README.md b/README.md
index d83a161..3876ac9 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# EzCountdown
-[](https://github.com/ez-plugins/EzCountdown/actions/workflows/ci.yml) [](https://github.com/ez-plugins/EzCountdown/releases) [](https://github.com/ez-plugins/EzCountdown/blob/main/LICENSE) [](https://github.com/ez-plugins/EzCountdown/issues)
+[](https://github.com/ez-plugins/EzCountdown/actions/workflows/ci.yml) [](https://codecov.io/gh/ez-plugins/EzCountdown) [](https://github.com/ez-plugins/EzCountdown/releases) [](https://github.com/ez-plugins/EzCountdown/blob/main/LICENSE) [](https://github.com/ez-plugins/EzCountdown/issues)
EzCountdown provides configurable countdown timers for events, launches, and maintenance windows across your server.
diff --git a/pom.xml b/pom.xml
index 9e51041..acdea45 100644
--- a/pom.xml
+++ b/pom.xml
@@ -175,12 +175,23 @@
3.0.0-M7
false
- -Dnet.bytebuddy.experimental=true
+ ${argLine} -Dnet.bytebuddy.experimental=true
+
+ **/*Test.java
+ **/*Tests.java
+ **/*TestCase.java
+ **/*UnitTest.java
+
**/*FeatureTest.java
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
org.apache.maven.plugins
maven-resources-plugin
@@ -312,6 +323,7 @@
maven-surefire-plugin
3.0.0-M7
+ ${argLine} -Dnet.bytebuddy.experimental=true
**/*FeatureTest.java
diff --git a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundAction.java b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundAction.java
index 2dd7779..de2f127 100644
--- a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundAction.java
+++ b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundAction.java
@@ -39,13 +39,14 @@ public ActionResult handle(InventoryClickEvent event, Player player, String cdNa
registry.scheduler().runTask(() -> registry.gui().editorMenu().openEditor(player, cd));
return;
}
- try {
- Sound.valueOf(value.toUpperCase(Locale.ROOT));
+ String normalized = value.toUpperCase(Locale.ROOT);
+ boolean exists = java.util.Arrays.stream(Sound.values()).anyMatch(s -> s.name().equals(normalized));
+ if (exists) {
cd.setEndSound(value.toUpperCase(Locale.ROOT));
manager.save();
player.sendMessage(messageManager.message("gui.edit.saved", java.util.Map.of("name", cdName)));
registry.scheduler().runTask(() -> registry.gui().editorMenu().openEditor(player, cd));
- } catch (IllegalArgumentException ex) {
+ } else {
player.sendMessage(org.bukkit.ChatColor.RED + "Invalid sound name: " + value);
SoundEditorHelper.sendAvailableSounds(player);
}
diff --git a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundAction.java b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundAction.java
index 322163f..a794197 100644
--- a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundAction.java
+++ b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundAction.java
@@ -39,13 +39,14 @@ public ActionResult handle(InventoryClickEvent event, Player player, String cdNa
registry.scheduler().runTask(() -> registry.gui().editorMenu().openEditor(player, cd));
return;
}
- try {
- Sound.valueOf(value.toUpperCase(Locale.ROOT));
+ String normalized = value.toUpperCase(Locale.ROOT);
+ boolean exists = java.util.Arrays.stream(Sound.values()).anyMatch(s -> s.name().equals(normalized));
+ if (exists) {
cd.setStartSound(value.toUpperCase(Locale.ROOT));
manager.save();
player.sendMessage(messageManager.message("gui.edit.saved", java.util.Map.of("name", cdName)));
registry.scheduler().runTask(() -> registry.gui().editorMenu().openEditor(player, cd));
- } catch (IllegalArgumentException ex) {
+ } else {
player.sendMessage(org.bukkit.ChatColor.RED + "Invalid sound name: " + value);
SoundEditorHelper.sendAvailableSounds(player);
}
diff --git a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/SoundEditorHelper.java b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/SoundEditorHelper.java
index ca1821a..138e414 100644
--- a/src/main/java/com/skyblockexp/ezcountdown/listener/actions/SoundEditorHelper.java
+++ b/src/main/java/com/skyblockexp/ezcountdown/listener/actions/SoundEditorHelper.java
@@ -33,18 +33,6 @@ static void sendAvailableSounds(Player player) {
}
private static String soundName(Sound sound) {
- if (sound == null) {
- return "";
- }
- try {
- java.lang.reflect.Method nameMethod = sound.getClass().getMethod("name");
- Object value = nameMethod.invoke(sound);
- if (value instanceof String s) {
- return s;
- }
- } catch (Exception ignored) {
- // Fallback for API variants where Sound is not a Java enum type.
- }
- return String.valueOf(sound);
+ return sound == null ? "" : sound.name();
}
}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrapVersionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrapVersionUnitTest.java
new file mode 100644
index 0000000..6039c0b
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/bootstrap/PluginBootstrapVersionUnitTest.java
@@ -0,0 +1,61 @@
+package com.skyblockexp.ezcountdown.bootstrap;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PluginBootstrapVersionUnitTest {
+
+ @Test
+ void isNewerVersionReturnsTrueForHigherPatch() throws Exception {
+ assertTrue(invokeIsNewerVersion("2.0.1", "2.0.2"));
+ }
+
+ @Test
+ void isNewerVersionReturnsTrueForHigherMinor() throws Exception {
+ assertTrue(invokeIsNewerVersion("2.0.9", "2.1.0"));
+ }
+
+ @Test
+ void isNewerVersionReturnsFalseForLowerPatch() throws Exception {
+ assertFalse(invokeIsNewerVersion("2.0.2", "2.0.1"));
+ }
+
+ @Test
+ void isNewerVersionReturnsFalseForEqualVersion() throws Exception {
+ assertFalse(invokeIsNewerVersion("2.0.2", "2.0.2"));
+ }
+
+ @Test
+ void isNewerVersionHandlesSuffixes() throws Exception {
+ assertTrue(invokeIsNewerVersion("2.0.2", "2.0.3-RC1"));
+ assertFalse(invokeIsNewerVersion("2.0.3-RC1", "2.0.2"));
+ }
+
+ @Test
+ void parseIntSafeReturnsDigitsOnlyPrefix() throws Exception {
+ assertEquals(2501, invokeParseIntSafe("25-R0.1-SNAPSHOT"));
+ }
+
+ @Test
+ void parseIntSafeReturnsZeroForNonNumeric() throws Exception {
+ assertEquals(0, invokeParseIntSafe("alpha"));
+ assertEquals(0, invokeParseIntSafe(""));
+ }
+
+ private static boolean invokeIsNewerVersion(String current, String latest) throws Exception {
+ Method m = PluginBootstrap.class.getDeclaredMethod("isNewerVersion", String.class, String.class);
+ m.setAccessible(true);
+ return (boolean) m.invoke(null, current, latest);
+ }
+
+ private static int invokeParseIntSafe(String value) throws Exception {
+ Method m = PluginBootstrap.class.getDeclaredMethod("parseIntSafe", String.class);
+ m.setAccessible(true);
+ return (int) m.invoke(null, value);
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/command/subcommand/CreateSubcommandBranchTest.java b/src/test/java/com/skyblockexp/ezcountdown/command/subcommand/CreateSubcommandBranchTest.java
new file mode 100644
index 0000000..bff7c3a
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/command/subcommand/CreateSubcommandBranchTest.java
@@ -0,0 +1,98 @@
+package com.skyblockexp.ezcountdown.command.subcommand;
+
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.display.DisplayType;
+import com.skyblockexp.ezcountdown.test.MockBukkitTestBase;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.Test;
+
+import java.util.EnumSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreateSubcommandBranchTest extends MockBukkitTestBase {
+
+ @Test
+ void createWithoutPermissionDoesNotCreate() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+ var player = addPlayer("no-perm-create");
+
+ sub.execute(player, new String[]{"create", "not-created", "duration", "60s"});
+
+ assertFalse(manager.getCountdown("not-created").isPresent());
+ }
+
+ @Test
+ void createWithDisplayOverrideUsesOnlyProvidedDisplayType() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+ CommandSender sender = server.getConsoleSender();
+
+ sub.execute(sender, new String[]{"create", "display-override", "duration", "60s", "--display", "chat"});
+
+ var created = manager.getCountdown("display-override");
+ assertTrue(created.isPresent());
+ assertEquals(EnumSet.of(DisplayType.CHAT), created.orElseThrow().getDisplayTypes());
+ }
+
+ @Test
+ void createWithInvalidDisplayOverrideDoesNotCreate() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+
+ sub.execute(server.getConsoleSender(), new String[]{"create", "bad-display", "duration", "60s", "--display", "nope"});
+
+ assertFalse(manager.getCountdown("bad-display").isPresent());
+ }
+
+ @Test
+ void createRecurringWithInvalidTimezoneDoesNotCreate() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+
+ sub.execute(server.getConsoleSender(), new String[]{"create", "bad-tz", "recurring", "12", "31"});
+
+ assertFalse(manager.getCountdown("bad-tz").isPresent());
+ }
+
+ @Test
+ void createFixedDateFromTokenParsesAndCreatesCountdown() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+
+ sub.execute(server.getConsoleSender(), new String[]{"create", "fixed-token", "2026-12-31", "23:59"});
+
+ var created = manager.getCountdown("fixed-token");
+ assertTrue(created.isPresent());
+ assertEquals(CountdownType.FIXED_DATE, created.orElseThrow().getType());
+ assertNotNull(created.orElseThrow().getTargetInstant());
+ }
+
+ @Test
+ void createRecurringWithInvalidMissedRunPolicyDoesNotCreate() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+
+ assertThrows(java.time.format.DateTimeParseException.class,
+ () -> sub.execute(server.getConsoleSender(), new String[]{"create", "bad-policy", "recurring", "1", "2", "not-a-time"}));
+
+ assertFalse(manager.getCountdown("bad-policy").isPresent());
+ }
+
+ @Test
+ void tabCompleteReturnsMatchingTypeSuggestions() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+ var values = sub.tabComplete(server.getConsoleSender(), new String[]{"create", "name", "d"});
+ assertEquals(java.util.List.of("duration"), values);
+ }
+
+ @Test
+ void createDuplicateNameDoesNotCreateSecondCountdown() {
+ CreateSubcommand sub = new CreateSubcommand(registry);
+ sub.execute(server.getConsoleSender(), new String[]{"create", "dupe", "duration", "60s"});
+ sub.execute(server.getConsoleSender(), new String[]{"create", "dupe", "duration", "120s"});
+
+ var stored = manager.getCountdown("dupe");
+ assertTrue(stored.isPresent());
+ assertEquals(60L, stored.orElseThrow().getDurationSeconds());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/compat/material/MaterialCompatTest.java b/src/test/java/com/skyblockexp/ezcountdown/compat/material/MaterialCompatTest.java
new file mode 100644
index 0000000..dd1fd98
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/compat/material/MaterialCompatTest.java
@@ -0,0 +1,33 @@
+package com.skyblockexp.ezcountdown.compat.material;
+
+import org.bukkit.Material;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class MaterialCompatTest {
+
+ @Test
+ void resolveReturnsFirstValidMaterial() {
+ Material resolved = MaterialCompat.resolve("NOT_A_REAL_MATERIAL", "PAPER", "STONE");
+ assertEquals(Material.PAPER, resolved);
+ }
+
+ @Test
+ void resolveSkipsNullNamesAndFindsValid() {
+ Material resolved = MaterialCompat.resolve(null, "STONE");
+ assertEquals(Material.STONE, resolved);
+ }
+
+ @Test
+ void resolveFallsBackToPaperWhenNamesInvalid() {
+ Material resolved = MaterialCompat.resolve("INVALID_ONE", "INVALID_TWO");
+ assertEquals(Material.PAPER, resolved);
+ }
+
+ @Test
+ void resolveFallsBackToPaperWhenNamesArrayIsNull() {
+ Material resolved = MaterialCompat.resolve((String[]) null);
+ assertEquals(Material.PAPER, resolved);
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorFeatureTest.java b/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorFeatureTest.java
new file mode 100644
index 0000000..e264204
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorFeatureTest.java
@@ -0,0 +1,102 @@
+package com.skyblockexp.ezcountdown.gui;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.test.MockBukkitTestBase;
+import com.skyblockexp.ezcountdown.util.MaterialCompat;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class CommandsEditorFeatureTest extends MockBukkitTestBase {
+
+ @Test
+ void openCommandsEditorPopulatesCommandItemsAndAddButton() {
+ Countdown countdown = new Countdown(
+ "cmd-edit",
+ CountdownType.MANUAL,
+ EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class),
+ 1,
+ null,
+ "{formatted}",
+ "start",
+ "end",
+ List.of("say first", "say second"),
+ ZoneId.systemDefault()
+ );
+
+ CommandsEditor editor = new CommandsEditor(manager, registry.gui().chatInputListener(), registry.messages(), registry);
+ var player = addPlayer("cmd-editor-player");
+
+ editor.openCommandsEditor(player, countdown);
+
+ Inventory top = player.getOpenInventory().getTopInventory();
+ assertNotNull(top);
+ assertEquals(9, top.getSize(), "Two command entries should still use one inventory row");
+ assertEquals(CommandsEditor.getPrefix() + "cmd-edit", player.getOpenInventory().getTitle());
+
+ ItemStack first = top.getItem(0);
+ assertNotNull(first);
+ assertEquals(Material.PAPER, first.getType());
+ assertNotNull(first.getItemMeta());
+ assertEquals(ChatColor.WHITE + "say first", first.getItemMeta().getDisplayName());
+
+ ItemStack second = top.getItem(1);
+ assertNotNull(second);
+ assertEquals(Material.PAPER, second.getType());
+ assertNotNull(second.getItemMeta());
+ assertEquals(ChatColor.WHITE + "say second", second.getItemMeta().getDisplayName());
+
+ ItemStack addButton = top.getItem(8);
+ assertNotNull(addButton);
+ assertEquals(MaterialCompat.resolve("GREEN_WOOL", "LIME_WOOL", "WOOL", "PAPER"), addButton.getType());
+ assertNotNull(addButton.getItemMeta());
+ assertEquals(ChatColor.GREEN + "Add Command", addButton.getItemMeta().getDisplayName());
+ }
+
+ @Test
+ void openCommandsEditorExpandsInventoryForManyCommands() {
+ List commands = java.util.stream.IntStream.range(0, 14)
+ .mapToObj(i -> "say c" + i)
+ .toList();
+
+ Countdown countdown = new Countdown(
+ "cmd-many",
+ CountdownType.MANUAL,
+ EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class),
+ 1,
+ null,
+ "{formatted}",
+ "start",
+ "end",
+ commands,
+ ZoneId.systemDefault()
+ );
+
+ CommandsEditor editor = new CommandsEditor(manager, registry.gui().chatInputListener(), registry.messages(), registry);
+ var player = addPlayer("cmd-many-player");
+
+ editor.openCommandsEditor(player, countdown);
+
+ Inventory top = player.getOpenInventory().getTopInventory();
+ assertNotNull(top);
+ assertEquals(18, top.getSize(), "14 commands + add button should require two rows");
+
+ ItemStack lastCommand = top.getItem(13);
+ assertNotNull(lastCommand);
+ assertEquals(Material.PAPER, lastCommand.getType());
+
+ ItemStack addButton = top.getItem(17);
+ assertNotNull(addButton);
+ assertEquals(ChatColor.GREEN + "Add Command", addButton.getItemMeta().getDisplayName());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorUnitTest.java
new file mode 100644
index 0000000..37d5547
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/gui/CommandsEditorUnitTest.java
@@ -0,0 +1,66 @@
+package com.skyblockexp.ezcountdown.gui;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.display.DisplayType;
+import com.skyblockexp.ezcountdown.util.MaterialCompat;
+import org.bukkit.inventory.Inventory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class CommandsEditorUnitTest {
+
+ private ServerMock server;
+
+ @BeforeEach
+ void setUp() {
+ server = MockBukkit.mock();
+ }
+
+ @AfterEach
+ void tearDown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void openCommandsEditorBuildsInventoryAndAddButton() {
+ Countdown countdown = new Countdown(
+ "unit-cmd",
+ CountdownType.MANUAL,
+ EnumSet.noneOf(DisplayType.class),
+ 1,
+ null,
+ "{formatted}",
+ "start",
+ "end",
+ List.of("say hello"),
+ ZoneId.systemDefault()
+ );
+
+ CommandsEditor editor = new CommandsEditor(null, null, null, null);
+ var player = server.addPlayer("editor-unit-player");
+
+ editor.openCommandsEditor(player, countdown);
+
+ Inventory top = player.getOpenInventory().getTopInventory();
+ assertNotNull(top);
+ assertEquals(9, top.getSize());
+ assertEquals(CommandsEditor.getPrefix() + "unit-cmd", player.getOpenInventory().getTitle());
+ assertEquals("say hello", org.bukkit.ChatColor.stripColor(top.getItem(0).getItemMeta().getDisplayName()));
+
+ var add = top.getItem(8);
+ assertNotNull(add);
+ assertEquals(MaterialCompat.resolve("GREEN_WOOL", "LIME_WOOL", "WOOL", "PAPER"), add.getType());
+ assertEquals("Add Command", org.bukkit.ChatColor.stripColor(add.getItemMeta().getDisplayName()));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorFeatureTest.java b/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorFeatureTest.java
new file mode 100644
index 0000000..778e247
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorFeatureTest.java
@@ -0,0 +1,63 @@
+package com.skyblockexp.ezcountdown.gui;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.display.DisplayType;
+import com.skyblockexp.ezcountdown.test.MockBukkitTestBase;
+import com.skyblockexp.ezcountdown.util.MaterialCompat;
+import org.bukkit.ChatColor;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class DisplayEditorFeatureTest extends MockBukkitTestBase {
+
+ @Test
+ void openDisplayEditorUsesEnabledAndDisabledMaterials() {
+ EnumSet active = EnumSet.of(DisplayType.CHAT, DisplayType.BOSS_BAR);
+ Countdown countdown = new Countdown(
+ "display-edit",
+ CountdownType.MANUAL,
+ active,
+ 1,
+ null,
+ "{formatted}",
+ "start",
+ "end",
+ List.of(),
+ ZoneId.systemDefault()
+ );
+
+ DisplayEditor editor = new DisplayEditor(manager, registry.messages());
+ var player = addPlayer("display-editor-player");
+
+ editor.openDisplayEditor(player, countdown);
+
+ Inventory top = player.getOpenInventory().getTopInventory();
+ assertNotNull(top);
+ assertEquals(9, top.getSize());
+ assertEquals(DisplayEditor.getPrefix() + "display-edit", player.getOpenInventory().getTitle());
+
+ DisplayType[] values = DisplayType.values();
+ for (int i = 0; i < values.length; i++) {
+ DisplayType type = values[i];
+ ItemStack item = top.getItem(i);
+ assertNotNull(item, "Each display type should have an editor slot");
+ assertNotNull(item.getItemMeta());
+ assertEquals(ChatColor.AQUA + type.name(), item.getItemMeta().getDisplayName());
+
+ if (active.contains(type)) {
+ assertEquals(MaterialCompat.resolve("LIME_CONCRETE", "LIME_WOOL", "WOOL"), item.getType());
+ } else {
+ assertEquals(MaterialCompat.resolve("GRAY_CONCRETE", "GRAY_WOOL", "WOOL"), item.getType());
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorUnitTest.java
new file mode 100644
index 0000000..78172f7
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/gui/DisplayEditorUnitTest.java
@@ -0,0 +1,72 @@
+package com.skyblockexp.ezcountdown.gui;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.display.DisplayType;
+import com.skyblockexp.ezcountdown.util.MaterialCompat;
+import org.bukkit.inventory.Inventory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class DisplayEditorUnitTest {
+
+ private ServerMock server;
+
+ @BeforeEach
+ void setUp() {
+ server = MockBukkit.mock();
+ }
+
+ @AfterEach
+ void tearDown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void openDisplayEditorMarksActiveTypesWithEnabledMaterial() {
+ Countdown countdown = new Countdown(
+ "unit-display",
+ CountdownType.MANUAL,
+ EnumSet.of(DisplayType.ACTION_BAR, DisplayType.CHAT),
+ 1,
+ null,
+ "{formatted}",
+ "start",
+ "end",
+ List.of(),
+ ZoneId.systemDefault()
+ );
+
+ DisplayEditor editor = new DisplayEditor(null, null);
+ var player = server.addPlayer("display-unit-player");
+
+ editor.openDisplayEditor(player, countdown);
+
+ Inventory top = player.getOpenInventory().getTopInventory();
+ assertNotNull(top);
+ assertEquals(9, top.getSize());
+ assertEquals(DisplayEditor.getPrefix() + "unit-display", player.getOpenInventory().getTitle());
+
+ DisplayType[] values = DisplayType.values();
+ for (int i = 0; i < values.length; i++) {
+ var item = top.getItem(i);
+ assertNotNull(item);
+ assertEquals(values[i].name(), org.bukkit.ChatColor.stripColor(item.getItemMeta().getDisplayName()));
+ if (countdown.getDisplayTypes().contains(values[i])) {
+ assertEquals(MaterialCompat.resolve("LIME_CONCRETE", "LIME_WOOL", "WOOL"), item.getType());
+ } else {
+ assertEquals(MaterialCompat.resolve("GRAY_CONCRETE", "GRAY_WOOL", "WOOL"), item.getType());
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/integration/CountdownCoreFlowFeatureTest.java b/src/test/java/com/skyblockexp/ezcountdown/integration/CountdownCoreFlowFeatureTest.java
new file mode 100644
index 0000000..a4bad4d
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/integration/CountdownCoreFlowFeatureTest.java
@@ -0,0 +1,85 @@
+package com.skyblockexp.ezcountdown.integration;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownBuilder;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.test.MockBukkitTestBase;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CountdownCoreFlowFeatureTest extends MockBukkitTestBase {
+
+ @Test
+ void autoRestartRestartsCountdownAfterNaturalEnd() throws Exception {
+ Countdown looping = CountdownBuilder.builder("looping")
+ .type(CountdownType.DURATION)
+ .durationSeconds(20)
+ .formatMessage("{formatted}")
+ .startMessage("")
+ .endMessage("")
+ .autoRestart(true)
+ .restartDelaySeconds(0)
+ .build();
+
+ manager.createCountdown(looping);
+ assertTrue(manager.startCountdown("looping"));
+
+ looping.setTargetInstant(Instant.now().minusSeconds(1));
+ invokeManagerTick();
+ server.getScheduler().performTicks(2);
+
+ Countdown restarted = manager.getCountdown("looping").orElseThrow();
+ assertTrue(restarted.isRunning(), "countdown should be running again after auto-restart");
+ assertNotNull(restarted.getTargetInstant(), "auto-restarted countdown should have a target instant");
+ assertTrue(restarted.getTargetInstant().isAfter(Instant.now().minusSeconds(1)));
+ }
+
+ @Test
+ void endStartsConfiguredOtherCountdown() throws Exception {
+ Countdown target = CountdownBuilder.builder("target")
+ .type(CountdownType.MANUAL)
+ .durationSeconds(15)
+ .formatMessage("{formatted}")
+ .startMessage("")
+ .endMessage("")
+ .build();
+ manager.createCountdown(target);
+
+ Countdown trigger = CountdownBuilder.builder("trigger")
+ .type(CountdownType.MANUAL)
+ .durationSeconds(5)
+ .formatMessage("{formatted}")
+ .startMessage("")
+ .endMessage("")
+ .startCountdown("target")
+ .restartDelaySeconds(0)
+ .build();
+
+ manager.createCountdown(trigger);
+ assertTrue(manager.startCountdown("trigger"));
+
+ trigger.setTargetInstant(Instant.now().minusSeconds(1));
+ invokeManagerTick();
+ server.getScheduler().performTicks(2);
+
+ Countdown afterTrigger = manager.getCountdown("trigger").orElseThrow();
+ Countdown afterTarget = manager.getCountdown("target").orElseThrow();
+
+ assertFalse(afterTrigger.isRunning(), "trigger countdown should stop after end");
+ assertTrue(afterTarget.isRunning(), "target countdown should be started by start-countdown wiring");
+ assertNotNull(afterTarget.getTargetInstant(), "started target countdown should receive a target instant");
+ }
+
+ private void invokeManagerTick() throws Exception {
+ Method tick = CountdownManager.class.getDeclaredMethod("tick");
+ tick.setAccessible(true);
+ tick.invoke(manager);
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/ChatInputListenerUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/ChatInputListenerUnitTest.java
new file mode 100644
index 0000000..aeed517
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/ChatInputListenerUnitTest.java
@@ -0,0 +1,105 @@
+package com.skyblockexp.ezcountdown.listener;
+
+import com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter;
+import org.bukkit.entity.Player;
+import org.bukkit.event.player.AsyncPlayerChatEvent;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+
+public class ChatInputListenerUnitTest {
+
+ @Test
+ public void onPlayerChatWithNoPendingRequestDoesNothing() {
+ SchedulerAdapter scheduler = mock(SchedulerAdapter.class);
+ ChatInputListener listener = new ChatInputListener(mock(org.bukkit.plugin.Plugin.class), scheduler);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+ when(event.getMessage()).thenReturn("hello");
+ listener.onPlayerChat(event);
+
+ verify(event, org.mockito.Mockito.never()).setCancelled(true);
+ }
+
+ @Test
+ public void onPlayerChatCancelConsumesInputWithoutCallback() {
+ SchedulerAdapter scheduler = mock(SchedulerAdapter.class);
+ ChatInputListener listener = new ChatInputListener(mock(org.bukkit.plugin.Plugin.class), scheduler);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+ AtomicReference seen = new AtomicReference<>(null);
+
+ listener.request(player, seen::set);
+
+ AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+ when(event.getMessage()).thenReturn("cancel");
+ when(event.isAsynchronous()).thenReturn(false);
+ listener.onPlayerChat(event);
+
+ verify(event).setCancelled(true);
+ assertEquals(null, seen.get());
+ }
+
+ @Test
+ public void onPlayerChatSyncInvokesCallbackImmediately() {
+ SchedulerAdapter scheduler = mock(SchedulerAdapter.class);
+ ChatInputListener listener = new ChatInputListener(mock(org.bukkit.plugin.Plugin.class), scheduler);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+ AtomicReference seen = new AtomicReference<>(null);
+
+ listener.request(player, seen::set);
+
+ AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+ when(event.getMessage()).thenReturn("new value");
+ when(event.isAsynchronous()).thenReturn(false);
+ listener.onPlayerChat(event);
+
+ verify(event).setCancelled(true);
+ assertEquals("new value", seen.get());
+ }
+
+ @Test
+ public void onPlayerChatAsyncUsesScheduler() {
+ SchedulerAdapter scheduler = mock(SchedulerAdapter.class);
+ doAnswer(invocation -> {
+ Runnable r = invocation.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(org.mockito.ArgumentMatchers.any(Runnable.class));
+
+ ChatInputListener listener = new ChatInputListener(mock(org.bukkit.plugin.Plugin.class), scheduler);
+ Player player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+ AtomicReference seen = new AtomicReference<>(null);
+
+ listener.request(player, seen::set);
+
+ AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+ when(event.getMessage()).thenReturn("async value");
+ when(event.isAsynchronous()).thenReturn(true);
+ listener.onPlayerChat(event);
+
+ verify(event).setCancelled(true);
+ assertEquals("async value", seen.get());
+ verify(scheduler).runTask(org.mockito.ArgumentMatchers.any(Runnable.class));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerTest.java
new file mode 100644
index 0000000..19add97
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerTest.java
@@ -0,0 +1,172 @@
+package com.skyblockexp.ezcountdown.listener;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.gui.DisplayEditor;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import com.skyblockexp.ezcountdown.gui.MainGui;
+import com.skyblockexp.ezcountdown.listener.actions.ActionResult;
+import com.skyblockexp.ezcountdown.listener.actions.GuiAction;
+import com.skyblockexp.ezcountdown.listener.actions.GuiActionRegistry;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.ChatColor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.InventoryView;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GuiClickListenerTest {
+
+ @Test
+ public void handlesMainGuiMissingCountdownAndActionResult() throws Exception {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ GuiClickListener listener = buildListener(manager, messages);
+
+ GuiActionRegistry registryMock = mock(GuiActionRegistry.class);
+ GuiAction action = mock(GuiAction.class);
+ when(registryMock.forMainGuiClick(ClickType.LEFT)).thenReturn(Optional.of(action));
+ when(action.handle(any(), any(), eq("cd"), any())).thenReturn(new ActionResult(true, true, true, Optional.empty()));
+ setRegistry(listener, registryMock);
+
+ Player player = mock(Player.class);
+ InventoryClickEvent missing = mainGuiEvent(player, ClickType.LEFT, "cd");
+ when(manager.getCountdown("cd")).thenReturn(Optional.empty());
+ when(messages.message(eq("commands.info.missing"), any(Map.class))).thenReturn("missing");
+
+ listener.onInventoryClick(missing);
+
+ verify(player).sendMessage("missing");
+ verify(player).closeInventory();
+
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ when(manager.getCountdown("cd")).thenReturn(Optional.of(cd));
+
+ listener.onInventoryClick(mainGuiEvent(player, ClickType.LEFT, "cd"));
+
+ verify(manager).save();
+ verify(player, org.mockito.Mockito.atLeast(2)).closeInventory();
+ }
+
+ @Test
+ public void handlesEditorDisplayAndCommandsPaths() throws Exception {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ GuiClickListener listener = buildListener(manager, messages);
+
+ GuiActionRegistry registryMock = mock(GuiActionRegistry.class);
+ GuiAction action = mock(GuiAction.class);
+ when(action.handle(any(), any(), any(), any())).thenReturn(new ActionResult(true, true, true, Optional.empty()));
+ when(registryMock.forEditorSlot(3)).thenReturn(Optional.of(action));
+ when(registryMock.forDisplaySlot(4)).thenReturn(Optional.of(action));
+ when(registryMock.forCommands()).thenReturn(Optional.of(action));
+ setRegistry(listener, registryMock);
+
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ when(manager.getCountdown("cd")).thenReturn(Optional.of(cd));
+
+ listener.onInventoryClick(prefixedEvent(player, EditorMenu.getPrefix() + "cd", 3));
+ listener.onInventoryClick(prefixedEvent(player, DisplayEditor.getPrefix() + "cd", 4));
+ listener.onInventoryClick(prefixedEvent(player, CommandsEditor.getPrefix() + "cd", 2));
+
+ verify(manager, org.mockito.Mockito.atLeast(3)).save();
+ verify(player, org.mockito.Mockito.atLeast(3)).closeInventory();
+ }
+
+ @Test
+ public void coversNoActionAndUnknownTitleBranches() throws Exception {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ GuiClickListener listener = buildListener(manager, messages);
+
+ GuiActionRegistry registryMock = mock(GuiActionRegistry.class);
+ when(registryMock.forMainGuiClick(ClickType.MIDDLE)).thenReturn(Optional.empty());
+ when(registryMock.forEditorSlot(2)).thenReturn(Optional.empty());
+ setRegistry(listener, registryMock);
+
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ when(manager.getCountdown("cd")).thenReturn(Optional.of(cd));
+
+ listener.onInventoryClick(mainGuiEvent(player, ClickType.MIDDLE, "cd"));
+ listener.onInventoryClick(prefixedEvent(player, EditorMenu.getPrefix() + "cd", 2));
+
+ when(manager.getCountdown("missing")).thenReturn(Optional.empty());
+ listener.onInventoryClick(prefixedEvent(player, EditorMenu.getPrefix() + "missing", 1));
+ listener.onInventoryClick(prefixedEvent(player, DisplayEditor.getPrefix() + "missing", 1));
+ listener.onInventoryClick(prefixedEvent(player, CommandsEditor.getPrefix() + "missing", 1));
+
+ InventoryClickEvent unknown = mock(InventoryClickEvent.class);
+ InventoryView unknownView = mock(InventoryView.class);
+ when(unknown.getView()).thenReturn(unknownView);
+ when(unknownView.getTitle()).thenReturn("Some Other Title");
+ when(unknown.getWhoClicked()).thenReturn(player);
+ listener.onInventoryClick(unknown);
+ }
+
+ private static GuiClickListener buildListener(CountdownManager manager, MessageManager messages) {
+ return new GuiClickListener(
+ mock(MainGui.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class),
+ mock(ChatInputListener.class),
+ manager,
+ messages,
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class)
+ );
+ }
+
+ private static void setRegistry(GuiClickListener listener, GuiActionRegistry registry) throws Exception {
+ Field f = GuiClickListener.class.getDeclaredField("actionRegistry");
+ f.setAccessible(true);
+ f.set(listener, registry);
+ }
+
+ private static InventoryClickEvent mainGuiEvent(Player player, ClickType clickType, String displayName) {
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ InventoryView view = mock(InventoryView.class);
+ ItemStack clicked = mock(ItemStack.class);
+ ItemMeta meta = mock(ItemMeta.class);
+
+ when(event.getView()).thenReturn(view);
+ when(view.getTitle()).thenReturn(MainGui.getTitle());
+ when(event.getWhoClicked()).thenReturn(player);
+ when(event.getClick()).thenReturn(clickType);
+ when(event.getCurrentItem()).thenReturn(clicked);
+ when(clicked.getType()).thenReturn(org.bukkit.Material.PAPER);
+ when(clicked.getItemMeta()).thenReturn(meta);
+ when(meta.getDisplayName()).thenReturn(ChatColor.WHITE + displayName);
+ return event;
+ }
+
+ private static InventoryClickEvent prefixedEvent(Player player, String title, int rawSlot) {
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ InventoryView view = mock(InventoryView.class);
+
+ when(event.getView()).thenReturn(view);
+ when(view.getTitle()).thenReturn(title);
+ when(event.getWhoClicked()).thenReturn(player);
+ when(event.getRawSlot()).thenReturn(rawSlot);
+ return event;
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerUnitTest.java
new file mode 100644
index 0000000..9e60d12
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/GuiClickListenerUnitTest.java
@@ -0,0 +1,92 @@
+package com.skyblockexp.ezcountdown.listener;
+
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.gui.DisplayEditor;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import com.skyblockexp.ezcountdown.gui.MainGui;
+import org.bukkit.entity.HumanEntity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GuiClickListenerUnitTest {
+
+ @Test
+ public void ignoresEventsWithMissingView() {
+ GuiClickListener listener = new GuiClickListener(
+ mock(MainGui.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class),
+ mock(ChatInputListener.class),
+ mock(com.skyblockexp.ezcountdown.manager.CountdownManager.class),
+ mock(com.skyblockexp.ezcountdown.manager.MessageManager.class),
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class)
+ );
+
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ when(event.getView()).thenReturn(null);
+
+ listener.onInventoryClick(event);
+
+ verify(event, never()).setCancelled(true);
+ }
+
+ @Test
+ public void mainGuiNullClickedItemIsIgnoredAfterCancel() {
+ com.skyblockexp.ezcountdown.manager.CountdownManager manager = mock(com.skyblockexp.ezcountdown.manager.CountdownManager.class);
+ com.skyblockexp.ezcountdown.manager.MessageManager messages = mock(com.skyblockexp.ezcountdown.manager.MessageManager.class);
+
+ GuiClickListener listener = new GuiClickListener(
+ mock(MainGui.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class),
+ mock(ChatInputListener.class),
+ manager,
+ messages,
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class)
+ );
+
+ Player player = mock(Player.class);
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ org.bukkit.inventory.InventoryView view = mock(org.bukkit.inventory.InventoryView.class);
+ when(event.getView()).thenReturn(view);
+ when(view.getTitle()).thenReturn(MainGui.getTitle());
+ when(event.getWhoClicked()).thenReturn(player);
+ when(event.getCurrentItem()).thenReturn(null);
+
+ listener.onInventoryClick(event);
+
+ verify(event).setCancelled(true);
+ verify(player, never()).closeInventory();
+ }
+
+ @Test
+ public void nonPlayerClickIsIgnored() {
+ GuiClickListener listener = new GuiClickListener(
+ mock(MainGui.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class),
+ mock(ChatInputListener.class),
+ mock(com.skyblockexp.ezcountdown.manager.CountdownManager.class),
+ mock(com.skyblockexp.ezcountdown.manager.MessageManager.class),
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class)
+ );
+
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ org.bukkit.inventory.InventoryView view = mock(org.bukkit.inventory.InventoryView.class);
+ when(event.getView()).thenReturn(view);
+ when(view.getTitle()).thenReturn(MainGui.getTitle());
+ when(event.getWhoClicked()).thenReturn(mock(HumanEntity.class));
+
+ listener.onInventoryClick(event);
+
+ verify(event, never()).setCancelled(true);
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultTest.java
new file mode 100644
index 0000000..5305a31
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultTest.java
@@ -0,0 +1,38 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ActionResultTest {
+
+ @Test
+ public void constructorsAndFactoriesExposeExpectedState() {
+ ActionResult handled = ActionResult.handled();
+ assertTrue(handled.isHandled());
+ assertFalse(handled.isMutated());
+ assertFalse(handled.isCloseInventory());
+ assertTrue(handled.getReopenTitle().isEmpty());
+
+ ActionResult mutated = ActionResult.handledAndMutated();
+ assertTrue(mutated.isHandled());
+ assertTrue(mutated.isMutated());
+
+ ActionResult close = ActionResult.handledAndClose();
+ assertTrue(close.isHandled());
+ assertTrue(close.isCloseInventory());
+
+ ActionResult none = ActionResult.none();
+ assertFalse(none.isHandled());
+
+ ActionResult custom = new ActionResult(true, false, true, Optional.of("title"));
+ assertEquals("title", custom.getReopenTitle().orElseThrow());
+
+ ActionResult nullReopen = new ActionResult(true, false, false, null);
+ assertTrue(nullReopen.getReopenTitle().isEmpty());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultUnitTest.java
new file mode 100644
index 0000000..18da5ad
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ActionResultUnitTest.java
@@ -0,0 +1,36 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ActionResultUnitTest {
+
+ @Test
+ public void staticFactoriesAndAccessorsWork() {
+ ActionResult handled = ActionResult.handled();
+ assertTrue(handled.isHandled());
+ assertFalse(handled.isMutated());
+
+ ActionResult mutated = ActionResult.handledAndMutated();
+ assertTrue(mutated.isHandled());
+ assertTrue(mutated.isMutated());
+
+ ActionResult close = ActionResult.handledAndClose();
+ assertTrue(close.isHandled());
+ assertTrue(close.isCloseInventory());
+
+ ActionResult none = ActionResult.none();
+ assertFalse(none.isHandled());
+
+ ActionResult custom = new ActionResult(true, false, true, Optional.of("title"));
+ assertEquals("title", custom.getReopenTitle().orElseThrow());
+
+ ActionResult nullReopen = new ActionResult(true, false, false, null);
+ assertTrue(nullReopen.getReopenTitle().isEmpty());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsTest.java
new file mode 100644
index 0000000..02b78ef
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsTest.java
@@ -0,0 +1,113 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.command.CountdownPermissions;
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.ChatColor;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CommandsEditorActionsTest {
+
+ @Test
+ public void handleCoversGuardBranches() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ CommandsEditor editor = mock(CommandsEditor.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+
+ when(registry.permissions()).thenReturn(new CountdownPermissions("base", "create.perm", "start", "stop", "delete", "list", "info", "reload"));
+ CommandsEditorActions action = new CommandsEditorActions(manager, messages, chat, editor, registry);
+
+ org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of("one", "two"), ZoneId.systemDefault());
+ Inventory inv = mock(Inventory.class);
+ when(inv.getSize()).thenReturn(9);
+
+ InventoryClickEvent clickedNull = mock(InventoryClickEvent.class);
+ when(clickedNull.getRawSlot()).thenReturn(0);
+ when(clickedNull.getInventory()).thenReturn(inv);
+ when(clickedNull.getCurrentItem()).thenReturn(null);
+ assertFalse(action.handle(clickedNull, player, "cd", Optional.of(cd)).isHandled());
+
+ ItemStack air = mock(ItemStack.class);
+ when(air.getType()).thenReturn(org.bukkit.Material.AIR);
+ InventoryClickEvent clickedAir = mock(InventoryClickEvent.class);
+ when(clickedAir.getRawSlot()).thenReturn(0);
+ when(clickedAir.getInventory()).thenReturn(inv);
+ when(clickedAir.getCurrentItem()).thenReturn(air);
+ assertFalse(action.handle(clickedAir, player, "cd", Optional.of(cd)).isHandled());
+
+ ItemStack noMeta = mock(ItemStack.class);
+ when(noMeta.getType()).thenReturn(org.bukkit.Material.PAPER);
+ when(noMeta.getItemMeta()).thenReturn(null);
+ InventoryClickEvent clickedNoMeta = mock(InventoryClickEvent.class);
+ when(clickedNoMeta.getRawSlot()).thenReturn(0);
+ when(clickedNoMeta.getInventory()).thenReturn(inv);
+ when(clickedNoMeta.getCurrentItem()).thenReturn(noMeta);
+ assertFalse(action.handle(clickedNoMeta, player, "cd", Optional.of(cd)).isHandled());
+
+ ItemStack noName = mock(ItemStack.class);
+ ItemMeta meta = mock(ItemMeta.class);
+ when(noName.getType()).thenReturn(org.bukkit.Material.PAPER);
+ when(noName.getItemMeta()).thenReturn(meta);
+ when(meta.getDisplayName()).thenReturn(null);
+ InventoryClickEvent clickedNoName = mock(InventoryClickEvent.class);
+ when(clickedNoName.getRawSlot()).thenReturn(0);
+ when(clickedNoName.getInventory()).thenReturn(inv);
+ when(clickedNoName.getCurrentItem()).thenReturn(noName);
+ assertFalse(action.handle(clickedNoName, player, "cd", Optional.of(cd)).isHandled());
+
+ ItemStack ok = mock(ItemStack.class);
+ ItemMeta okMeta = mock(ItemMeta.class);
+ when(ok.getType()).thenReturn(org.bukkit.Material.PAPER);
+ when(ok.getItemMeta()).thenReturn(okMeta);
+ when(okMeta.getDisplayName()).thenReturn(ChatColor.WHITE + "one");
+
+ InventoryClickEvent shiftLeftBoundary = mock(InventoryClickEvent.class);
+ when(shiftLeftBoundary.getRawSlot()).thenReturn(0);
+ when(shiftLeftBoundary.getInventory()).thenReturn(inv);
+ when(shiftLeftBoundary.getCurrentItem()).thenReturn(ok);
+ when(shiftLeftBoundary.getClick()).thenReturn(ClickType.SHIFT_LEFT);
+ ActionResult shiftLeft = action.handle(shiftLeftBoundary, player, "cd", Optional.of(cd));
+ assertTrue(shiftLeft.isHandled());
+ assertTrue(shiftLeft.isMutated());
+
+ InventoryClickEvent shiftRightBoundary = mock(InventoryClickEvent.class);
+ when(shiftRightBoundary.getRawSlot()).thenReturn(1);
+ when(shiftRightBoundary.getInventory()).thenReturn(inv);
+ when(shiftRightBoundary.getCurrentItem()).thenReturn(ok);
+ when(shiftRightBoundary.getClick()).thenReturn(ClickType.SHIFT_RIGHT);
+ ActionResult shiftRight = action.handle(shiftRightBoundary, player, "cd", Optional.of(cd));
+ assertTrue(shiftRight.isHandled());
+ assertTrue(shiftRight.isMutated());
+
+ InventoryClickEvent rightOutOfRange = mock(InventoryClickEvent.class);
+ when(rightOutOfRange.getRawSlot()).thenReturn(5);
+ when(rightOutOfRange.getInventory()).thenReturn(inv);
+ when(rightOutOfRange.getCurrentItem()).thenReturn(ok);
+ when(rightOutOfRange.getClick()).thenReturn(ClickType.RIGHT);
+ ActionResult right = action.handle(rightOutOfRange, player, "cd", Optional.of(cd));
+ assertTrue(right.isHandled());
+ assertTrue(right.isMutated());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsUnitTest.java
new file mode 100644
index 0000000..7276519
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/CommandsEditorActionsUnitTest.java
@@ -0,0 +1,139 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.command.CountdownPermissions;
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.ChatColor;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class CommandsEditorActionsUnitTest {
+
+ @Test
+ public void coversCoreBranches() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ CommandsEditor editor = mock(CommandsEditor.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.permissions()).thenReturn(new CountdownPermissions("base", "create.perm", "start", "stop", "delete", "list", "info", "reload"));
+ when(messages.message(eq("commands.create.no-permission"))).thenReturn("no-perm");
+ when(messages.message(eq("gui.commands.moved"), anyMap())).thenReturn("moved");
+ when(messages.message(eq("gui.commands.removed"), anyMap())).thenReturn("removed");
+ when(messages.message(eq("gui.commands.added"), anyMap())).thenReturn("added");
+ when(messages.message(eq("gui.commands.edited"), anyMap())).thenReturn("edited");
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+ doAnswer(call -> {
+ Runnable r = call.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ CommandsEditorActions action = new CommandsEditorActions(manager, messages, chat, editor, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of("one", "two", "three"), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ Inventory inv = mock(Inventory.class);
+ when(inv.getSize()).thenReturn(9);
+
+ InventoryClickEvent addClickNoPerm = mock(InventoryClickEvent.class);
+ when(addClickNoPerm.getRawSlot()).thenReturn(8);
+ when(addClickNoPerm.getInventory()).thenReturn(inv);
+ when(player.hasPermission("create.perm")).thenReturn(false);
+ ActionResult addNoPerm = action.handle(addClickNoPerm, player, "cd", Optional.of(cd));
+ assertTrue(addNoPerm.isHandled());
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> { captured.set(call2.getArgument(1)); return null; }).when(chat).request(any(Player.class), any());
+ when(player.hasPermission("create.perm")).thenReturn(true);
+ InventoryClickEvent addClick = mock(InventoryClickEvent.class);
+ when(addClick.getRawSlot()).thenReturn(8);
+ when(addClick.getInventory()).thenReturn(inv);
+ action.handle(addClick, player, "cd", Optional.of(cd));
+ captured.get().accept("say added");
+
+ ItemStack clicked = mock(ItemStack.class);
+ ItemMeta meta = mock(ItemMeta.class);
+ when(clicked.getType()).thenReturn(org.bukkit.Material.PAPER);
+ when(clicked.getItemMeta()).thenReturn(meta);
+ when(meta.getDisplayName()).thenReturn(ChatColor.WHITE + "one");
+
+ InventoryClickEvent left = mock(InventoryClickEvent.class);
+ when(left.getRawSlot()).thenReturn(0);
+ when(left.getInventory()).thenReturn(inv);
+ when(left.getCurrentItem()).thenReturn(clicked);
+ when(left.getClick()).thenReturn(ClickType.LEFT);
+ action.handle(left, player, "cd", Optional.of(cd));
+ captured.get().accept("say edited");
+
+ InventoryClickEvent shiftLeft = mock(InventoryClickEvent.class);
+ when(shiftLeft.getRawSlot()).thenReturn(1);
+ when(shiftLeft.getInventory()).thenReturn(inv);
+ when(shiftLeft.getCurrentItem()).thenReturn(clicked);
+ when(shiftLeft.getClick()).thenReturn(ClickType.SHIFT_LEFT);
+ ActionResult movedLeft = action.handle(shiftLeft, player, "cd", Optional.of(cd));
+ assertTrue(movedLeft.isMutated());
+
+ InventoryClickEvent shiftRight = mock(InventoryClickEvent.class);
+ when(shiftRight.getRawSlot()).thenReturn(0);
+ when(shiftRight.getInventory()).thenReturn(inv);
+ when(shiftRight.getCurrentItem()).thenReturn(clicked);
+ when(shiftRight.getClick()).thenReturn(ClickType.SHIFT_RIGHT);
+ ActionResult movedRight = action.handle(shiftRight, player, "cd", Optional.of(cd));
+ assertTrue(movedRight.isMutated());
+
+ InventoryClickEvent right = mock(InventoryClickEvent.class);
+ when(right.getRawSlot()).thenReturn(0);
+ when(right.getInventory()).thenReturn(inv);
+ when(right.getCurrentItem()).thenReturn(clicked);
+ when(right.getClick()).thenReturn(ClickType.RIGHT);
+ ActionResult removed = action.handle(right, player, "cd", Optional.of(cd));
+ assertTrue(removed.isMutated());
+
+ InventoryClickEvent noneClick = mock(InventoryClickEvent.class);
+ when(noneClick.getRawSlot()).thenReturn(0);
+ when(noneClick.getInventory()).thenReturn(inv);
+ when(noneClick.getCurrentItem()).thenReturn(clicked);
+ when(noneClick.getClick()).thenReturn(ClickType.MIDDLE);
+ ActionResult noAction = action.handle(noneClick, player, "cd", Optional.of(cd));
+ assertFalse(noAction.isHandled());
+
+ verify(manager, org.mockito.Mockito.atLeastOnce()).save();
+ verify(editor, org.mockito.Mockito.atLeastOnce()).openCommandsEditor(eq(player), any(Countdown.class));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/DeleteCountdownActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/DeleteCountdownActionUnitTest.java
new file mode 100644
index 0000000..a802b1e
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/DeleteCountdownActionUnitTest.java
@@ -0,0 +1,81 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.command.CountdownPermissions;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class DeleteCountdownActionUnitTest {
+
+ @Test
+ public void noPermissionReturnsHandledWithoutMutation() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ when(registry.permissions()).thenReturn(new CountdownPermissions("base", "create", "start", "stop", "delete.perm", "list", "info", "reload"));
+ when(messages.message(eq("commands.delete.no-permission"))).thenReturn("no-perm");
+
+ DeleteCountdownAction action = new DeleteCountdownAction(manager, messages, registry);
+ Player player = mock(Player.class);
+ when(player.hasPermission("delete.perm")).thenReturn(false);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "abc", Optional.empty());
+
+ assertTrue(result.isHandled());
+ assertFalse(result.isMutated());
+ verify(manager, never()).deleteCountdown("abc");
+ }
+
+ @Test
+ public void successfulDeleteReturnsMutatedAndClose() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ when(registry.permissions()).thenReturn(new CountdownPermissions("base", "create", "start", "stop", "delete.perm", "list", "info", "reload"));
+ when(manager.deleteCountdown("abc")).thenReturn(true);
+ when(messages.message(eq("commands.delete.success"), anyMap())).thenReturn("deleted");
+
+ DeleteCountdownAction action = new DeleteCountdownAction(manager, messages, registry);
+ Player player = mock(Player.class);
+ when(player.hasPermission("delete.perm")).thenReturn(true);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "abc", Optional.empty());
+
+ assertTrue(result.isHandled());
+ assertTrue(result.isMutated());
+ assertTrue(result.isCloseInventory());
+ }
+
+ @Test
+ public void missingDeleteReturnsHandledCloseNotMutated() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ when(registry.permissions()).thenReturn(new CountdownPermissions("base", "create", "start", "stop", "delete.perm", "list", "info", "reload"));
+ when(manager.deleteCountdown("abc")).thenReturn(false);
+ when(messages.message(eq("commands.delete.missing"), anyMap())).thenReturn("missing");
+
+ DeleteCountdownAction action = new DeleteCountdownAction(manager, messages, registry);
+ Player player = mock(Player.class);
+ when(player.hasPermission("delete.perm")).thenReturn(true);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "abc", Optional.empty());
+
+ assertTrue(result.isHandled());
+ assertFalse(result.isMutated());
+ assertTrue(result.isCloseInventory());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditDurationOrTargetActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditDurationOrTargetActionUnitTest.java
new file mode 100644
index 0000000..6aeb41a
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditDurationOrTargetActionUnitTest.java
@@ -0,0 +1,127 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import com.skyblockexp.ezcountdown.type.CountdownTypeHandler;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EditDurationOrTargetActionUnitTest {
+
+ @Test
+ public void handleEmptyReturnsNone() {
+ EditDurationOrTargetAction action = new EditDurationOrTargetAction(mock(CountdownManager.class), mock(MessageManager.class), mock(ChatInputListener.class), mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class));
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), mock(Player.class), "cd", Optional.empty());
+ assertFalse(none.isHandled());
+ }
+
+ @Test
+ public void handlerPathAppliedAndNotApplied() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editor = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editor);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+ when(messages.message(eq("gui.edit.invalid-duration"), anyMap())).thenReturn("invalid");
+ doAnswer(inv -> {
+ Runnable r = inv.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> { captured.set(call2.getArgument(1)); return null; }).when(chat).request(any(Player.class), any());
+
+ EditDurationOrTargetAction action = new EditDurationOrTargetAction(manager, messages, chat, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+
+ CountdownTypeHandler handler = mock(CountdownTypeHandler.class);
+ when(manager.getHandler(cd.getType())).thenReturn(handler);
+ when(handler.tryApplyEditorInput(eq("ok"), eq(cd), any())).thenReturn(true);
+ when(handler.tryApplyEditorInput(eq("bad"), eq(cd), any())).thenReturn(false);
+
+ action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ captured.get().accept("ok");
+ captured.get().accept("bad");
+
+ verify(manager).save();
+ verify(player).sendMessage("saved");
+ verify(player).sendMessage("invalid");
+ verify(editor).openEditor(player, cd);
+ }
+
+ @Test
+ public void noHandlerParsesDurationAndInstantAndInvalid() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editor = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editor);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+ when(messages.message(eq("gui.edit.invalid-duration"), anyMap())).thenReturn("invalid");
+ doAnswer(inv -> {
+ Runnable r = inv.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> { captured.set(call2.getArgument(1)); return null; }).when(chat).request(any(Player.class), any());
+
+ EditDurationOrTargetAction action = new EditDurationOrTargetAction(manager, messages, chat, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setRunning(true);
+ when(manager.getHandler(cd.getType())).thenReturn(null);
+
+ action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ captured.get().accept("60s");
+ captured.get().accept("2026-01-01T00:00:00Z");
+ captured.get().accept("not-a-duration-or-instant");
+
+ assertTrue(cd.getDurationSeconds() >= 60L);
+ assertNotNull(cd.getTargetInstant());
+ verify(player, org.mockito.Mockito.atLeast(2)).sendMessage("saved");
+ verify(player).sendMessage("invalid");
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundActionTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundActionTest.java
new file mode 100644
index 0000000..13ee751
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditEndSoundActionTest.java
@@ -0,0 +1,82 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.util.Locale;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EditEndSoundActionTest {
+
+ @Test
+ public void handleCoversNoneValidAndInvalidInput() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editorMenu = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editorMenu);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+
+ doAnswer(call -> {
+ Runnable r = call.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> {
+ captured.set(call2.getArgument(1));
+ return null;
+ }).when(chat).request(any(Player.class), any());
+
+ EditEndSoundAction action = new EditEndSoundAction(manager, messages, chat, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "start", "end", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult handled = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(handled.isHandled());
+
+ String validSound = org.bukkit.Sound.values()[0].name().toLowerCase(Locale.ROOT);
+ captured.get().accept("none");
+ captured.get().accept(validSound);
+ captured.get().accept("not_a_sound");
+
+ verify(manager, org.mockito.Mockito.atLeast(2)).save();
+ verify(player, org.mockito.Mockito.atLeast(2)).sendMessage("saved");
+ verify(editorMenu, org.mockito.Mockito.atLeast(2)).openEditor(eq(player), any(Countdown.class));
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage(org.mockito.ArgumentMatchers.contains("Invalid sound name"));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditMessageActionsUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditMessageActionsUnitTest.java
new file mode 100644
index 0000000..fa32aa2
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditMessageActionsUnitTest.java
@@ -0,0 +1,114 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EditMessageActionsUnitTest {
+
+ @Test
+ public void startMessageActionRequestsInputAndSaves() {
+ runTextAction(new EditStartMessageActionFixture());
+ }
+
+ @Test
+ public void endMessageActionRequestsInputAndSaves() {
+ runTextAction(new EditEndMessageActionFixture());
+ }
+
+ @Test
+ public void formatMessageActionRequestsInputAndSaves() {
+ runTextAction(new EditFormatMessageActionFixture());
+ }
+
+ private void runTextAction(Fixture fixture) {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editorMenu = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editorMenu);
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+ doAnswer(inv -> {
+ Runnable r = inv.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> {
+ captured.set(call2.getArgument(1));
+ return null;
+ }).when(chat).request(any(Player.class), any());
+
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "start", "end", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = fixture.create(manager, messages, chat, registry).handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult handled = fixture.create(manager, messages, chat, registry).handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(handled.isHandled());
+ captured.get().accept("&aupdated");
+
+ verify(manager).save();
+ verify(player).sendMessage("saved");
+ verify(editorMenu).openEditor(eq(player), any(Countdown.class));
+ }
+
+ private interface Fixture {
+ GuiAction create(CountdownManager manager, MessageManager messages, ChatInputListener chat, com.skyblockexp.ezcountdown.bootstrap.Registry registry);
+ }
+
+ private static class EditStartMessageActionFixture implements Fixture {
+ @Override
+ public GuiAction create(CountdownManager manager, MessageManager messages, ChatInputListener chat, com.skyblockexp.ezcountdown.bootstrap.Registry registry) {
+ return new EditStartMessageAction(manager, messages, chat, registry);
+ }
+ }
+
+ private static class EditEndMessageActionFixture implements Fixture {
+ @Override
+ public GuiAction create(CountdownManager manager, MessageManager messages, ChatInputListener chat, com.skyblockexp.ezcountdown.bootstrap.Registry registry) {
+ return new EditEndMessageAction(manager, messages, chat, registry);
+ }
+ }
+
+ private static class EditFormatMessageActionFixture implements Fixture {
+ @Override
+ public GuiAction create(CountdownManager manager, MessageManager messages, ChatInputListener chat, com.skyblockexp.ezcountdown.bootstrap.Registry registry) {
+ return new EditFormatMessageAction(manager, messages, chat, registry);
+ }
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartCountdownTargetActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartCountdownTargetActionUnitTest.java
new file mode 100644
index 0000000..52ad592
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartCountdownTargetActionUnitTest.java
@@ -0,0 +1,77 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EditStartCountdownTargetActionUnitTest {
+
+ @Test
+ public void handlesEmptyAndMissingTargetAndExistingTarget() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editor = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editor);
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+ when(messages.message(eq("gui.edit.start-countdown.missing"), anyMap())).thenReturn("missing");
+ doAnswer(inv -> {
+ Runnable r = inv.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> { captured.set(call2.getArgument(1)); return null; }).when(chat).request(any(Player.class), any());
+
+ EditStartCountdownTargetAction action = new EditStartCountdownTargetAction(manager, messages, chat, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault(), false, null, 0);
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult handled = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(handled.isHandled());
+
+ when(manager.getCountdown("other")).thenReturn(Optional.of(new Countdown("other", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault())));
+ captured.get().accept("other");
+ captured.get().accept("missingOne");
+
+ verify(manager, org.mockito.Mockito.atLeast(2)).save();
+ verify(player, org.mockito.Mockito.atLeast(2)).sendMessage("saved");
+ verify(player).sendMessage("missing");
+ verify(editor, org.mockito.Mockito.atLeastOnce()).openEditor(eq(player), any(Countdown.class));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundActionTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundActionTest.java
new file mode 100644
index 0000000..b834552
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/EditStartSoundActionTest.java
@@ -0,0 +1,82 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.listener.ChatInputListener;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.util.Locale;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EditStartSoundActionTest {
+
+ @Test
+ public void handleCoversNoneValidAndInvalidInput() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ChatInputListener chat = mock(ChatInputListener.class);
+ com.skyblockexp.ezcountdown.bootstrap.Registry registry = mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class);
+ com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter scheduler = mock(com.skyblockexp.ezcountdown.compat.scheduler.SchedulerAdapter.class);
+ com.skyblockexp.ezcountdown.gui.GuiManager gui = mock(com.skyblockexp.ezcountdown.gui.GuiManager.class);
+ com.skyblockexp.ezcountdown.gui.EditorMenu editorMenu = mock(com.skyblockexp.ezcountdown.gui.EditorMenu.class);
+
+ when(registry.scheduler()).thenReturn(scheduler);
+ when(registry.gui()).thenReturn(gui);
+ when(gui.editorMenu()).thenReturn(editorMenu);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+
+ doAnswer(call -> {
+ Runnable r = call.getArgument(0);
+ r.run();
+ return new com.skyblockexp.ezcountdown.compat.scheduler.TaskHandle() {
+ @Override public void cancel() {}
+ @Override public boolean isCancelled() { return false; }
+ };
+ }).when(scheduler).runTask(any(Runnable.class));
+
+ AtomicReference> captured = new AtomicReference<>();
+ doAnswer(call2 -> {
+ captured.set(call2.getArgument(1));
+ return null;
+ }).when(chat).request(any(Player.class), any());
+
+ EditStartSoundAction action = new EditStartSoundAction(manager, messages, chat, registry);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "start", "end", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult handled = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(handled.isHandled());
+
+ String validSound = org.bukkit.Sound.values()[0].name().toLowerCase(Locale.ROOT);
+ captured.get().accept("none");
+ captured.get().accept(validSound);
+ captured.get().accept("not_a_sound");
+
+ verify(manager, org.mockito.Mockito.atLeast(2)).save();
+ verify(player, org.mockito.Mockito.atLeast(2)).sendMessage("saved");
+ verify(editorMenu, org.mockito.Mockito.atLeast(2)).openEditor(eq(player), any(Countdown.class));
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage(org.mockito.ArgumentMatchers.contains("Invalid sound name"));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/GuiActionRegistryUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/GuiActionRegistryUnitTest.java
new file mode 100644
index 0000000..a1941b6
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/GuiActionRegistryUnitTest.java
@@ -0,0 +1,54 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.gui.DisplayEditor;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import org.bukkit.event.inventory.ClickType;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+public class GuiActionRegistryUnitTest {
+
+ @Test
+ public void registryReturnsExpectedMainGuiActions() {
+ GuiActionRegistry registry = new GuiActionRegistry(
+ mock(com.skyblockexp.ezcountdown.manager.CountdownManager.class),
+ mock(com.skyblockexp.ezcountdown.manager.MessageManager.class),
+ mock(com.skyblockexp.ezcountdown.listener.ChatInputListener.class),
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class)
+ );
+
+ assertTrue(registry.forMainGuiClick(ClickType.LEFT).isPresent());
+ assertTrue(registry.forMainGuiClick(ClickType.RIGHT).isPresent());
+ assertTrue(registry.forMainGuiClick(ClickType.SHIFT_RIGHT).isPresent());
+ assertFalse(registry.forMainGuiClick(ClickType.MIDDLE).isPresent());
+ }
+
+ @Test
+ public void registryReturnsExpectedEditorSlotMappings() {
+ GuiActionRegistry registry = new GuiActionRegistry(
+ mock(com.skyblockexp.ezcountdown.manager.CountdownManager.class),
+ mock(com.skyblockexp.ezcountdown.manager.MessageManager.class),
+ mock(com.skyblockexp.ezcountdown.listener.ChatInputListener.class),
+ mock(com.skyblockexp.ezcountdown.bootstrap.Registry.class),
+ mock(EditorMenu.class),
+ mock(DisplayEditor.class),
+ mock(CommandsEditor.class)
+ );
+
+ for (int i = 0; i <= 10; i++) {
+ assertTrue(registry.forEditorSlot(i).isPresent());
+ }
+ assertFalse(registry.forEditorSlot(11).isPresent());
+ assertFalse(registry.forEditorSlot(-1).isPresent());
+
+ assertTrue(registry.forDisplaySlot(0).isPresent());
+ assertTrue(registry.forCommands().isPresent());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/OpenEditorActionsUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/OpenEditorActionsUnitTest.java
new file mode 100644
index 0000000..161bd45
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/OpenEditorActionsUnitTest.java
@@ -0,0 +1,68 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.gui.CommandsEditor;
+import com.skyblockexp.ezcountdown.gui.DisplayEditor;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class OpenEditorActionsUnitTest {
+
+ @Test
+ public void openEditorActionHandlesPresentAndEmpty() {
+ EditorMenu menu = mock(EditorMenu.class);
+ OpenEditorAction action = new OpenEditorAction(menu);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult ok = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(ok.isHandled());
+ verify(menu).openEditor(player, cd);
+ }
+
+ @Test
+ public void openDisplayEditorActionHandlesPresentAndEmpty() {
+ DisplayEditor editor = mock(DisplayEditor.class);
+ OpenDisplayEditorAction action = new OpenDisplayEditorAction(editor);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult ok = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(ok.isHandled());
+ verify(editor).openDisplayEditor(player, cd);
+ }
+
+ @Test
+ public void openCommandsEditorActionHandlesPresentAndEmpty() {
+ CommandsEditor editor = mock(CommandsEditor.class);
+ OpenCommandsEditorAction action = new OpenCommandsEditorAction(editor);
+ Player player = mock(Player.class);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ ActionResult ok = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+ assertTrue(ok.isHandled());
+ verify(editor).openCommandsEditor(player, cd);
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionTest.java
new file mode 100644
index 0000000..04eb69d
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionTest.java
@@ -0,0 +1,74 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import com.skyblockexp.ezcountdown.util.TimeFormat;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PreviewCountdownActionTest {
+
+ @Test
+ public void handleCoversEmptyDurationAndTargetBranches() {
+ MessageManager messages = mock(MessageManager.class);
+ CountdownManager manager = mock(CountdownManager.class);
+ when(manager.getTimeFormatConfig()).thenReturn(TimeFormat.FormatConfig.DEFAULT);
+ PreviewCountdownAction action = new PreviewCountdownAction(messages, manager);
+ Player player = mock(Player.class);
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ Countdown durationCd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ durationCd.setDurationSeconds(65);
+ when(messages.formatWithPrefix(anyString(), anyMap())).thenReturn("rendered-duration");
+
+ ActionResult durationRes = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(durationCd));
+ assertTrue(durationRes.isHandled());
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage("rendered-duration");
+
+ Countdown targetCd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ targetCd.setTargetInstant(Instant.now().plusSeconds(30));
+ when(messages.formatWithPrefix(contains("cd"), anyMap())).thenReturn("rendered-target");
+
+ ActionResult targetRes = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(targetCd));
+ assertTrue(targetRes.isHandled());
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage("rendered-target");
+ }
+
+ @Test
+ public void handleClampsPastTargetToZero() {
+ MessageManager messages = mock(MessageManager.class);
+ CountdownManager manager = mock(CountdownManager.class);
+ when(manager.getTimeFormatConfig()).thenReturn(TimeFormat.FormatConfig.DEFAULT);
+ when(messages.formatWithPrefix(contains("0s"), anyMap())).thenReturn("zero");
+
+ PreviewCountdownAction action = new PreviewCountdownAction(messages, manager);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setTargetInstant(Instant.now().minusSeconds(10));
+ Player player = mock(Player.class);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+
+ assertTrue(result.isHandled());
+ verify(player).sendMessage("zero");
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionUnitTest.java
new file mode 100644
index 0000000..88cf603
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/PreviewCountdownActionUnitTest.java
@@ -0,0 +1,70 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import com.skyblockexp.ezcountdown.util.TimeFormat;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PreviewCountdownActionUnitTest {
+
+ @Test
+ public void emptyCountdownReturnsNone() {
+ PreviewCountdownAction action = new PreviewCountdownAction(mock(MessageManager.class), mock(CountdownManager.class));
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), mock(Player.class), "t", Optional.empty());
+ assertFalse(result.isHandled());
+ }
+
+ @Test
+ public void previewUsesDurationWhenTargetMissing() {
+ MessageManager messages = mock(MessageManager.class);
+ CountdownManager manager = mock(CountdownManager.class);
+ when(manager.getTimeFormatConfig()).thenReturn(TimeFormat.FormatConfig.DEFAULT);
+ when(messages.formatWithPrefix(anyString(), anyMap())).thenReturn("rendered");
+
+ PreviewCountdownAction action = new PreviewCountdownAction(messages, manager);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setDurationSeconds(65);
+ Player player = mock(Player.class);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+
+ assertTrue(result.isHandled());
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage("rendered");
+ }
+
+ @Test
+ public void previewUsesTargetRemainingWhenPresent() {
+ MessageManager messages = mock(MessageManager.class);
+ CountdownManager manager = mock(CountdownManager.class);
+ when(manager.getTimeFormatConfig()).thenReturn(TimeFormat.FormatConfig.DEFAULT);
+ when(messages.formatWithPrefix(org.mockito.ArgumentMatchers.contains("cd"), anyMap())).thenReturn("rendered-target");
+
+ PreviewCountdownAction action = new PreviewCountdownAction(messages, manager);
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setTargetInstant(Instant.now().plusSeconds(30));
+ Player player = mock(Player.class);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+
+ assertTrue(result.isHandled());
+ verify(player).sendMessage("rendered-target");
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionTest.java
new file mode 100644
index 0000000..8ceb962
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionTest.java
@@ -0,0 +1,54 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ToggleAutoRestartActionTest {
+
+ @Test
+ public void handleCoversEmptySuccessAndFailure() {
+ Countdown original = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault(), false, null, 0);
+
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ EditorMenu editor = mock(EditorMenu.class);
+ Player player = mock(Player.class);
+ ToggleAutoRestartAction action = new ToggleAutoRestartAction(manager, messages, editor);
+
+ ActionResult empty = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(empty.isHandled());
+
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+
+ ActionResult success = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(original));
+ assertTrue(success.isHandled());
+ assertTrue(success.isMutated());
+ verify(editor).openEditor(eq(player), any(Countdown.class));
+
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(false);
+ ActionResult failed = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(original));
+ assertTrue(failed.isHandled());
+ assertFalse(failed.isMutated());
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionUnitTest.java
new file mode 100644
index 0000000..dc31e71
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleAutoRestartActionUnitTest.java
@@ -0,0 +1,54 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.gui.EditorMenu;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ToggleAutoRestartActionUnitTest {
+
+ @Test
+ public void emptyCountdownReturnsNone() {
+ ToggleAutoRestartAction action = new ToggleAutoRestartAction(mock(CountdownManager.class), mock(MessageManager.class), mock(EditorMenu.class));
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), mock(Player.class), "cd", Optional.empty());
+ assertFalse(result.isHandled());
+ }
+
+ @Test
+ public void updateSuccessReturnsHandledMutatedAndReopensEditor() {
+ Countdown original = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault(), false, null, 0);
+
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ EditorMenu editor = mock(EditorMenu.class);
+ Player player = mock(Player.class);
+
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+ when(messages.message(eq("gui.edit.saved"), anyMap())).thenReturn("saved");
+
+ ToggleAutoRestartAction action = new ToggleAutoRestartAction(manager, messages, editor);
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(original));
+
+ assertTrue(result.isHandled());
+ assertTrue(result.isMutated());
+ verify(editor).openEditor(eq(player), any(Countdown.class));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleDisplayTypeActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleDisplayTypeActionUnitTest.java
new file mode 100644
index 0000000..f3e978f
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleDisplayTypeActionUnitTest.java
@@ -0,0 +1,71 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.display.DisplayType;
+import com.skyblockexp.ezcountdown.gui.DisplayEditor;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ToggleDisplayTypeActionUnitTest {
+
+ @Test
+ public void emptyCountdownReturnsNone() {
+ ToggleDisplayTypeAction action = new ToggleDisplayTypeAction(mock(CountdownManager.class), mock(MessageManager.class), mock(DisplayEditor.class));
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), mock(Player.class), "cd", Optional.empty());
+ assertFalse(result.isHandled());
+ }
+
+ @Test
+ public void outOfRangeSlotReturnsNone() {
+ Countdown countdown = new Countdown("cd", CountdownType.MANUAL, EnumSet.of(DisplayType.CHAT), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ when(event.getRawSlot()).thenReturn(100);
+
+ ToggleDisplayTypeAction action = new ToggleDisplayTypeAction(mock(CountdownManager.class), mock(MessageManager.class), mock(DisplayEditor.class));
+ ActionResult result = action.handle(event, mock(Player.class), "cd", Optional.of(countdown));
+
+ assertFalse(result.isHandled());
+ }
+
+ @Test
+ public void validSlotTogglesAndPersists() {
+ Countdown countdown = new Countdown("cd", CountdownType.MANUAL, EnumSet.of(DisplayType.CHAT), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ InventoryClickEvent event = mock(InventoryClickEvent.class);
+ when(event.getRawSlot()).thenReturn(0);
+
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ DisplayEditor editor = mock(DisplayEditor.class);
+ Player player = mock(Player.class);
+
+ when(manager.updateCountdown(eq("cd"), any(Countdown.class))).thenReturn(true);
+ when(messages.message(eq("gui.display.toggled"), anyMap())).thenReturn("toggled");
+
+ ToggleDisplayTypeAction action = new ToggleDisplayTypeAction(manager, messages, editor);
+ ActionResult result = action.handle(event, player, "cd", Optional.of(countdown));
+
+ assertTrue(result.isHandled());
+ assertTrue(result.isMutated());
+ verify(manager).save();
+ verify(editor).openDisplayEditor(eq(player), any(Countdown.class));
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionTest.java
new file mode 100644
index 0000000..3ca3ef5
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionTest.java
@@ -0,0 +1,89 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import com.skyblockexp.ezcountdown.util.TimeFormat;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ToggleRunningActionTest {
+
+ @Test
+ public void handleCoversEmptyStopAndStartBranches() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ToggleRunningAction action = new ToggleRunningAction(manager, messages);
+ Player player = mock(Player.class);
+
+ ActionResult none = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ when(messages.message(eq("commands.stop.success"), anyMap())).thenReturn("stopped");
+ Countdown running = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ running.setRunning(true);
+
+ ActionResult stopped = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(running));
+ assertTrue(stopped.isHandled());
+ assertTrue(stopped.isMutated());
+ assertTrue(stopped.isCloseInventory());
+ verify(manager).stopCountdown("cd");
+ verify(player).sendMessage("stopped");
+
+ when(messages.message(eq("commands.start.success"), anyMap())).thenReturn("started");
+ Countdown notRunning = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ notRunning.setRunning(false);
+
+ ActionResult started = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(notRunning));
+ assertTrue(started.isHandled());
+ assertTrue(started.isMutated());
+ assertTrue(started.isCloseInventory());
+ verify(manager).startCountdown("cd");
+ verify(player).sendMessage("started");
+ }
+
+ @Test
+ public void previewActionCoversEmptyDurationAndTargetPaths() {
+ MessageManager messages = mock(MessageManager.class);
+ CountdownManager manager = mock(CountdownManager.class);
+ when(manager.getTimeFormatConfig()).thenReturn(TimeFormat.FormatConfig.DEFAULT);
+ PreviewCountdownAction preview = new PreviewCountdownAction(messages, manager);
+ Player player = mock(Player.class);
+
+ ActionResult none = preview.handle(mock(InventoryClickEvent.class), player, "cd", Optional.empty());
+ assertFalse(none.isHandled());
+
+ Countdown durationCd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ durationCd.setDurationSeconds(65);
+ when(messages.formatWithPrefix(contains("cd"), anyMap())).thenReturn("rendered-duration");
+
+ ActionResult durationRes = preview.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(durationCd));
+ assertTrue(durationRes.isHandled());
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage("rendered-duration");
+
+ Countdown targetCd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "{name} {formatted}", "s", "e", List.of(), ZoneId.systemDefault());
+ targetCd.setTargetInstant(Instant.now().minusSeconds(10));
+ when(messages.formatWithPrefix(contains("0"), anyMap())).thenReturn("rendered-target");
+
+ ActionResult targetRes = preview.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(targetCd));
+ assertTrue(targetRes.isHandled());
+ verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage("rendered-target");
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionUnitTest.java b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionUnitTest.java
new file mode 100644
index 0000000..8d151c8
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/listener/actions/ToggleRunningActionUnitTest.java
@@ -0,0 +1,71 @@
+package com.skyblockexp.ezcountdown.listener.actions;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import com.skyblockexp.ezcountdown.manager.CountdownManager;
+import com.skyblockexp.ezcountdown.manager.MessageManager;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.ZoneId;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ToggleRunningActionUnitTest {
+
+ @Test
+ public void emptyCountdownReturnsNone() {
+ ToggleRunningAction action = new ToggleRunningAction(mock(CountdownManager.class), mock(MessageManager.class));
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), mock(Player.class), "cd", Optional.empty());
+ assertFalse(result.isHandled());
+ }
+
+ @Test
+ public void runningCountdownStops() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ToggleRunningAction action = new ToggleRunningAction(manager, messages);
+ Player player = mock(Player.class);
+ when(messages.message(eq("commands.stop.success"), anyMap())).thenReturn("stopped");
+
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setRunning(true);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+
+ assertTrue(result.isHandled());
+ assertTrue(result.isMutated());
+ assertTrue(result.isCloseInventory());
+ verify(manager).stopCountdown("cd");
+ verify(player).sendMessage("stopped");
+ }
+
+ @Test
+ public void stoppedCountdownStarts() {
+ CountdownManager manager = mock(CountdownManager.class);
+ MessageManager messages = mock(MessageManager.class);
+ ToggleRunningAction action = new ToggleRunningAction(manager, messages);
+ Player player = mock(Player.class);
+ when(messages.message(eq("commands.start.success"), anyMap())).thenReturn("started");
+
+ Countdown cd = new Countdown("cd", CountdownType.MANUAL, EnumSet.noneOf(com.skyblockexp.ezcountdown.display.DisplayType.class), 1, null, "fmt", "s", "e", List.of(), ZoneId.systemDefault());
+ cd.setRunning(false);
+
+ ActionResult result = action.handle(mock(InventoryClickEvent.class), player, "cd", Optional.of(cd));
+
+ assertTrue(result.isHandled());
+ assertTrue(result.isMutated());
+ verify(manager).startCountdown("cd");
+ verify(player).sendMessage("started");
+ }
+}
diff --git a/src/test/java/com/skyblockexp/ezcountdown/util/CountdownClonerTest.java b/src/test/java/com/skyblockexp/ezcountdown/util/CountdownClonerTest.java
new file mode 100644
index 0000000..3d688ae
--- /dev/null
+++ b/src/test/java/com/skyblockexp/ezcountdown/util/CountdownClonerTest.java
@@ -0,0 +1,67 @@
+package com.skyblockexp.ezcountdown.util;
+
+import com.skyblockexp.ezcountdown.api.model.Countdown;
+import com.skyblockexp.ezcountdown.api.model.CountdownBuilder;
+import com.skyblockexp.ezcountdown.api.model.CountdownType;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.LocalTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CountdownClonerTest {
+
+ @Test
+ void copyRuntimeFieldsCopiesAllValues() {
+ Countdown src = CountdownBuilder.builder("src")
+ .type(CountdownType.DURATION)
+ .durationSeconds(300)
+ .build();
+ src.setTargetInstant(Instant.parse("2026-01-01T00:00:30Z"));
+ src.setRecurringMonth(12);
+ src.setRecurringDay(25);
+ src.setRecurringTime(LocalTime.of(8, 30));
+ src.setStartSound("BLOCK_NOTE_BLOCK_PLING");
+ src.setEndSound("ENTITY_PLAYER_LEVELUP");
+ src.setRunning(true);
+
+ Countdown dest = CountdownBuilder.builder("dest")
+ .type(CountdownType.MANUAL)
+ .durationSeconds(10)
+ .build();
+
+ CountdownCloner.copyRuntimeFields(src, dest);
+
+ assertEquals(300, dest.getDurationSeconds());
+ assertEquals(src.getTargetInstant(), dest.getTargetInstant());
+ assertEquals(12, dest.getRecurringMonth());
+ assertEquals(25, dest.getRecurringDay());
+ assertEquals(LocalTime.of(8, 30), dest.getRecurringTime());
+ assertEquals("BLOCK_NOTE_BLOCK_PLING", dest.getStartSound());
+ assertEquals("ENTITY_PLAYER_LEVELUP", dest.getEndSound());
+ assertTrue(dest.isRunning());
+ }
+
+ @Test
+ void copyRuntimeFieldsHandlesNullTargetInstant() {
+ Countdown src = CountdownBuilder.builder("src-null")
+ .type(CountdownType.DURATION)
+ .durationSeconds(45)
+ .build();
+ src.setTargetInstant(null);
+
+ Countdown dest = CountdownBuilder.builder("dest-null")
+ .type(CountdownType.DURATION)
+ .durationSeconds(1)
+ .build();
+ dest.setTargetInstant(Instant.parse("2026-01-01T00:00:01Z"));
+
+ CountdownCloner.copyRuntimeFields(src, dest);
+
+ assertNull(dest.getTargetInstant());
+ assertEquals(45, dest.getDurationSeconds());
+ }
+}