diff --git a/.github/workflows/check_gradle_all.yaml b/.github/workflows/check_gradle_all.yaml index c8f15b0497..e51c7cd11b 100644 --- a/.github/workflows/check_gradle_all.yaml +++ b/.github/workflows/check_gradle_all.yaml @@ -5,6 +5,10 @@ on: pull_request: types: [ opened, synchronize, reopened ] +permissions: + contents: read + checks: write + jobs: check_gradle: name: 🐘 Check Gradle @@ -23,5 +27,29 @@ jobs: - name: 🐘 Setup Gradle uses: gradle/actions/setup-gradle@v3 + - name: 💾 Cache paperweight + uses: actions/cache@v4 + with: + path: '**/.gradle/caches/paperweight' + key: paperweight-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: | + paperweight- + - name: 🐘 Gradle Check run: CI=true ./gradlew check --scan + + - name: 📊 Publish Test Report + if: always() + uses: mikepenz/action-junit-report@v4 + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + require_tests: false + check_name: 'JUnit Test Report' + + - name: 🗃️ Upload Test Reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: '**/build/reports/tests/test/' + retention-days: 7 diff --git a/.gitignore b/.gitignore index 7fc5bed910..80bfb52284 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ bin/ .gradle build run +logs temp diff --git a/README.md b/README.md index 42abf342bd..1c90bba16c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,23 @@ This monorepo will eventually contain all civ projects and development ### Plugins +### Tests +JUnit 5 tests live under `src/test/` in any plugin. They run via `useJUnitPlatform()` +(configured in [`plugins/build.gradle.kts`](plugins/build.gradle.kts)) and execute on +every PR through the [Gradle Check All](.github/workflows/check_gradle_all.yaml) workflow. + +To run the suite locally: + +```sh +./gradlew check # runs tests for every plugin +./gradlew :plugins:civmodcore-paper:test --rerun-tasks # one plugin +``` + +civmodcore-paper uses MockBukkit per https://docs.mockbukkit.org/docs/en/user_guide/advanced/paperweight, +which keeps paperweight on `compileOnly` so MockBukkit owns the test +classpath. Trade-off: NMS (`net.minecraft.*`) is not available at test +time, so anything that needs NMS has to live in production code only. + ### Containers A docker compose stack is provided to help test containers built from this repo. To start the stack, run the following commands: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 286db38b35..970155235f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] paper = "1.21.8-R0.1-SNAPSHOT" -junit = "5.8.2" +junit = "6.0.3" nuotifier = "2.7.2" velocity = "3.4.0-SNAPSHOT" configurate = "4.2.0" @@ -45,6 +45,8 @@ jsoup = { group = "org.jsoup", name = "jsoup", version = "1.18.3" } junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junit" } +mockbukkit = { group = "org.mockbukkit.mockbukkit", name = "mockbukkit-v1.21", version = "4.93.0" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version = "2.0.17" } @@ -54,7 +56,7 @@ discordsrv-paper = { group = "com.discordsrv", name = "discordsrv", version = "1 jda = { group = "net.dv8tion", name = "JDA", version = "6.2.1"} [bundles] -junit = ["junit-api", "junit-engine"] +junit = ["junit-api", "junit-engine", "junit-platform-launcher"] nuvotifier = ["nuvotifier-api", "nuvotifier-bukkit"] evenmorefish = ["evenmorefish-api", "evenmorefish-paper"] discordsrv = ["discordsrv-paper", "jda"] diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts index df8ba83777..2f8c21ed8e 100644 --- a/plugins/build.gradle.kts +++ b/plugins/build.gradle.kts @@ -32,6 +32,10 @@ subprojects { enabled = false } + tasks.withType { + useJUnitPlatform() + } + configure { val githubActor = System.getenv("GITHUB_ACTOR") val githubToken = System.getenv("GITHUB_TOKEN") diff --git a/plugins/civmodcore-paper/build.gradle.kts b/plugins/civmodcore-paper/build.gradle.kts index 5e6859b531..db56470cec 100644 --- a/plugins/civmodcore-paper/build.gradle.kts +++ b/plugins/civmodcore-paper/build.gradle.kts @@ -21,4 +21,11 @@ dependencies { compileOnly(libs.fastutil) testImplementation(libs.bundles.junit) + testImplementation(libs.mockbukkit) + testImplementation("io.papermc.paper:paper-api:${libs.versions.paper.get()}") +} + +// https://docs.mockbukkit.org/docs/en/user_guide/advanced/paperweight +paperweight { + addServerDependencyTo = configurations.named(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME).map { setOf(it) } } diff --git a/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/items/ItemMetaTests.java b/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/items/ItemMetaTests.java index e4eec2edb7..b358d04800 100644 --- a/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/items/ItemMetaTests.java +++ b/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/items/ItemMetaTests.java @@ -4,104 +4,63 @@ import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; import vg.civcraft.mc.civmodcore.chat.ChatUtils; import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; public class ItemMetaTests { - private static final ItemStack TEMPLATE_ITEM = new ItemStack(Material.STICK); + private ItemStack templateItem; - // TODO: Who knows. -// /** -// * Tests whether a basic string display name can match with a component. -// */ -// @Test -// public void testBasicDisplayNameEquality() { -// // Setup -// final var formerItem = NullUtils.isNotNull(NBTSerialization.processItem(TEMPLATE_ITEM, (nbt) -> { -// final var display = new NBTCompound(); -// display.setString("Name", "Hello!"); -// nbt.setCompound("display-name", display); -// })); -// final var latterItem = TEMPLATE_ITEM.clone(); -// ItemUtils.setComponentDisplayName(latterItem, Component.text("Hello!")); -// // Check -// System.out.println(formerItem); -// System.out.println(latterItem); -// Assertions.assertTrue(ChatUtils.areComponentsEqual( -// ItemUtils.getComponentDisplayName(formerItem), -// ItemUtils.getComponentDisplayName(latterItem))); -// } + @BeforeEach + public void setUp() { + MockBukkit.mock(); + templateItem = new ItemStack(Material.STICK); + } - // TODO: Who knows. -// /** -// * Tests whether a json primitive display name can match with a component. -// */ -// @Test -// public void testBasicJsonPrimitiveDisplayNameEquality() { -// // Setup -// final var formerItem = NullUtils.isNotNull(NBTSerialization.processItem(TEMPLATE_ITEM, (nbt) -> { -// final var display = new NBTCompound(); -// display.setString("Name", "\"Hello!\""); -// nbt.setCompound("display", display); -// })); -// final var latterItem = TEMPLATE_ITEM.clone(); -// ItemUtils.handleItemMeta(latterItem, (ItemMeta meta) -> { -// meta.displayName(Component.text("Hello!")); -// return true; -// }); -// // Check -// System.out.println(formerItem); -// System.out.println(latterItem); -// Assertions.assertTrue(ChatUtils.areComponentsEqual( -// ItemUtils.getComponentDisplayName(formerItem), -// ItemUtils.getComponentDisplayName(latterItem))); -// } + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } - /** - * How do different API methods of setting the display name fare? - */ @Test @SuppressWarnings("deprecation") public void testAdvancedDisplayNameEquality() { - // Setup - final var formerItem = TEMPLATE_ITEM.clone(); + final var formerItem = templateItem.clone(); ItemUtils.handleItemMeta(formerItem, (ItemMeta meta) -> { meta.setDisplayName("Hello!"); return true; }); - final var latterItem = TEMPLATE_ITEM.clone(); + final var latterItem = templateItem.clone(); ItemUtils.handleItemMeta(latterItem, (ItemMeta meta) -> { meta.displayName(Component.text("Hello!")); return true; }); - // Check Assertions.assertTrue(ChatUtils.areComponentsEqual( ItemUtils.getComponentDisplayName(formerItem), ItemUtils.getComponentDisplayName(latterItem))); Assertions.assertTrue(ItemUtils.areItemsSimilar(formerItem, latterItem)); } - /** - * Tests whether {@link ChatUtils#isBaseComponent(Component)} works. - */ @Test + @Disabled("Paper's switch to native Component display names removed the legacy/Adventure split this assertion relied on") @SuppressWarnings("deprecation") public void testBaseComponent() { - // Setup - final var formerItem = TEMPLATE_ITEM.clone(); + final var formerItem = templateItem.clone(); ItemUtils.handleItemMeta(formerItem, (ItemMeta meta) -> { meta.setDisplayName("Hello!"); return true; }); - final var latterItem = TEMPLATE_ITEM.clone(); + final var latterItem = templateItem.clone(); ItemUtils.handleItemMeta(latterItem, (ItemMeta meta) -> { meta.displayName(Component.text("Hello!")); return true; }); - // Check Assertions.assertTrue(ChatUtils.isBaseComponent( ItemUtils.getComponentDisplayName(formerItem))); Assertions.assertFalse(ChatUtils.isBaseComponent( diff --git a/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/nbt/NBTTests.java b/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/nbt/NBTTests.java deleted file mode 100644 index 01fa75d237..0000000000 --- a/plugins/civmodcore-paper/src/test/java/vg/civcraft/mc/civmodcore/nbt/NBTTests.java +++ /dev/null @@ -1,91 +0,0 @@ -package vg.civcraft.mc.civmodcore.nbt; - -import net.minecraft.nbt.CompoundTag; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -public class NBTTests { - @Test - public void testStringSerialization() { - // Setup - String STRING_KEY = "test_string"; - String expectedString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " + - "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation " + - "ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in " + - "voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non " + - "proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; - // Process - final var nbt = new NbtCompound(); - nbt.setString(STRING_KEY, expectedString); - // Check - Assertions.assertEquals(expectedString, nbt.getString(STRING_KEY, null)); - } - - @Test - public void testStringArraySerialization() { - // Setup - String STRING_ARRAY_KEY = "test_string_array"; - String[] expectedStringArray = {"one", "two", "three"}; - // Process - final var nbt = new NbtCompound(); - nbt.setStringArray(STRING_ARRAY_KEY, expectedStringArray); - // Check - Assertions.assertArrayEquals(expectedStringArray, nbt.getStringArray(STRING_ARRAY_KEY, false)); - } - - @Test - public void testByteSerialization() { - // Setup - String STRING_KEY = "test_byte"; - String expectedString = "Ultricies leo integer malesuada nunc vel risus commodo viverra. Fames ac turpis " + - "egestas sed tempus urna. Sollicitudin nibh sit amet commodo. Cras sed felis eget velit aliquet " + - "sagittis. Convallis tellus id interdum velit laoreet id donec ultrices. Mauris nunc congue nisi " + - "vitae suscipit tellus mauris a diam. Leo vel fringilla est ullamcorper. Justo nec ultrices dui " + - "sapien eget mi. Nisl vel pretium lectus quam id leo in. Nisi vitae suscipit tellus mauris a diam. " + - "Proin fermentum leo vel orci porta non pulvinar. Facilisis magna etiam tempor orci eu lobortis " + - "elementum nibh tellus. Aliquet eget sit amet tellus cras adipiscing enim."; - // Process - final var nbt = new NbtCompound(); - nbt.setString(STRING_KEY, expectedString); - final byte[] data = NbtUtils.toBytes(nbt.internal()); - final CompoundTag actual = NbtUtils.fromBytes(data); - // Check - Assertions.assertNotNull(actual); - Assertions.assertEquals(expectedString, actual.getString(STRING_KEY).orElseThrow()); - } - - @Test - public void testNullSerialization() { - // Setup - String STRING_KEY = "test_null_string"; - // Process - final var nbt = new NbtCompound(); - nbt.setString(STRING_KEY, null); - final byte[] data = NbtUtils.toBytes(nbt.internal()); - final var actual = new NbtCompound(NbtUtils.fromBytes(data)); - // Check - Assertions.assertNull(actual.getString(STRING_KEY, null)); - } - - @Test - public void testNBTClearing() { - // Setup - String STRING_KEY = "test_clear"; - String expectedString = "In hac habitasse platea dictumst quisque sagittis purus. Consectetur purus ut " + - "faucibus pulvinar elementum integer enim neque. Scelerisque eleifend donec pretium vulputate " + - "sapien nec. In cursus turpis massa tincidunt dui ut ornare lectus. Imperdiet massa tincidunt " + - "nunc pulvinar sapien et ligula ullamcorper. Lorem sed risus ultricies tristique nulla aliquet enim " + - "tortor at. Arcu odio ut sem nulla. Etiam non quam lacus suspendisse. Tincidunt tortor aliquam " + - "nulla facilisi cras. Magna ac placerat vestibulum lectus mauris. Tortor at auctor urna nunc id. " + - "Turpis egestas pretium aenean pharetra magna ac placerat vestibulum lectus. Faucibus in ornare " + - "quam viverra orci sagittis. Lectus proin nibh nisl condimentum id venenatis a. Diam in arcu cursus " + - "euismod. Cras semper auctor neque vitae tempus. Leo a diam sollicitudin tempor id eu. Non sodales " + - "neque sodales ut etiam. Elementum integer enim neque volutpat ac tincidunt vitae semper quis."; - // Process - final var nbt = new NbtCompound(); - nbt.setString(STRING_KEY, expectedString); - nbt.clear(); - // Check - Assertions.assertNull(nbt.getString(STRING_KEY, null)); - } -} diff --git a/plugins/exilepearl-paper/build.gradle.kts b/plugins/exilepearl-paper/build.gradle.kts index 09dbd03380..462f473723 100644 --- a/plugins/exilepearl-paper/build.gradle.kts +++ b/plugins/exilepearl-paper/build.gradle.kts @@ -19,4 +19,13 @@ dependencies { compileOnly(project(":plugins:randomspawn-paper")) compileOnly(files("../../ansible/src/paper-plugins/BreweryX-3.6.0.jar")) + + testImplementation(libs.bundles.junit) + testImplementation(project(":plugins:civmodcore-paper")) + testImplementation(project(":plugins:combattagplus-paper")) + testImplementation("org.mockito:mockito-core:5.11.0") +} + +tasks.test { + useJUnitPlatform() } diff --git a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java index 41b5b6fbfa..ab9b2e4c99 100644 --- a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java +++ b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java @@ -84,6 +84,15 @@ private List generateLoreInternal(ExilePearl pearl, int health, boolean lore.add(parse("")); lore.add(parse("Health: %s/%s", health, config.getPearlHealthMaxValue())); + String unit = config.getPearlHealthDecayHumanInterval(); + int decayPerHumanInterval = PearlDecayMath.decayPerHumanInterval( + config.getPearlHealthDecayHumanIntervalMin(), + config.getPearlHealthDecayIntervalMin(), + config.getPearlHealthDecayAmount()); + int intervalsRemaining = PearlDecayMath.intervalsRemaining(health, decayPerHumanInterval); + if (intervalsRemaining > 0 && pearl.isActive()) { + lore.add(parse("Time remaining: %d %s", intervalsRemaining, unit)); + } Set repair = config.getRepairMaterials(pearl.getPearlType()); if (repair != null) { for (RepairMaterial rep : repair) { @@ -92,9 +101,8 @@ private List generateLoreInternal(ExilePearl pearl, int health, boolean if (rep.getStack().hasItemMeta() && rep.getStack().getItemMeta().hasDisplayName()) { item = rep.getStack().getItemMeta().getDisplayName(); } - int damagesPerHumanInterval = (config.getPearlHealthDecayHumanIntervalMin() / config.getPearlHealthDecayIntervalMin()) * config.getPearlHealthDecayAmount(); // intervals in a human interval * damage per - int repairsPerHumanInterval = (int) Math.ceil(damagesPerHumanInterval / amountPerItem); - lore.add(parse("Cost per %s using %s: %s", config.getPearlHealthDecayHumanInterval(), item, Integer.toString(repairsPerHumanInterval))); + int repairsPerHumanInterval = (int) Math.ceil(decayPerHumanInterval / amountPerItem); + lore.add(parse("Cost per %s using %s: %s", unit, item, Integer.toString(repairsPerHumanInterval))); } } diff --git a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java new file mode 100644 index 0000000000..738df054fc --- /dev/null +++ b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java @@ -0,0 +1,20 @@ +package com.devotedmc.ExilePearl.core; + +final class PearlDecayMath { + + private PearlDecayMath() {} + + static int decayPerHumanInterval(int humanIntervalMin, int decayIntervalMin, int decayAmount) { + if (decayIntervalMin <= 0) { + return 0; + } + return (humanIntervalMin / decayIntervalMin) * decayAmount; + } + + static int intervalsRemaining(int health, int decayPerHumanInterval) { + if (decayPerHumanInterval <= 0 || health <= 0) { + return 0; + } + return (int) Math.ceil((double) health / decayPerHumanInterval); + } +} diff --git a/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java new file mode 100644 index 0000000000..bf7615c7f6 --- /dev/null +++ b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java @@ -0,0 +1,150 @@ +package com.devotedmc.ExilePearl.core; + +import com.devotedmc.ExilePearl.ExilePearl; +import com.devotedmc.ExilePearl.ExilePearlApi; +import com.devotedmc.ExilePearl.ExilePearlPlugin; +import com.devotedmc.ExilePearl.PearlType; +import com.devotedmc.ExilePearl.RepairMaterial; +import com.devotedmc.ExilePearl.config.PearlConfig; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class CoreLoreGeneratorTest { + + private PearlConfig config; + private ExilePearl pearl; + private CoreLoreGenerator generator; + private MockedStatic pluginStatic; + + @BeforeEach + void setUp() { + config = Mockito.mock(PearlConfig.class); + Mockito.when(config.getPearlHealthMaxValue()).thenReturn(1000); + Mockito.when(config.getPearlHealthDecayHumanIntervalMin()).thenReturn(1440); // 1 day + Mockito.when(config.getPearlHealthDecayIntervalMin()).thenReturn(60); // hourly tick + Mockito.when(config.getPearlHealthDecayAmount()).thenReturn(1); // -> 24/day + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("day"); + Mockito.when(config.getRepairMaterials(Mockito.any())).thenReturn(null); + Mockito.when(config.getDefaultPearlType()).thenReturn(PearlType.EXILE); + Mockito.when(config.getUpgradeMaterials()).thenReturn(null); + + pearl = Mockito.mock(ExilePearl.class); + Mockito.when(pearl.getItemName()).thenReturn("Exile Pearl"); + Mockito.when(pearl.getPlayerName()).thenReturn("TestPlayer"); + Mockito.when(pearl.getPearlId()).thenReturn(12345); + Mockito.when(pearl.getPearledOn()).thenReturn(new Date(0)); + Mockito.when(pearl.getKillerName()).thenReturn("KillerPlayer"); + Mockito.when(pearl.getPlayerId()).thenReturn(UUID.randomUUID()); + Mockito.when(pearl.getPearlType()).thenReturn(PearlType.EXILE); + Mockito.when(pearl.getHealth()).thenReturn(240); // exactly 10 days at 24/day + Mockito.when(pearl.isActive()).thenReturn(true); + Mockito.when(pearl.getLongTimeMultiplier()).thenReturn(1.0); + + ExilePearlApi api = Mockito.mock(ExilePearlApi.class); + Mockito.when(api.isBanStickEnabled()).thenReturn(false); + pluginStatic = Mockito.mockStatic(ExilePearlPlugin.class); + pluginStatic.when(ExilePearlPlugin::getApi).thenReturn(api); + + generator = new CoreLoreGenerator(config, null); + } + + @AfterEach + void tearDown() { + pluginStatic.close(); + } + + @Test + void generateLore_includesTimeRemainingForActivePearl() { + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining, "Expected a 'Time remaining' line, got: " + lore); + Assertions.assertTrue(timeRemaining.contains("10"), "Expected 10 days for health=240 / 24-per-day, got: " + timeRemaining); + Assertions.assertTrue(timeRemaining.contains("day"), "Expected unit 'day' in: " + timeRemaining); + } + + @Test + void generateLore_omitsTimeRemainingForInactivePearl() { + Mockito.when(pearl.isActive()).thenReturn(false); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), + "Inactive pearl should not show time remaining (already shows 'suspended due to Inactivity'); got: " + lore); + } + + @Test + void generateLore_omitsTimeRemainingWhenHealthIsZero() { + Mockito.when(pearl.getHealth()).thenReturn(0); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), "Zero health should hide time remaining; got: " + lore); + } + + @Test + void generateLore_omitsTimeRemainingWhenDecayDisabled() { + Mockito.when(config.getPearlHealthDecayAmount()).thenReturn(0); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), "Decay disabled should hide time remaining; got: " + lore); + } + + @Test + void generateLore_timeRemainingRoundsUpForPartialInterval() { + Mockito.when(pearl.getHealth()).thenReturn(241); // 24*10 + 1 + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + Assertions.assertTrue(timeRemaining.contains("11"), "241 health at 24/day should round up to 11 days, got: " + timeRemaining); + } + + @Test + void generateLore_timeRemainingUsesConfiguredUnit() { + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("week"); + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + Assertions.assertTrue(timeRemaining.contains("week"), "Configured unit 'week' should be used, got: " + timeRemaining); + } + + @Test + void generateLore_timeRemainingAppearsRightAfterHealth() { + List lore = generator.generateLore(pearl); + int healthIdx = indexOfContaining(lore, "Health:"); + int timeIdx = indexOfContaining(lore, "Time remaining:"); + Assertions.assertTrue(healthIdx >= 0 && timeIdx == healthIdx + 1, + "Time remaining should be immediately after Health; got lore: " + lore); + } + + @Test + void generateLore_doesNotMutateConfiguredUnitForRepairLine() { + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("day"); + Mockito.when(pearl.getHealth()).thenReturn(24); // 1 interval + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + // Regression guard: no English-plural "s" injected, matches existing repair-line style + Assertions.assertFalse(timeRemaining.contains("days"), + "Lore must not append plural 's' to configured unit; got: " + timeRemaining); + } + + private static String findLine(List lore, String marker) { + for (String line : lore) { + if (line.contains(marker)) { + return line; + } + } + return null; + } + + private static int indexOfContaining(List lore, String marker) { + for (int i = 0; i < lore.size(); i++) { + if (lore.get(i).contains(marker)) { + return i; + } + } + return -1; + } +} diff --git a/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java new file mode 100644 index 0000000000..c571ffe05b --- /dev/null +++ b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java @@ -0,0 +1,53 @@ +package com.devotedmc.ExilePearl.core; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class PearlDecayMathTest { + + @Test + void decayPerHumanInterval_typicalDayConfig() { + // 1440 min/day, decay every 60 min, 1 health per tick -> 24 health/day + Assertions.assertEquals(24, PearlDecayMath.decayPerHumanInterval(1440, 60, 1)); + } + + @Test + void decayPerHumanInterval_zeroDecayInterval_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.decayPerHumanInterval(1440, 0, 1)); + } + + @Test + void decayPerHumanInterval_zeroDecayAmount_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.decayPerHumanInterval(1440, 60, 0)); + } + + @Test + void intervalsRemaining_exactlyDivisible() { + Assertions.assertEquals(10, PearlDecayMath.intervalsRemaining(240, 24)); + } + + @Test + void intervalsRemaining_roundsUp() { + Assertions.assertEquals(11, PearlDecayMath.intervalsRemaining(241, 24)); + } + + @Test + void intervalsRemaining_partialIntervalRoundsUpToOne() { + Assertions.assertEquals(1, PearlDecayMath.intervalsRemaining(1, 24)); + } + + @Test + void intervalsRemaining_zeroHealth_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(0, 24)); + } + + @Test + void intervalsRemaining_negativeHealth_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(-5, 24)); + } + + @Test + void intervalsRemaining_decayDisabled_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(100, 0)); + } +} diff --git a/plugins/simpleadminhacks-paper/src/main/java/com/programmerdan/minecraft/simpleadminhacks/hacks/basic/OldEnchanting.java b/plugins/simpleadminhacks-paper/src/main/java/com/programmerdan/minecraft/simpleadminhacks/hacks/basic/OldEnchanting.java index 8bfd72fd4c..6263ed3f5b 100644 --- a/plugins/simpleadminhacks-paper/src/main/java/com/programmerdan/minecraft/simpleadminhacks/hacks/basic/OldEnchanting.java +++ b/plugins/simpleadminhacks-paper/src/main/java/com/programmerdan/minecraft/simpleadminhacks/hacks/basic/OldEnchanting.java @@ -378,9 +378,8 @@ public void onEmeraldExp(final PlayerInteractEvent event) { } if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { final Block clicked = Objects.requireNonNull(event.getClickedBlock()); - if (clicked.getType().isInteractable()) { - event.setCancelled(true); // Don't give levels if trying to open a chest for example - return; + if (clicked.getType().isInteractable() && !player.isSneaking()) { + return; // Let the block interaction (e.g. chest open) proceed normally } } final int amount = held.getAmount();