From a12b915366665024d373b23428d2af99e35facf3 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:37:49 +0000 Subject: [PATCH] test: cover surgery runtime and fix treatment regressions --- .github/workflows/build.yml | 8 + README.md | 9 + pom.xml | 65 ++- .../surgery/managers/SurgeryItemHandler.java | 13 +- .../surgery/procedures/Durations.java | 24 +- .../surgery/SurgeryLifecycleTest.java | 175 ++++++++ .../surgery/commands/SurgeryCommandTest.java | 257 ++++++++++++ .../managers/SurgeryCompletionTest.java | 230 ++++++++++ .../managers/SurgeryItemHandlerTest.java | 393 ++++++++++++++++++ .../managers/SurgeryMechanicsManagerTest.java | 363 ++++++++++++++++ .../managers/SurgeryPresentationTest.java | 222 ++++++++++ .../managers/SurgeryStateAndRequestTest.java | 159 +++++++ .../surgery/procedures/DurationsTest.java | 25 ++ .../procedures/ProcedureRegistryTest.java | 10 + 14 files changed, 1937 insertions(+), 16 deletions(-) create mode 100644 src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java create mode 100644 src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3099946..d137804 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,3 +70,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index 7995eda..9fb5e02 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ Originally created by [Justinas Launikonis](https://github.com/JustinasLa). Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests and coverage + +Run `mvn clean verify` with Java 21 and the pinned plugin dependencies installed. +The build runs the tests and enforces **100% executable-line coverage** across +all production Java classes, with no coverage exclusions. JaCoCo's HTML and XML +reports are written to `target/site/jacoco/` and uploaded by the Build workflow. +Branch and instruction coverage are reported separately; the enforced threshold +is line coverage. Build and maintenance scripts are outside this runtime-code metric. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index 2884d0a..216c77e 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ tfminecraft.net + 21 UTF-8 2.4.0 @@ -19,6 +20,12 @@ + + org.mockito + mockito-core + 5.23.0 + test + io.papermc.paper @@ -57,7 +64,13 @@ org.junit.jupiter junit-jupiter - 5.10.2 + 5.14.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 test @@ -89,13 +102,57 @@ org.apache.maven.plugins - maven-compiler-plugin - 3.14.1 + maven-dependency-plugin + 3.7.0 + + properties + org.apache.maven.plugins maven-surefire-plugin - 3.2.5 + 3.5.4 + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java index 602ea90..2cfc49a 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java @@ -93,6 +93,12 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { // Update the menu based on what was clicked updateMenu(player, tool); + // Tool handling can also end the operation (for example, chloroform + // misuse). Completion owns the final feedback once the session ends. + if (!stateManager.hasSession(playerId)) { + return; + } + // Play correct sound only if no skill fail occurred String skillFailMsg = stateManager.getSkillFail(playerId); if (skillFailMsg.isEmpty()) { @@ -115,6 +121,10 @@ private void updateMenu(Player player, SurgeryTool tool) { UUID playerId = player.getUniqueId(); boolean skillFail = isSkillFail(playerId); + // Consume the previous sponge's protection after this move's roll. + // A successful sponge below can then protect the following move. + stateManager.setSpongeEffect(playerId, false); + String skillFailMsg = switch (tool) { case SPONGE -> handleSponge(player, menu, playerId, skillFail); case SCALPEL -> handleScalpel(player, menu, playerId, skillFail); @@ -150,9 +160,6 @@ private void updateMenu(Player player, SurgeryTool tool) { ItemStack skillFailBlock = uiUpdater.createInfoBlock(skillFailColor, ChatColor.GOLD + "Skill Fail", skillFailDisplay); menu.setItem(SurgeryConstants.SLOT_SKILL_FAIL, skillFailBlock); - // Clear sponge effect (it only lasts for one move) - stateManager.setSpongeEffect(playerId, false); - // Play "broken item" sound if skill fail occurred if (!skillFailMsg.isEmpty()) { player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 1.0f); diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Durations.java b/src/main/java/net/tfminecraft/surgery/procedures/Durations.java index ada9ad7..dacabdc 100644 --- a/src/main/java/net/tfminecraft/surgery/procedures/Durations.java +++ b/src/main/java/net/tfminecraft/surgery/procedures/Durations.java @@ -27,13 +27,18 @@ public static long parseMs(String value) { if (!normalized.substring(end, matcher.start()).isBlank()) { return -1L; } - long amount = Long.parseLong(matcher.group(1)); - total += switch (matcher.group(2)) { - case "d" -> amount * 86_400_000L; - case "h" -> amount * 3_600_000L; - case "m" -> amount * 60_000L; - default -> amount * 1_000L; - }; + try { + long amount = Long.parseLong(matcher.group(1)); + long unit = switch (matcher.group(2)) { + case "d" -> 86_400_000L; + case "h" -> 3_600_000L; + case "m" -> 60_000L; + default -> 1_000L; + }; + total = Math.addExact(total, Math.multiplyExact(amount, unit)); + } catch (NumberFormatException | ArithmeticException ex) { + return -1L; + } end = matcher.end(); } return end > 0 && normalized.substring(end).isBlank() ? total : -1L; @@ -44,8 +49,9 @@ public static long parseMs(String value) { // ============================================== public static String formatHours(long ms) { if (ms < 3_600_000L) { - return Math.max(0L, (ms + 59_999L) / 60_000L) + "m"; + long nonnegative = Math.max(0L, ms); + return (nonnegative / 60_000L + (nonnegative % 60_000L == 0 ? 0 : 1)) + "m"; } - return ((ms + 3_599_999L) / 3_600_000L) + "h"; + return (ms / 3_600_000L + (ms % 3_600_000L == 0 ? 0 : 1)) + "h"; } } diff --git a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java new file mode 100644 index 0000000..e603407 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.surgery; + +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.listeners.PlayerListener; +import net.tfminecraft.surgery.managers.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +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 org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SurgeryLifecycleTest { + private ServerMock server; + private MockedStatic tlibs; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("RPCharacters"); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.PAPER)); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + } + + @AfterEach + void tearDown() throws Exception { + try { + // MockBukkit leaves the top inventory null after closeInventory; + // the live server restores the player's crafting inventory. + for (Player player : server.getOnlinePlayers()) { + player.openInventory(server.createInventory(null, 9)); + } + MockBukkit.unmock(); + } finally { + if (tlibs != null) tlibs.close(); + var instance = SurgeryPlugin.class.getDeclaredField("instance"); + instance.setAccessible(true); + instance.set(null, null); + } + } + + private SurgeryMenuManager manager(SurgeryPlugin plugin) throws Exception { + var field = SurgeryPlugin.class.getDeclaredField("surgeryMenuManager"); + field.setAccessible(true); + return (SurgeryMenuManager) field.get(plugin); + } + + @Test + void enablesManagersRegistersCommandAndClosesOnlySurgeryMenusOnShutdown() throws Exception { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + assertSame(plugin, SurgeryPlugin.getInstance()); + assertTrue(plugin.isEnabled()); + assertNotNull(plugin.getCommand("surgery").getExecutor()); + assertSame(plugin.getCommand("surgery").getExecutor(), plugin.getCommand("surgery").getTabCompleter()); + SurgeryMenuManager menus = manager(plugin); + assertNotNull(menus.getUiUpdater()); + assertNotNull(menus.getStateManager()); + assertNotNull(menus.getRequestManager()); + assertNotNull(menus.getMenuBuilder()); + assertNotNull(menus.getCompletionHandler()); + assertNotNull(menus.getMechanicsManager()); + assertNotNull(menus.getItemHandler()); + PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(), other = server.addPlayer(); + patient.openInventory(server.createInventory(null, 9)); + Inventory ordinary = server.createInventory(null, 9); + other.openInventory(ordinary); + menus.openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Leg", 1_000)); + assertEquals(patient.getUniqueId(), menus.getStateManager().getPatientUuid(surgeon.getUniqueId())); + assertEquals("leg", menus.getStateManager().getTraitId(surgeon.getUniqueId())); + assertTrue(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); + assertFalse(menus.isSurgeryMenu(null)); + assertFalse(menus.isSurgeryMenu(ordinary)); + menus.handleItemClick(surgeon, null, 0); + plugin.onDisable(); + assertFalse(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); + assertSame(ordinary, other.getOpenInventory().getTopInventory()); + } + + @Test + void missingSharedApiDisablesPluginBeforeCommandRegistration() { + tlibs.when(TLibs::getItemAPI).thenReturn(null); + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + assertFalse(plugin.isEnabled()); + } + + @Test + void managerDelegatesAbandonmentAndDisconnectsWithoutLeakingSessions() throws Exception { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + SurgeryMenuManager menus = manager(plugin); + SurgeryStateManager state = menus.getStateManager(); + PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(); + menus.handlePatientQuit(patient); + state.setPatientName(surgeon.getUniqueId(), "Patient"); + menus.handleSurgeryAbandonment(surgeon); + assertFalse(state.hasSession(surgeon.getUniqueId())); + state.setPatientName(surgeon.getUniqueId(), "Patient"); + menus.handleSurgeonQuit(surgeon); + assertFalse(state.hasSession(surgeon.getUniqueId())); + state.setPatientUuid(surgeon.getUniqueId(), patient.getUniqueId()); + menus.handlePatientQuit(patient); + assertFalse(state.hasSession(surgeon.getUniqueId())); + UUID missingSurgeon = UUID.randomUUID(); + state.setPatientUuid(missingSurgeon, patient.getUniqueId()); + menus.handlePatientQuit(patient); + assertFalse(state.hasSession(missingSurgeon)); + server.getScheduler().performOneTick(); + } + + @Test + void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { + SurgeryMenuManager menus = mock(SurgeryMenuManager.class); + SurgeryRequestManager requests = new SurgeryRequestManager(); + when(menus.getRequestManager()).thenReturn(requests); + PlayerListener listener = new PlayerListener(menus); + PlayerMock player = server.addPlayer(); + Inventory top = new SurgeryMenuHolder().getInventory(); + top.setItem(1, new ItemStack(Material.PAPER)); + player.openInventory(top); + when(menus.isSurgeryMenu(top)).thenReturn(true); + InventoryClickEvent click = new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.CONTAINER, + 1, ClickType.LEFT, InventoryAction.PICKUP_ALL); + listener.onInventoryClick(click); + assertTrue(click.isCancelled()); + verify(menus).handleItemClick(player, top.getItem(1), 1); + InventoryClickEvent bottom = new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.CONTAINER, + 54, ClickType.LEFT, InventoryAction.PICKUP_ALL); + listener.onInventoryClick(bottom); + assertTrue(bottom.isCancelled()); + InventoryClickEvent outside = new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.OUTSIDE, + -999, ClickType.LEFT, InventoryAction.NOTHING); + listener.onInventoryClick(outside); + assertTrue(outside.isCancelled()); + verify(menus, times(1)).handleItemClick(any(), any(), anyInt()); + InventoryDragEvent drag = mock(InventoryDragEvent.class); + when(drag.getInventory()).thenReturn(top); + listener.onInventoryDrag(drag); + verify(drag).setCancelled(true); + listener.onInventoryClose(new InventoryCloseEvent(player.getOpenInventory())); + verify(menus).handleSurgeryAbandonment(player); + requests.offer(player.getUniqueId(), UUID.randomUUID(), "leg", Long.MAX_VALUE); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + listener.onPlayerQuit(quit); + verify(menus).handleSurgeonQuit(player); + verify(menus).handlePatientQuit(player); + assertNull(requests.pending(player.getUniqueId(), 0)); + + when(menus.isSurgeryMenu(top)).thenReturn(false); + click.setCancelled(false); + listener.onInventoryClick(click); + assertFalse(click.isCancelled()); + listener.onInventoryClose(new InventoryCloseEvent(player.getOpenInventory())); + verify(menus, times(1)).handleSurgeryAbandonment(player); + clearInvocations(drag); + listener.onInventoryDrag(drag); + verify(drag, never()).setCancelled(anyBoolean()); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java b/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java new file mode 100644 index 0000000..ffebf00 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java @@ -0,0 +1,257 @@ +package net.tfminecraft.surgery.commands; + +import net.kyori.adventure.text.Component; +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.SurgeryPlugin; +import net.tfminecraft.surgery.managers.SurgeryMenuManager; +import net.tfminecraft.surgery.managers.SurgeryRequestManager; +import net.tfminecraft.surgery.managers.SurgeryStateManager; +import net.tfminecraft.surgery.managers.SurgeryUIUpdater; +import net.tfminecraft.surgery.procedures.Ailments; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SurgeryCommandTest { + private Player surgeon, patient; + private SurgeryMenuManager menus; + private SurgeryStateManager state; + private SurgeryRequestManager requests; + private YamlConfiguration config; + private SurgeryCommand command; + private Command bukkitCommand; + private HealingInjury injury; + private MockedStatic bukkit; + private MockedStatic ailments; + private World world; + + @BeforeEach + void setUp() { + var server = MockBukkit.mock(); + world = server.addSimpleWorld("world"); + surgeon = player("Surgeon"); + patient = player("Patient"); + menus = mock(SurgeryMenuManager.class); + state = new SurgeryStateManager(); + requests = new SurgeryRequestManager(); + SurgeryUIUpdater ui = mock(SurgeryUIUpdater.class); + when(ui.getMessage(anyString())).thenAnswer(c -> c.getArgument(0) + " %patient% %surgeon% %ailment% %remaining% %extra% %seconds%"); + when(ui.getMessage(anyString(), anyString())).thenAnswer(c -> c.getArgument(0) + " %distance%"); + when(menus.getStateManager()).thenReturn(state); + when(menus.getRequestManager()).thenReturn(requests); + when(menus.getUiUpdater()).thenReturn(ui); + SurgeryPlugin plugin = mock(SurgeryPlugin.class); + config = new YamlConfiguration(); + when(plugin.getConfig()).thenReturn(config); + command = new SurgeryCommand(menus, plugin); + bukkitCommand = mock(Command.class); + injury = new HealingInjury("leg", "§cBroken leg", 7_200_000L); + bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + bukkit.when(() -> Bukkit.getPlayerExact("Patient")).thenReturn(patient); + bukkit.when(() -> Bukkit.getPlayer(surgeon.getUniqueId())).thenReturn(surgeon); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(surgeon, patient)); + ailments = mockStatic(Ailments.class); + ailments.when(() -> Ailments.mostSevere(patient)).thenReturn(injury); + ailments.when(() -> Ailments.find(patient, "leg")).thenReturn(injury); + } + + @AfterEach + void tearDown() { + ailments.close(); + bukkit.close(); + MockBukkit.unmock(); + } + + private Player player(String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn(name); + when(player.isOnline()).thenReturn(true); + when(player.hasPermission(anyString())).thenReturn(true); + when(player.canSee(any(Player.class))).thenReturn(true); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + return player; + } + + private void run(CommandSender sender, String... args) { + assertTrue(command.onCommand(sender, bukkitCommand, "surgery", args)); + } + + private void message(Player player, String prefix) { + verify(player).sendMessage(startsWith(prefix)); + } + + private void offerRequest() { + requests.offer(patient.getUniqueId(), surgeon.getUniqueId(), "leg", Long.MAX_VALUE); + } + + @Test + void consoleUsagePermissionsAndMissingPatientsProduceFeedback() { + CommandSender console = mock(CommandSender.class); + run(console, "Patient"); + verify(console).sendMessage(startsWith("command-console")); + run(surgeon); + message(surgeon, "command-usage"); + when(surgeon.hasPermission(anyString())).thenReturn(false); + run(surgeon, "Patient"); + message(surgeon, "command-no-permission"); + when(surgeon.hasPermission(anyString())).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayerExact("Patient")).thenReturn(null); + run(surgeon, "Patient"); + message(surgeon, "command-player-not-found"); + bukkit.when(() -> Bukkit.getPlayerExact("Patient")).thenReturn(patient); + when(patient.isOnline()).thenReturn(false); + run(surgeon, "Patient"); + verify(surgeon, times(2)).sendMessage(startsWith("command-player-not-found")); + } + + @ParameterizedTest + @ValueSource(strings = {"surgeon-busy", "self", "patient-busy", "patient-operating", "other-world", "too-far", "fractional-range"}) + void offersRejectConflictingSessionsAndDistantPatients(String reason) { + String feedback; + switch (reason) { + case "surgeon-busy" -> { state.setPatientName(surgeon.getUniqueId(), "Other"); feedback = "command-already-in-surgery"; } + case "self" -> { doReturn(surgeon.getUniqueId()).when(patient).getUniqueId(); feedback = "command-self-surgery"; } + case "patient-busy" -> { state.setPatientUuid(UUID.randomUUID(), patient.getUniqueId()); feedback = "command-patient-in-surgery"; } + case "patient-operating" -> { state.setPatientName(patient.getUniqueId(), "Other"); feedback = "command-patient-is-operating"; } + case "other-world" -> { when(patient.getWorld()).thenReturn(mock(World.class)); feedback = "command-too-far"; } + default -> { + config.set("max-surgery-distance", reason.equals("fractional-range") ? 7.5 : 5.0); + when(patient.getLocation()).thenReturn(new Location(world, 10, 64, 0)); + feedback = "command-too-far"; + } + } + run(surgeon, "Patient"); + message(surgeon, feedback); + assertNull(requests.pending(patient.getUniqueId(), 0)); + verify(menus, never()).openSurgeryMenu(any(), any(), any()); + } + + @Test + void offersRequireAnAilmentAndDoNotReplacePendingConsent() { + ailments.when(() -> Ailments.mostSevere(patient)).thenReturn(null); + run(surgeon, "Patient"); + message(surgeon, "command-no-ailment"); + ailments.when(() -> Ailments.mostSevere(patient)).thenReturn(injury); + offerRequest(); + var old = requests.pending(patient.getUniqueId(), 0); + run(surgeon, "Patient"); + message(surgeon, "request-already-pending"); + assertSame(old, requests.pending(patient.getUniqueId(), 0)); + } + + @Test + void offersStripFormattingDescribePenaltyAndProvideConsentButtons() { + long before = System.currentTimeMillis(); + run(surgeon, "Patient"); + var request = requests.pending(patient.getUniqueId(), before); + assertEquals(surgeon.getUniqueId(), request.surgeonId()); + assertEquals("leg", request.traitId()); + assertTrue(request.expiresAt() >= before + 60_000); + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(patient).sendMessage(messages.capture()); + assertTrue(messages.getValue().contains("Broken leg 2h 12h 60")); + assertFalse(messages.getValue().contains("§c")); + ArgumentCaptor buttons = ArgumentCaptor.forClass(Component.class); + verify(patient).sendMessage(buttons.capture()); + assertEquals("/surgery accept", buttons.getValue().clickEvent().value()); + assertTrue(buttons.getValue().children().stream().anyMatch(c -> c.clickEvent() != null && c.clickEvent().value().equals("/surgery deny"))); + } + + @Test + void blankPermissionClampedTimeoutAndInvalidPenaltyAreHandled() { + config.set("permission", " "); + config.set("request-timeout-seconds", 1); + config.set("failure-healing-penalty", "invalid"); + when(surgeon.hasPermission(anyString())).thenReturn(false); + long before = System.currentTimeMillis(); + run(surgeon, "Patient"); + assertTrue(requests.pending(patient.getUniqueId(), before).expiresAt() >= before + 10_000); + verify(patient).sendMessage(contains("invalid 10")); + } + + @Test + void acceptsOnlyFreshConsentFromAnAvailablePermittedSurgeon() { + run(patient, "AcCePt"); + message(patient, "request-none"); + offerRequest(); + bukkit.when(() -> Bukkit.getPlayer(surgeon.getUniqueId())).thenReturn(null); + run(patient, "accept"); + message(patient, "request-surgeon-gone"); + offerRequest(); + bukkit.when(() -> Bukkit.getPlayer(surgeon.getUniqueId())).thenReturn(surgeon); + when(surgeon.isOnline()).thenReturn(false); + run(patient, "accept"); + when(surgeon.isOnline()).thenReturn(true); + when(surgeon.hasPermission(anyString())).thenReturn(false); + offerRequest(); + run(patient, "accept"); + verify(patient, times(3)).sendMessage(startsWith("request-surgeon-gone")); + verify(menus, never()).openSurgeryMenu(any(), any(), any()); + } + + @Test + void acceptingRechecksStateAndInjuryThenOpensTheMatchingProcedure() { + offerRequest(); + state.setPatientName(surgeon.getUniqueId(), "Other"); + run(patient, "accept"); + message(patient, "request-cannot-start"); + state.cleanup(surgeon.getUniqueId()); + offerRequest(); + ailments.when(() -> Ailments.find(patient, "leg")).thenReturn(null); + run(patient, "accept"); + message(patient, "request-ailment-gone"); + ailments.when(() -> Ailments.find(patient, "leg")).thenReturn(injury); + offerRequest(); + run(patient, "accept"); + verify(menus).openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Broken leg", 7_200_000L)); + message(surgeon, "request-accepted-surgeon"); + message(patient, "request-accepted-patient"); + assertNull(requests.pending(patient.getUniqueId(), 0)); + } + + @Test + void denyingNotifiesTheSurgeonWhenStillAvailable() { + run(patient, "DeNy"); + message(patient, "request-none"); + offerRequest(); + run(patient, "deny"); + message(patient, "request-denied-patient"); + message(surgeon, "request-denied-surgeon"); + bukkit.when(() -> Bukkit.getPlayer(surgeon.getUniqueId())).thenReturn(null); + offerRequest(); + run(patient, "deny"); + verify(surgeon, times(1)).sendMessage(startsWith("request-denied-surgeon")); + } + + @Test + void completionFiltersByPermissionVisibilityPrefixAndArgumentPosition() { + assertTrue(command.onTabComplete(surgeon, bukkitCommand, "surgery", new String[0]).isEmpty()); + assertEquals(List.of("accept", "deny", "Patient"), command.onTabComplete(surgeon, bukkitCommand, "surgery", new String[]{""})); + assertEquals(List.of("Patient"), command.onTabComplete(surgeon, bukkitCommand, "surgery", new String[]{"PA"})); + when(surgeon.canSee(patient)).thenReturn(false); + assertTrue(command.onTabComplete(surgeon, bukkitCommand, "surgery", new String[]{"PA"}).isEmpty()); + when(surgeon.hasPermission(anyString())).thenReturn(false); + assertEquals(List.of("accept", "deny"), command.onTabComplete(surgeon, bukkitCommand, "surgery", new String[]{""})); + assertEquals(List.of("accept"), command.onTabComplete(mock(CommandSender.class), bukkitCommand, "surgery", new String[]{"A"})); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java new file mode 100644 index 0000000..81b318d --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -0,0 +1,230 @@ +package net.tfminecraft.surgery.managers; + +import net.tfminecraft.rpcharacters.api.HealingInjuries; +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.surgery.procedures.Durations; +import net.tfminecraft.surgery.procedures.Procedure; +import org.bukkit.Bukkit; +import org.bukkit.Sound; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +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.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SurgeryCompletionTest { + private final UUID surgeonId = UUID.randomUUID(), patientId = UUID.randomUUID(); + private Player surgeon, patient; + private JavaPlugin plugin; + private YamlConfiguration config; + private SurgeryStateManager state; + private SurgeryUIUpdater ui; + private SurgeryCompletionHandler completion; + private BukkitScheduler scheduler; + private ConsoleCommandSender console; + private MockedStatic bukkit; + private MockedStatic injuries; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + surgeon = mock(Player.class); + patient = mock(Player.class); + when(surgeon.getUniqueId()).thenReturn(surgeonId); + when(patient.getUniqueId()).thenReturn(patientId); + when(surgeon.getName()).thenReturn("Surgeon"); + when(patient.getName()).thenReturn("Patient"); + plugin = mock(JavaPlugin.class); + config = new YamlConfiguration(); + when(plugin.getConfig()).thenReturn(config); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + state = new SurgeryStateManager(); + state.setPatientUuid(surgeonId, patientId); + state.setPatientName(surgeonId, "Patient"); + state.setAilment(surgeonId, "leg", "Broken leg", new Procedure("Fracture", 2, 0, 0, Set.of())); + ui = mock(SurgeryUIUpdater.class); + when(ui.getMessage(anyString())).thenAnswer(c -> c.getArgument(0)); + completion = new SurgeryCompletionHandler(plugin, state, ui); + scheduler = mock(BukkitScheduler.class); + console = mock(ConsoleCommandSender.class); + bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.dispatchCommand(eq(console), anyString())).thenReturn(true); + injuries = mockStatic(HealingInjuries.class); + } + + @AfterEach + void tearDown() { + injuries.close(); + bukkit.close(); + MockBukkit.unmock(); + } + + @Test + void successRequiresEveryClinicalCondition() { + state.setStatus(surgeonId, "Unconscious"); + state.setOperationSite(surgeonId, "Clean"); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setCured(surgeonId, true); + assertTrue(completion.isSurgerySuccessful(surgeonId)); + state.setPulse(surgeonId, "Weak"); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setPulse(surgeonId, "Strong"); + state.setStatus(surgeonId, "Awake"); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setStatus(surgeonId, "Unconscious"); + state.setTemperature(surgeonId, 101); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setTemperature(surgeonId, 100); + state.setOperationSite(surgeonId, "Unclean"); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setOperationSite(surgeonId, "Clean"); + state.setIncisions(surgeonId, 1); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setIncisions(surgeonId, 0); + state.setBrokenBones(surgeonId, 1); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setBrokenBones(surgeonId, 0); + state.setShatteredBones(surgeonId, 1); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setShatteredBones(surgeonId, 0); + state.setBleeding(surgeonId, true); + assertFalse(completion.isSurgerySuccessful(surgeonId)); + state.setBleeding(surgeonId, false); + assertTrue(completion.isSurgerySuccessful(surgeonId)); + } + + @Test + void successfulCureRunsRewardOnceAndClosesOnNextTick() { + injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(true); + config.set("commands.surgery-success", "reward %surgeon% %player% %ailment%"); + completion.handleSuccess(surgeon); + injuries.verify(() -> HealingInjuries.cure(patient, "leg")); + bukkit.verify(() -> Bukkit.dispatchCommand(console, "reward Surgeon Patient Broken leg")); + assertFalse(state.hasSession(surgeonId)); + verify(surgeon).sendMessage("surgery-successful"); + verify(patient).sendMessage("patient-surgery-successful"); + verify(surgeon).playSound(surgeon.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); + verifyDeferredClose(); + } + + @Test + void missingPatientOrAlreadyHealedAilmentDoesNotRewardSuccess() { + completion.handleSuccess(surgeon); + verify(surgeon).sendMessage("ailment-already-healed"); + verify(surgeon, never()).sendMessage("surgery-successful"); + verifyDeferredClose(); + state.setPatientUuid(surgeonId, null); + state.setAilment(surgeonId, "leg", "Leg", null); + completion.handleSuccess(surgeon); + assertFalse(state.hasSession(surgeonId)); + injuries.verify(() -> HealingInjuries.cure(patient, "leg"), times(1)); + } + + @Test + void failureExtendsHealingAndRunsCommandBeforeCleaningSession() { + state.setOperated(surgeonId, true); + config.set("failure-healing-penalty", "90m"); + config.set("commands.surgery-failure", "penalty %player% %surgeon% %ailment%"); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 5_400_000L)).thenReturn(7_200_000L); + completion.failSurgery(surgeon, "failure reason"); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 5_400_000L)); + bukkit.verify(() -> Bukkit.dispatchCommand(console, "penalty Patient Surgeon Broken leg")); + verify(surgeon).sendMessage("failure reason"); + verify(surgeon).sendMessage("surgery-failed-penalty"); + verify(patient).sendMessage("patient-surgery-failed"); + assertFalse(state.hasSession(surgeonId)); + completion.failSurgery(surgeon, "duplicate"); + verify(surgeon, never()).sendMessage("duplicate"); + verifyDeferredClose(); + } + + @Test + void invalidPenaltyUsesDefaultAndHarmlessFailuresDoNotExtendInjuries() { + state.setOperated(surgeonId, true); + config.set("failure-healing-penalty", "invalid"); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 43_200_000L)).thenReturn(-1L); + completion.failSurgery(surgeon, "failed"); + verify(plugin.getLogger()).warning("Invalid failure-healing-penalty, using 12h"); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 43_200_000L)); + verify(surgeon, never()).sendMessage("surgery-failed-penalty"); + state.setPatientUuid(surgeonId, patientId); + state.setAilment(surgeonId, "leg", "Leg", null); + completion.failSurgery(surgeon, "not operated"); + injuries.verifyNoMoreInteractions(); + } + + @Test + void zeroPenaltyMissingPatientAndMissingTraitAreSafe() { + state.setOperated(surgeonId, true); + config.set("failure-healing-penalty", "0h"); + completion.handleQuit(surgeon); + state.setOperated(surgeonId, true); + state.setPatientUuid(surgeonId, patientId); + completion.handleQuit(surgeon); + state.setOperated(surgeonId, true); + state.setAilment(surgeonId, "leg", "Leg", null); + completion.handleQuit(surgeon); + completion.handleQuit(surgeon); + assertFalse(state.hasSession(surgeonId)); + injuries.verifyNoInteractions(); + verifyNoInteractions(scheduler); + } + + @Test + void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { + completion.handleAbandonment(surgeon); + assertFalse(state.hasSession(surgeonId)); + verify(surgeon).sendMessage("surgery-cancelled"); + completion.handleAbandonment(surgeon); + verify(surgeon, times(1)).sendMessage("surgery-cancelled"); + state.setOperated(surgeonId, true); + completion.handleAbandonment(surgeon); + verify(surgeon).sendMessage("failure-gave-up"); + assertFalse(state.hasSession(surgeonId)); + } + + @Test + void ailmentQueriesSelectWorstAndFindCaseInsensitively() throws Exception { + HealingInjury shortInjury = new HealingInjury("arm", "Arm", 1_000); + HealingInjury worst = new HealingInjury("leg", "Leg", 2_000); + injuries.when(() -> HealingInjuries.list(patient)).thenReturn(List.of(shortInjury, worst)); + assertSame(worst, Ailments.mostSevere(patient)); + assertSame(worst, Ailments.find(patient, "LEG")); + assertNull(Ailments.find(patient, "missing")); + injuries.when(() -> HealingInjuries.list(patient)).thenReturn(List.of()); + assertNull(Ailments.mostSevere(patient)); + for (Class utility : List.of(Ailments.class, Durations.class)) { + var constructor = utility.getDeclaredConstructor(); + assertTrue(Modifier.isPrivate(constructor.getModifiers())); + constructor.setAccessible(true); + assertNotNull(constructor.newInstance()); + } + } + + private void verifyDeferredClose() { + verify(surgeon, never()).closeInventory(); + ArgumentCaptor close = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), close.capture()); + close.getValue().run(); + verify(surgeon).closeInventory(); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java new file mode 100644 index 0000000..62768a5 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java @@ -0,0 +1,393 @@ +package net.tfminecraft.surgery.managers; + +import net.tfminecraft.surgery.procedures.Complication; +import net.tfminecraft.surgery.procedures.Procedure; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SurgeryItemHandlerTest { + private final UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private YamlConfiguration config; + private SurgeryStateManager state; + private SurgeryUIUpdater ui; + private SurgeryMechanicsManager mechanics; + private SurgeryCompletionHandler completion; + private SurgeryItemHandler handler; + private ItemAPI api; + private Player player; + private PlayerInventory inventory; + private Inventory menu; + + @BeforeEach + void setUp() { + ServerMock server = MockBukkit.mock(); + config = new YamlConfiguration(); + config.set("skill-fail.base-chance", 0.0); + config.set("skill-fail.bleeding-chance", 0.0); + config.set("skill-fail.with-sponge-chance", 0.0); + config.set("pulse.scalpel-decrease-chance", 0.0); + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getConfig()).thenReturn(config); + state = new SurgeryStateManager(); + state.setStatus(id, "Unconscious"); + state.setOperationSite(id, "Clean"); + state.setAilment(id, "trait", "Ailment", new Procedure("Procedure", 2, 1, 1, Set.of())); + ui = mock(SurgeryUIUpdater.class); + when(ui.getMessage(anyString())).thenAnswer(call -> call.getArgument(0)); + when(ui.getMessageList(anyString())).thenAnswer(call -> List.of(call.getArgument(0, String.class))); + when(ui.createInfoBlock(any(), anyString(), anyString())).thenReturn(new ItemStack(Material.PAPER)); + mechanics = mock(SurgeryMechanicsManager.class); + completion = mock(SurgeryCompletionHandler.class); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.PAPER)); + when(api.getChecker().checkItemWithPath(any(), anyString())).thenReturn(true); + SurgeryItemsConfig items = mock(SurgeryItemsConfig.class); + when(items.getItemPath(any())).thenAnswer(call -> ((SurgeryTool) call.getArgument(0)).getDefaultPath()); + menu = server.createInventory(null, 54); + player = mock(Player.class); + inventory = mock(PlayerInventory.class); + InventoryView view = mock(InventoryView.class); + when(player.getUniqueId()).thenReturn(id); + when(player.getInventory()).thenReturn(inventory); + when(player.getOpenInventory()).thenReturn(view); + when(player.getLocation()).thenReturn(new Location(server.addSimpleWorld("world"), 0, 64, 0)); + when(view.getTopInventory()).thenReturn(menu); + when(inventory.getSize()).thenReturn(3); + handler = new SurgeryItemHandler(plugin, api, state, ui, mechanics, completion, items); + handler.initialize(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + private void click(SurgeryTool tool) { + ItemStack stock = new ItemStack(Material.PAPER, 2); + when(inventory.getItem(0)).thenReturn(stock); + handler.handleItemClick(player, new ItemStack(Material.PAPER), tool.getSlot()); + } + + @Test + void spongeProtectsExactlyTheNextAction() { + state.setBleeding(id, true); + state.setMovesSinceLastSponge(id, 4); + click(SurgeryTool.SPONGE); + assertFalse(state.isBleeding(id)); + assertEquals(0, state.getMovesSinceLastSponge(id)); + assertTrue(state.hasSpongeEffect(id), "A successful sponge must protect the next action"); + + config.set("skill-fail.base-chance", 1.0); + config.set("skill-fail.with-sponge-chance", 0.0); + state.setTemperature(id, 109.0); + click(SurgeryTool.TINCTURE); + assertEquals(103.6, state.getTemperature(id), 0.0001); + assertEquals("", state.getSkillFail(id)); + assertFalse(state.hasSpongeEffect(id), "The next action must consume the protection"); + + click(SurgeryTool.TINCTURE); + assertEquals(109.0, state.getTemperature(id), 0.0001); + assertEquals("skill-fail-tincture", state.getSkillFail(id)); + } + + @Test + void nonToolsAndEmptyClicksHaveNoEffects() { + handler.handleItemClick(player, null, SurgeryTool.SPONGE.getSlot()); + handler.handleItemClick(player, new ItemStack(Material.AIR), SurgeryTool.SPONGE.getSlot()); + handler.handleItemClick(player, new ItemStack(Material.PAPER), SurgeryConstants.SLOT_DIAGNOSIS); + verifyNoInteractions(mechanics, completion, inventory); + } + + @Test + void cuttingAnAwakePatientFailsBeforeConsumingATool() { + state.setStatus(id, "Awake"); + click(SurgeryTool.SCALPEL); + assertTrue(state.hasOperated(id)); + verify(completion).failSurgery(player, "failure-stabbed-awake"); + verify(inventory, never()).getItem(anyInt()); + verifyNoInteractions(mechanics); + } + + @Test + void inventoryConsumptionMatchesItemPathAndPreservesUnrelatedItems() { + ItemStack unrelated = new ItemStack(Material.STONE, 8); + ItemStack tool = new ItemStack(Material.PAPER, 3); + when(inventory.getItem(1)).thenReturn(unrelated); + when(inventory.getItem(2)).thenReturn(tool); + when(api.getChecker().checkItemWithPath(unrelated, SurgeryTool.SUTURE.getDefaultPath())).thenReturn(false); + handler.handleItemClick(player, new ItemStack(Material.PAPER), SurgeryTool.SUTURE.getSlot()); + assertEquals(8, unrelated.getAmount()); + assertEquals(2, tool.getAmount()); + verify(mechanics).processMoveEffects(player, SurgeryTool.SUTURE); + tool.setAmount(1); + handler.handleItemClick(player, new ItemStack(Material.PAPER), SurgeryTool.SUTURE.getSlot()); + verify(inventory).setItem(2, null); + } + + @Test + void missingToolReportsErrorWithoutAdvancingTheOperation() { + handler.handleItemClick(player, new ItemStack(Material.PAPER), SurgeryTool.SUTURE.getSlot()); + verify(player).sendMessage("item-not-in-inventory"); + verify(player).playSound(any(Location.class), eq(Sound.ENTITY_VILLAGER_NO), eq(1.0f), eq(1.0f)); + verifyNoInteractions(mechanics, completion); + } + + @Test + void moveEffectsThatEndTheSessionPreventToolEffectsAndSuccessSounds() { + doAnswer(call -> { state.cleanup(id); return null; }).when(mechanics).processMoveEffects(player, SurgeryTool.SUTURE); + state.setIncisions(id, 2); + click(SurgeryTool.SUTURE); + assertFalse(state.hasSession(id)); + verify(ui, never()).updateIncisionBlock(any(), any(), anyInt()); + verify(player, never()).playSound(any(Location.class), eq(Sound.ENTITY_PLAYER_LEVELUP), anyFloat(), anyFloat()); + } + + @ParameterizedTest + @EnumSource(SurgeryTool.class) + void eachToolHasADeterministicFailureMessageAndBreakSound(SurgeryTool tool) { + config.set("skill-fail.base-chance", 1.0); + config.set("skill-fail.bleeding-chance", 1.0); + config.set("skill-fail.with-sponge-chance", 1.0); + click(tool); + assertEquals("skill-fail-" + tool.getConfigKey(), state.getSkillFail(id)); + assertNotNull(menu.getItem(SurgeryConstants.SLOT_SKILL_FAIL)); + verify(player).playSound(any(Location.class), eq(Sound.ENTITY_ITEM_BREAK), eq(1.0f), eq(1.0f)); + verify(player, never()).playSound(any(Location.class), eq(Sound.ENTITY_PLAYER_LEVELUP), anyFloat(), anyFloat()); + switch (tool) { + case SCALPEL -> { assertEquals("Steady", state.getPulse(id)); assertEquals(0, state.getIncisions(id)); } + case TINCTURE -> assertEquals(104.0, state.getTemperature(id), 0.0001); + case TRANSFUSION -> assertEquals("Unsanitary", state.getOperationSite(id)); + case SILVER_WIRE, SPLINT -> { + assertTrue(state.isBleeding(id)); + verify(ui).sendNumberedMessage(player, "bleeding-warning"); + } + default -> assertFalse(state.isCured(id)); + } + } + + @Test + void absentAndEmptySkillFailureListsUseFallbackText() { + config.set("skill-fail.base-chance", 1.0); + when(ui.getMessageList(anyString())).thenReturn(null); + handler.initialize(); + click(SurgeryTool.SPONGE); + assertEquals("Something went wrong!", state.getSkillFail(id)); + when(ui.getMessageList(anyString())).thenReturn(List.of()); + handler.initialize(); + click(SurgeryTool.SPONGE); + assertEquals("Something went wrong!", state.getSkillFail(id)); + } + + @Test + void thermometerRevealsConfiguredTinctureAndHandlesMissingDefinition() { + menu.setItem(SurgeryTool.THERMOMETER.getSlot(), new ItemStack(Material.PAPER)); + click(SurgeryTool.THERMOMETER); + assertNull(menu.getItem(SurgeryTool.THERMOMETER.getSlot())); + assertNotNull(menu.getItem(SurgeryTool.TINCTURE.getSlot())); + menu.clear(); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + click(SurgeryTool.THERMOMETER); + assertNull(menu.getItem(SurgeryTool.TINCTURE.getSlot())); + } + + @Test + void examinationRevealsBonesAndUsesAlreadyMadeIncisions() { + state.setIncisions(id, 3); + click(SurgeryTool.STETHOSCOPE); + assertTrue(state.isExamined(id)); + assertEquals(1, state.getBrokenBones(id)); + assertEquals(1, state.getShatteredBones(id)); + verify(ui).updateDiagnosisBlock(menu, id); + verify(mechanics).handleBoneReveal(player, menu, id, 3); + verify(mechanics).checkForDressing(player, menu, id, 3); + } + + @Test + void examinationFindsSepsisWithoutReducingAnExistingFever() { + config.set("complications.sepsis.fever-min", 102.0); + config.set("complications.sepsis.fever-max", 102.0); + state.setAilment(id, "sepsis", "Sepsis", new Procedure("Sepsis", 1, 0, 0, Set.of(Complication.SEPSIS))); + click(SurgeryTool.STETHOSCOPE); + assertTrue(state.hasRisingTemp(id)); + assertEquals(102.0, state.getTemperature(id)); + state.setTemperature(id, 108.0); + click(SurgeryTool.STETHOSCOPE); + assertEquals(108.0, state.getTemperature(id)); + verify(ui, times(2)).sendNumberedMessage(player, "sepsis-found"); + } + + @Test + void scalpelCreatesDirtyIncisionsWeakensPulseAndRefreshesTools() { + config.set("pulse.scalpel-decrease-chance", 1.0); + click(SurgeryTool.SCALPEL); + assertTrue(state.hasOperated(id)); + assertEquals(1, state.getIncisions(id)); + assertEquals("Unclean", state.getOperationSite(id)); + assertEquals("Steady", state.getPulse(id)); + verify(ui).updateIncisionBlock(menu, id, 1); + verify(mechanics).handleBoneReveal(player, menu, id, 1); + verify(mechanics).updateDynamicTools(player, menu, id); + verify(mechanics).checkForDressing(player, menu, id, 1); + config.set("pulse.scalpel-decrease-chance", 0.0); + click(SurgeryTool.SCALPEL); + assertEquals(2, state.getIncisions(id)); + assertEquals("Steady", state.getPulse(id)); + verify(ui, times(1)).updateOperationSiteBlock(menu, id, "Unclean"); + } + + @Test + void suturesStopBleedingOnlyWhenLastIncisionClosesAndNeverGoNegative() { + state.setIncisions(id, 2); + state.setBleeding(id, true); + click(SurgeryTool.SUTURE); + assertEquals(1, state.getIncisions(id)); + assertTrue(state.isBleeding(id)); + click(SurgeryTool.SUTURE); + assertEquals(0, state.getIncisions(id)); + assertFalse(state.isBleeding(id)); + click(SurgeryTool.SUTURE); + assertEquals(0, state.getIncisions(id)); + } + + @Test + void tinctureClampsAtNormalTemperatureAndDeathThreshold() { + state.setTemperature(id, 100.0); + click(SurgeryTool.TINCTURE); + assertEquals(98.6, state.getTemperature(id)); + verify(ui).sendNumberedMessage(player, "temperature-reduced"); + config.set("skill-fail.base-chance", 1.0); + state.setTemperature(id, 109.0); + click(SurgeryTool.TINCTURE); + assertEquals(110.0, state.getTemperature(id)); + } + + @Test + void transfusionImprovesPulseAndCarbolicAcidProtectsTheCleanSite() { + state.setPulse(id, "Weak"); + state.setOperationSite(id, "Unsanitary"); + click(SurgeryTool.TRANSFUSION); + assertEquals("Steady", state.getPulse(id)); + verify(ui).updatePulseBlock(menu, id, "Steady"); + click(SurgeryTool.CARBOLIC_ACID); + assertEquals("Clean", state.getOperationSite(id)); + assertTrue(state.hasAntisepticProtection(id)); + verify(ui).sendNumberedMessage(player, "operation-clean"); + } + + @Test + void chloroformSedatesAwakePatientsAndCanBeReusedAfterCooldown() { + state.setStatus(id, "Awake"); + click(SurgeryTool.CHLOROFORM); + assertEquals("Unconscious", state.getStatus(id)); + assertEquals(0, state.getUnconsciousTimer(id)); + assertTrue(state.hasOperated(id)); + state.setStatus(id, "Coming to"); + state.setUnconsciousTimer(id, 4); + click(SurgeryTool.CHLOROFORM); + assertEquals("Unconscious", state.getStatus(id)); + assertEquals(0, state.getUnconsciousTimer(id)); + verifyNoInteractions(completion); + } + + @Test + void chloroformMisuseEndsTheSessionWithoutSuccessFeedback() { + state.setUnconsciousTimer(id, 1); + doAnswer(call -> { state.cleanup(id); return null; }).when(completion).failSurgery(player, "failure-anesthetic-misuse"); + click(SurgeryTool.CHLOROFORM); + verify(completion).failSurgery(player, "failure-anesthetic-misuse"); + assertFalse(state.hasSession(id)); + verify(ui, never()).createInfoBlock(any(), anyString(), anyString()); + verify(player, never()).playSound(any(Location.class), eq(Sound.ENTITY_PLAYER_LEVELUP), anyFloat(), anyFloat()); + } + + @Test + void smellingSaltsReviveCollapsedPatientsAndRemoveTheirDeadline() { + state.setStatus(id, "Collapsed"); + state.setCollapseCountdown(id, 1); + menu.setItem(SurgeryTool.SMELLING_SALTS.getSlot(), new ItemStack(Material.PAPER)); + click(SurgeryTool.SMELLING_SALTS); + assertEquals("Unconscious", state.getStatus(id)); + assertNull(state.getCollapseCountdown(id)); + assertNull(menu.getItem(SurgeryTool.SMELLING_SALTS.getSlot())); + click(SurgeryTool.SMELLING_SALTS); + verify(ui, times(1)).updateStatusBlock(menu, id, "Unconscious"); + } + + @Test + void silverWireConvertsShatteredBonesAndSplintFinishesRepair() { + state.setShatteredBones(id, 2); + state.setRevealedShatteredBones(id, 2); + click(SurgeryTool.SILVER_WIRE); + assertEquals(1, state.getShatteredBones(id)); + assertEquals(1, state.getRevealedShatteredBones(id)); + assertEquals(1, state.getBrokenBones(id)); + assertEquals(1, state.getRevealedBrokenBones(id)); + click(SurgeryTool.SPLINT); + assertEquals(0, state.getBrokenBones(id)); + assertEquals(0, state.getRevealedBrokenBones(id)); + verify(mechanics).checkForDressing(player, menu, id, 0); + state.setRevealedShatteredBones(id, 0); + click(SurgeryTool.SILVER_WIRE); + assertEquals(0, state.getShatteredBones(id)); + assertEquals(0, state.getRevealedShatteredBones(id)); + state.setRevealedBrokenBones(id, 0); + click(SurgeryTool.SPLINT); + assertEquals(0, state.getBrokenBones(id)); + assertEquals(0, state.getRevealedBrokenBones(id)); + click(SurgeryTool.SILVER_WIRE); + click(SurgeryTool.SPLINT); + assertEquals(0, state.getBrokenBones(id)); + assertEquals(0, state.getShatteredBones(id)); + } + + @Test + void arteryForcepsRequireAnOpenBleedingWound() { + state.setBleeding(id, true); + click(SurgeryTool.ARTERY_FORCEPS); + assertTrue(state.isBleeding(id)); + state.setIncisions(id, 1); + click(SurgeryTool.ARTERY_FORCEPS); + assertFalse(state.isBleeding(id)); + click(SurgeryTool.ARTERY_FORCEPS); + verify(mechanics, times(1)).updateDynamicTools(player, menu, id); + } + + @Test + void dressingTreatsTheConditionAndOnlyCompletesWhenAllChecksPass() { + menu.setItem(SurgeryTool.DRESSING.getSlot(), new ItemStack(Material.PAPER)); + click(SurgeryTool.DRESSING); + assertTrue(state.isCured(id)); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + verify(ui).sendNumberedMessage(player, "condition-treated-incomplete"); + verify(ui).sendNumberedMessage(player, "check-remaining"); + verify(completion, never()).handleSuccess(player); + when(completion.isSurgerySuccessful(id)).thenReturn(true); + click(SurgeryTool.DRESSING); + verify(completion).handleSuccess(player); + verify(ui, times(1)).sendNumberedMessage(player, "condition-treated-incomplete"); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java new file mode 100644 index 0000000..0bcf8fe --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java @@ -0,0 +1,363 @@ +package net.tfminecraft.surgery.managers; + +import net.tfminecraft.surgery.procedures.Complication; +import net.tfminecraft.surgery.procedures.Procedure; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SurgeryMechanicsManagerTest { + private final UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private YamlConfiguration config; + private SurgeryStateManager state; + private SurgeryUIUpdater ui; + private SurgeryCompletionHandler completion; + private SurgeryMechanicsManager mechanics; + private ItemAPI api; + private Player player; + private Inventory menu; + private World world; + + @BeforeEach + void setUp() { + ServerMock server = MockBukkit.mock(); + config = new YamlConfiguration(); + config.set("pulse.degradation-chance-bleeding", 0.0); + config.set("complications.shock.collapse-chance", 0.0); + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getConfig()).thenReturn(config); + state = new SurgeryStateManager(); + state.setStatus(id, "Unconscious"); + state.setOperationSite(id, "Clean"); + ui = mock(SurgeryUIUpdater.class); + when(ui.getMessage(anyString())).thenAnswer(call -> call.getArgument(0)); + when(ui.getMessage(anyString(), anyString())).thenAnswer(call -> call.getArgument(0)); + completion = mock(SurgeryCompletionHandler.class); + api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.PAPER)); + SurgeryItemsConfig items = mock(SurgeryItemsConfig.class); + when(items.getItemPath(any())).thenAnswer(call -> ((SurgeryTool) call.getArgument(0)).getDefaultPath()); + menu = server.createInventory(null, 54); + world = server.addSimpleWorld("world"); + player = mock(Player.class); + InventoryView view = mock(InventoryView.class); + when(player.getUniqueId()).thenReturn(id); + when(player.getOpenInventory()).thenReturn(view); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(view.getTopInventory()).thenReturn(menu); + mechanics = new SurgeryMechanicsManager(plugin, api, state, ui, completion, items); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + private void move() { + mechanics.processMoveEffects(player, SurgeryTool.SUTURE); + } + + private void procedure(int incisions, int broken, int shattered, Complication... complications) { + state.setAilment(id, "trait", "Ailment", new Procedure("Procedure", incisions, broken, shattered, Set.of(complications))); + } + + @Test + void ordinaryMoveAdvancesCountersAndResetsResolvedDangerCounters() { + state.setMoveCount(id, 2); + state.setMovesSinceLastSponge(id, 3); + state.setRedTempCounter(id, 1); + state.setExtremelyWeakCounter(id, 1); + move(); + assertEquals(3, state.getMoveCount(id)); + assertEquals(4, state.getMovesSinceLastSponge(id)); + assertEquals(0, state.getRedTempCounter(id)); + assertEquals(0, state.getExtremelyWeakCounter(id)); + assertEquals(98.6, state.getTemperature(id)); + verifyNoInteractions(completion); + } + + @Test + void anestheticProgressesThroughComingToAndAwakeAtConfiguredBoundaries() { + config.set("anesthetic.unconscious-moves", 2); + config.set("anesthetic.coming-to-moves", 2); + state.setUnconsciousTimer(id, 0); + move(); + assertEquals("Unconscious", state.getStatus(id)); + move(); + assertEquals("Coming to", state.getStatus(id)); + move(); + assertEquals("Coming to", state.getStatus(id)); + move(); + assertEquals("Awake", state.getStatus(id)); + assertEquals(4, state.getUnconsciousTimer(id)); + verify(ui).updateStatusBlock(menu, id, "Coming to"); + verify(ui).updateStatusBlock(menu, id, "Awake"); + verify(ui).sendNumberedMessage(player, "patient-coming-to"); + verify(ui).sendNumberedMessage(player, "patient-woke-up"); + } + + @Test + void collapsedPatientHasAFullResuscitationWindow() { + state.setStatus(id, "Collapsed"); + state.setCollapseCountdown(id, 2); + move(); + assertEquals(1, state.getCollapseCountdown(id)); + mechanics.processMoveEffects(player, SurgeryTool.SMELLING_SALTS); + assertEquals(1, state.getCollapseCountdown(id)); + verifyNoInteractions(completion); + move(); + verify(completion).failSurgery(player, "failure-not-resuscitated"); + } + + @Test + void missingCollapseCountdownDoesNotInventADeadline() { + state.setStatus(id, "Collapsed"); + move(); + assertNull(state.getCollapseCountdown(id)); + verifyNoInteractions(completion); + } + + @Test + void temperatureAtInstantDeathThresholdFailsBeforeFurtherEffects() { + state.setTemperature(id, 110.0); + move(); + verify(completion).failSurgery(player, "failure-infection"); + verify(ui, never()).updateTemperatureBlock(any(), any(), anyDouble()); + } + + @Test + void redFeverFailsOnlyAfterTheConfiguredConsecutiveMoves() { + state.setTemperature(id, 107.0); + move(); + move(); + verifyNoInteractions(completion); + assertEquals(2, state.getRedTempCounter(id)); + move(); + verify(completion).failSurgery(player, "failure-high-fever"); + } + + @Test + void bleedingWeakensPulseAndEventuallyCausesBleedOut() { + config.set("pulse.degradation-chance-bleeding", 1.0); + state.setBleeding(id, true); + move(); + assertEquals("Steady", state.getPulse(id)); + verify(ui).updatePulseBlock(menu, id, "Steady"); + verify(ui).sendNumberedMessage(player, "pulse-weakening"); + state.setPulse(id, "Extremely Weak"); + move(); + verify(completion).failSurgery(player, "failure-bled-out"); + } + + @Test + void prolongedExtremelyWeakPulseFailsEvenWithoutActiveBleeding() { + state.setPulse(id, "Extremely Weak"); + move(); + move(); + verifyNoInteractions(completion); + assertEquals(2, state.getExtremelyWeakCounter(id)); + move(); + verify(completion).failSurgery(player, "failure-weak-pulse"); + } + + @Test + void dirtyOpenWoundsRaiseFeverAndClampItAtDeathThreshold() { + state.setOperationSite(id, "Unclean"); + state.setIncisions(id, 1); + state.setTemperature(id, 109.0); + move(); + assertEquals(110.0, state.getTemperature(id)); + verify(ui).updateTemperatureBlock(menu, id, 110.0); + verifyNoInteractions(completion); + move(); + verify(completion).failSurgery(player, "failure-infection"); + } + + @Test + void sepsisRaisesTemperatureEvenAtACleanSiteAndProtectionDelaysIt() { + state.setHasRisingTemp(id, true); + state.setAntisepticProtection(id, true); + move(); + assertEquals(98.6, state.getTemperature(id)); + state.setOperationSite(id, "Unclean"); + move(); + assertFalse(state.hasAntisepticProtection(id)); + verify(ui).sendNumberedMessage(player, "protection-lost"); + move(); + assertEquals(100.4, state.getTemperature(id), 0.0001); + } + + @Test + void bleedingDirtySiteRaisesFeverWithoutAnIncision() { + state.setOperationSite(id, "Unclean"); + state.setBleeding(id, true); + move(); + assertEquals(100.4, state.getTemperature(id), 0.0001); + } + + @Test + void patientMustRemainOnlineInTheSameWorldAndInsideConfiguredRange() { + UUID patientId = UUID.fromString("00000000-0000-0000-0000-000000000003"); + state.setPatientUuid(id, patientId); + Player patient = mock(Player.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + assertEquals(0, state.getMoveCount(id)); + clearInvocations(completion); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + clearInvocations(completion); + when(patient.isOnline()).thenReturn(true); + when(patient.getWorld()).thenReturn(mock(World.class)); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + clearInvocations(completion); + when(patient.getWorld()).thenReturn(world); + when(patient.getLocation()).thenReturn(new Location(world, 10, 64, 0)); + move(); + assertEquals(1, state.getMoveCount(id)); + verifyNoInteractions(completion); + when(patient.getLocation()).thenReturn(new Location(world, 10.01, 64, 0)); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + } + } + + @Test + void complicationsWaitForExaminationThenHaemorrhageAndShockAddTools() { + procedure(2, 0, 0, Complication.HAEMORRHAGE, Complication.SHOCK); + config.set("complications.haemorrhage.bleeding-interval-min", 2); + config.set("complications.haemorrhage.bleeding-interval-max", 2); + config.set("complications.shock.collapse-chance", 1.0); + state.setIncisions(id, 2); + move(); + assertFalse(state.isBleeding(id)); + state.setExamined(id, true); + move(); + assertTrue(state.isBleeding(id)); + assertEquals("Collapsed", state.getStatus(id)); + assertEquals(2, state.getCollapseCountdown(id)); + assertNotNull(menu.getItem(SurgeryTool.ARTERY_FORCEPS.getSlot())); + assertNotNull(menu.getItem(SurgeryTool.SMELLING_SALTS.getSlot())); + verify(ui).sendNumberedMessage(player, "haemorrhage"); + verify(ui).sendNumberedMessage(player, "shock-collapse"); + } + + @Test + void haemorrhageDoesNotStartBeforeMinimumInterval() { + procedure(2, 0, 0, Complication.HAEMORRHAGE); + state.setExamined(id, true); + move(); + assertFalse(state.isBleeding(id)); + } + + @Test + void dressingWaitsForExaminationIncisionsAndAllBoneRepairs() { + mechanics.checkForDressing(player, menu, id, 2); + procedure(2, 1, 1); + mechanics.checkForDressing(player, menu, id, 2); + state.setExamined(id, true); + mechanics.checkForDressing(player, menu, id, 1); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + state.setBrokenBones(id, 1); + mechanics.checkForDressing(player, menu, id, 2); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + state.setBrokenBones(id, 0); + state.setShatteredBones(id, 1); + mechanics.checkForDressing(player, menu, id, 2); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + state.setShatteredBones(id, 0); + mechanics.checkForDressing(player, menu, id, 2); + assertNotNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + mechanics.checkForDressing(player, menu, id, 2); + verify(ui, times(1)).sendNumberedMessage(player, "dressing-ready"); + menu.clear(); + state.setCured(id, true); + mechanics.checkForDressing(player, menu, id, 2); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + } + + @Test + void missingConfiguredDressingDoesNotAnnounceAnUnavailableTool() { + procedure(1, 0, 0); + state.setExamined(id, true); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + mechanics.checkForDressing(player, menu, id, 1); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + verify(ui, never()).sendNumberedMessage(player, "dressing-ready"); + } + + @Test + void boneRevealRequiresExamAndEnoughIncisionsAndIsIdempotent() { + mechanics.handleBoneReveal(player, menu, id, 3); + procedure(2, 0, 0); + mechanics.handleBoneReveal(player, menu, id, 3); + procedure(2, 1, 2); + state.setBrokenBones(id, 1); + state.setShatteredBones(id, 2); + mechanics.handleBoneReveal(player, menu, id, 3); + assertEquals(0, state.getRevealedBrokenBones(id)); + state.setExamined(id, true); + mechanics.handleBoneReveal(player, menu, id, 1); + assertEquals(0, state.getRevealedShatteredBones(id)); + mechanics.handleBoneReveal(player, menu, id, 3); + assertEquals(1, state.getRevealedBrokenBones(id)); + assertEquals(2, state.getRevealedShatteredBones(id)); + assertNotNull(menu.getItem(SurgeryTool.SPLINT.getSlot())); + assertNotNull(menu.getItem(SurgeryTool.SILVER_WIRE.getSlot())); + mechanics.handleBoneReveal(player, menu, id, 3); + verify(ui, times(1)).sendNumberedMessage(player, "discovered-broken-bone"); + verify(ui, times(1)).sendNumberedMessage(player, "discovered-shattered-bone"); + } + + @Test + void dynamicToolsDisappearWhenTheirConditionsResolve() { + state.setStatus(id, "Collapsed"); + state.setRevealedBrokenBones(id, 1); + state.setRevealedShatteredBones(id, 1); + state.setIncisions(id, 2); + state.setBleeding(id, true); + mechanics.updateDynamicTools(player, menu, id); + for (SurgeryTool tool : new SurgeryTool[]{SurgeryTool.SMELLING_SALTS, SurgeryTool.SPLINT, + SurgeryTool.SILVER_WIRE, SurgeryTool.ARTERY_FORCEPS}) { + assertNotNull(menu.getItem(tool.getSlot())); + } + state.setStatus(id, "Unconscious"); + state.setRevealedBrokenBones(id, 0); + state.setRevealedShatteredBones(id, 0); + state.setBleeding(id, false); + mechanics.updateDynamicTools(player, menu, id); + for (SurgeryTool tool : new SurgeryTool[]{SurgeryTool.SMELLING_SALTS, SurgeryTool.SPLINT, + SurgeryTool.SILVER_WIRE, SurgeryTool.ARTERY_FORCEPS}) { + assertNull(menu.getItem(tool.getSlot())); + } + state.setStatus(id, "Collapsed"); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + mechanics.updateDynamicTools(player, menu, id); + assertNull(menu.getItem(SurgeryTool.SMELLING_SALTS.getSlot())); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java new file mode 100644 index 0000000..ca64ff7 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java @@ -0,0 +1,222 @@ +package net.tfminecraft.surgery.managers; + +import net.tfminecraft.surgery.procedures.Procedure; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SurgeryPresentationTest { + @TempDir Path directory; + private ServerMock server; + private JavaPlugin plugin; + private YamlConfiguration config; + private Logger logger; + private SurgeryStateManager state; + + @BeforeEach + void setUp() throws Exception { + server = MockBukkit.mock(); + plugin = mock(JavaPlugin.class); + config = new YamlConfiguration(); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getConfig()).thenReturn(config); + when(plugin.getResource(anyString())).thenAnswer(call -> getClass().getResourceAsStream("/" + call.getArgument(0))); + doAnswer(call -> { + String resource = call.getArgument(0); + try (InputStream in = plugin.getResource(resource)) { + Files.copy(in, directory.resolve(resource)); + } + return null; + }).when(plugin).saveResource(anyString(), eq(false)); + state = new SurgeryStateManager(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void statusColorsAndProgressionDescribeAllClinicalStates() { + new SurgeryConstants(); + String[] pulses = {"Strong", "Steady", "Weak", "Extremely Weak", "unknown"}; + Material[] colors = {Material.LIME_CONCRETE, Material.YELLOW_CONCRETE, + Material.ORANGE_CONCRETE, Material.RED_CONCRETE, Material.GRAY_CONCRETE}; + String[] improved = {"Strong", "Strong", "Steady", "Weak", "Strong"}; + String[] worsened = {"Steady", "Weak", "Extremely Weak", "Extremely Weak", "Extremely Weak"}; + for (int i = 0; i < pulses.length; i++) { + assertEquals(colors[i], SurgeryConstants.getPulseColor(pulses[i])); + assertEquals(improved[i], SurgeryConstants.improvePulse(pulses[i])); + assertEquals(worsened[i], SurgeryConstants.worsenPulse(pulses[i])); + } + String[] statuses = {"Unconscious", "Awake", "Coming to", "Collapsed", "unknown"}; + String[] sites = {"Clean", "Not sanitized", "Unclean", "Unsanitary", "unknown"}; + for (int i = 0; i < statuses.length; i++) { + assertEquals(colors[i], SurgeryConstants.getStatusColor(statuses[i])); + assertEquals(colors[i], SurgeryConstants.getOperationSiteColor(sites[i])); + } + double[] temperatures = {100, 100.1, 104, 104.1, 106, 106.1}; + Material[] temperatureColors = {Material.LIME_CONCRETE, Material.YELLOW_CONCRETE, + Material.YELLOW_CONCRETE, Material.ORANGE_CONCRETE, Material.ORANGE_CONCRETE, Material.RED_CONCRETE}; + for (int i = 0; i < temperatures.length; i++) { + assertEquals(temperatureColors[i], SurgeryConstants.getTemperatureColor(temperatures[i])); + } + assertEquals(Material.LIME_CONCRETE, SurgeryConstants.getIncisionColor(0)); + assertEquals(Material.YELLOW_CONCRETE, SurgeryConstants.getIncisionColor(1)); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.ROOT); + assertEquals("98.6°F / 37.0°C", SurgeryConstants.formatTemperature(98.6)); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void itemConfigCreatesDefaultsAndPreservesCustomPaths() throws Exception { + SurgeryItemsConfig items = new SurgeryItemsConfig(plugin); + assertTrue(Files.isRegularFile(directory.resolve("surgeryItemsConfig.yml"))); + for (SurgeryTool tool : SurgeryTool.values()) { + assertEquals(tool.getDefaultPath(), items.getItemPath(tool)); + assertEquals(tool, SurgeryTool.fromSlot(tool.getSlot())); + } + assertNull(SurgeryTool.fromSlot(-1)); + assertNull(SurgeryTool.fromSlot(SurgeryConstants.SLOT_PULSE)); + Files.writeString(directory.resolve("surgeryItemsConfig.yml"), "items:\n sponge: custom.sponge\n"); + items = new SurgeryItemsConfig(plugin); + assertEquals("custom.sponge", items.getItemPath(SurgeryTool.SPONGE)); + assertEquals(SurgeryTool.SCALPEL.getDefaultPath(), items.getItemPath(SurgeryTool.SCALPEL)); + } + + @Test + void itemConfigFallsBackIfBundledResourceIsMissingOrUnreadable() throws Exception { + doReturn(null).when(plugin).getResource("surgeryItemsConfig.yml"); + assertEquals(SurgeryTool.SPONGE.getDefaultPath(), new SurgeryItemsConfig(plugin).getItemPath(SurgeryTool.SPONGE)); + InputStream broken = mock(InputStream.class); + when(broken.transferTo(any())).thenThrow(new IOException("broken resource")); + doReturn(broken).when(plugin).getResource("surgeryItemsConfig.yml"); + assertEquals(SurgeryTool.SPONGE.getDefaultPath(), new SurgeryItemsConfig(plugin).getItemPath(SurgeryTool.SPONGE)); + verify(logger).severe("Could not create surgeryItemsConfig.yml: broken resource"); + verify(broken).close(); + } + + @Test + void messagesAndInfoBlocksShowTheCurrentState() throws Exception { + SurgeryUIUpdater ui = new SurgeryUIUpdater(plugin, state); + assertTrue(Files.exists(directory.resolve("messages.yml"))); + Files.writeString(directory.resolve("messages.yml"), "hello: '&aHello'\nlist: [one, two]\n"); + ui = new SurgeryUIUpdater(plugin, state); + assertEquals("§aHello", ui.getMessage("hello")); + assertEquals("", ui.getMessage("missing")); + assertEquals("§cFallback", ui.getMessage("missing", "&cFallback")); + assertEquals(List.of("one", "two"), ui.getMessageList("list")); + ItemStack info = ui.createInfoBlock(Material.PAPER, "Name", "Description"); + assertEquals("Name", info.getItemMeta().getDisplayName()); + assertEquals(List.of("Description"), info.getItemMeta().getLore()); + assertEquals(Material.AIR, ui.createInfoBlock(Material.AIR, "Name", "Description").getType()); + assertEquals(Material.AIR, ui.createInfoBlock(Material.AIR, "Name", List.of("Line")).getType()); + PlayerMock player = server.addPlayer(); + UUID id = player.getUniqueId(); + Inventory menu = server.createInventory(null, 54); + state.setAilment(id, "leg", "Broken leg", new Procedure("Bone repair", 2, 1, 2, Set.of())); + ui.updateDiagnosisBlock(menu, id); + assertEquals(Material.RED_CONCRETE, menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getType()); + state.setExamined(id, true); + state.setRevealedBrokenBones(id, 1); + state.setRevealedShatteredBones(id, 2); + ui.updateDiagnosisBlock(menu, id); + assertEquals(Material.YELLOW_CONCRETE, menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getType()); + assertTrue(menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getItemMeta().getLore().stream().anyMatch(s -> s.contains("Shattered Bones"))); + state.setCured(id, true); + ui.updateDiagnosisBlock(menu, id); + assertEquals(Material.LIME_CONCRETE, menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getType()); + assertTrue(menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getItemMeta().getLore().contains("§aTreated and dressed")); + state.setAilment(id, "eye", "Eye", new Procedure("Eye repair", 1, 0, 0, Set.of())); + ui.updateDiagnosisBlock(menu, id); + assertFalse(menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getItemMeta().getLore().stream().anyMatch(s -> s.contains("Bones"))); + state.setAilment(id, "unknown", "Unknown", null); + ui.updateDiagnosisBlock(menu, id); + assertEquals(Material.RED_CONCRETE, menu.getItem(SurgeryConstants.SLOT_DIAGNOSIS).getType()); + ui.updateIncisionBlock(menu, id, 2); + ui.updateTemperatureBlock(menu, id, 107); + ui.updateOperationSiteBlock(menu, id, "Clean"); + ui.updateStatusBlock(menu, id, "Unconscious"); + ui.updatePulseBlock(menu, id, "Weak"); + assertEquals(Material.YELLOW_CONCRETE, menu.getItem(SurgeryConstants.SLOT_INCISIONS).getType()); + assertEquals(Material.RED_CONCRETE, menu.getItem(SurgeryConstants.SLOT_TEMPERATURE).getType()); + assertEquals(Material.LIME_CONCRETE, menu.getItem(SurgeryConstants.SLOT_OPERATION_SITE).getType()); + assertEquals(Material.LIME_CONCRETE, menu.getItem(SurgeryConstants.SLOT_STATUS).getType()); + assertEquals(Material.ORANGE_CONCRETE, menu.getItem(SurgeryConstants.SLOT_PULSE).getType()); + state.setMoveCount(id, 2); + ui.sendNumberedMessage(player, "Message"); + assertEquals("§7§l[Move 3] §rMessage", player.nextMessage()); + } + + @Test + void menusInitializePatientStateAndOnlyStartingTools() { + SurgeryItemsConfig items = mock(SurgeryItemsConfig.class); + when(items.getItemPath(any())).thenAnswer(c -> ((SurgeryTool) c.getArgument(0)).getDefaultPath()); + when(items.getItemPath(SurgeryTool.SPONGE)).thenReturn(null); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.PAPER)); + when(api.getCreator().getItemFromPath(SurgeryTool.SCALPEL.getDefaultPath())).thenReturn(null); + SurgeryUIUpdater ui = new SurgeryUIUpdater(plugin, state); + SurgeryMenuBuilder builder = new SurgeryMenuBuilder(plugin, api, state, ui, items); + PlayerMock player = server.addPlayer(); + ThreadLocalRandom random = mock(ThreadLocalRandom.class); + when(random.nextBoolean()).thenReturn(true, false); + when(random.nextDouble()).thenReturn(0.5); + config.set("temperature.rising-temp-min", 100); + config.set("temperature.rising-temp-max", 104); + try (MockedStatic randomness = mockStatic(ThreadLocalRandom.class)) { + randomness.when(ThreadLocalRandom::current).thenReturn(random); + builder.buildAndOpenMenu(player); + Inventory menu = player.getOpenInventory().getTopInventory(); + assertInstanceOf(SurgeryMenuHolder.class, menu.getHolder()); + assertSame(menu, menu.getHolder().getInventory()); + assertNull(menu.getItem(SurgeryTool.DRESSING.getSlot())); + assertNull(menu.getItem(SurgeryTool.SPONGE.getSlot())); + assertEquals(Material.PAPER, menu.getItem(SurgeryTool.SUTURE.getSlot()).getType()); + assertEquals(102.0, state.getTemperature(player.getUniqueId())); + assertTrue(state.hasRisingTemp(player.getUniqueId())); + assertEquals("Awake", state.getStatus(player.getUniqueId())); + assertFalse(state.isBleeding(player.getUniqueId())); + assertFalse(state.isCured(player.getUniqueId())); + assertEquals(0, state.getIncisions(player.getUniqueId())); + assertEquals(0, state.getBrokenBones(player.getUniqueId())); + assertEquals(0, state.getShatteredBones(player.getUniqueId())); + builder.buildAndOpenMenu(player); + assertFalse(state.hasRisingTemp(player.getUniqueId())); + assertEquals(98.6, state.getTemperature(player.getUniqueId())); + } + verify(logger, times(2)).warning("[Surgery] Could not load item: null"); + verify(logger, times(2)).warning("[Surgery] Could not load item: " + SurgeryTool.SCALPEL.getDefaultPath()); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java new file mode 100644 index 0000000..8c94b7b --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java @@ -0,0 +1,159 @@ +package net.tfminecraft.surgery.managers; + +import net.tfminecraft.surgery.procedures.Procedure; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class SurgeryStateAndRequestTest { + @Test + void sessionsAreIsolatedAndCleanupRestoresAllDefaults() { + SurgeryStateManager state = new SurgeryStateManager(); + UUID surgeon = UUID.randomUUID(), other = UUID.randomUUID(), patient = UUID.randomUUID(); + assertDefaults(state, surgeon); + assertFalse(state.hasSession(surgeon)); + state.removeCollapseCountdown(surgeon); + assertFalse(state.hasSession(surgeon)); + state.setPatientName(surgeon, "Patient"); + assertTrue(state.hasSession(surgeon)); + assertEquals("Strong", state.getPulse(surgeon)); + state.setPatientUuid(surgeon, patient); + Procedure procedure = new Procedure("Fracture", 2, 1, 1, Set.of()); + state.setAilment(surgeon, "broken_leg", "Broken leg", procedure); + state.setExamined(surgeon, true); + state.setOperated(surgeon, true); + state.setPulse(surgeon, "Weak"); + state.setStatus(surgeon, "Collapsed"); + state.setTemperature(surgeon, 104.0); + state.setOperationSite(surgeon, "Clean"); + state.setIncisions(surgeon, 2); + state.setSkillFail(surgeon, "Missed"); + state.setBleeding(surgeon, true); + state.setBrokenBones(surgeon, 3); + state.setShatteredBones(surgeon, 2); + state.setRevealedBrokenBones(surgeon, 1); + state.setRevealedShatteredBones(surgeon, 2); + state.setCollapseCountdown(surgeon, 3); + state.setCured(surgeon, true); + state.setAntisepticProtection(surgeon, true); + state.setSpongeEffect(surgeon, true); + state.setMoveCount(surgeon, 7); + state.setMovesSinceLastSponge(surgeon, 4); + state.setUnconsciousTimer(surgeon, 5); + state.setHasRisingTemp(surgeon, true); + state.setExtremelyWeakCounter(surgeon, 2); + state.setRedTempCounter(surgeon, 1); + + assertEquals("Patient", state.getPatientName(surgeon)); + assertEquals(patient, state.getPatientUuid(surgeon)); + assertEquals("broken_leg", state.getTraitId(surgeon)); + assertEquals("Broken leg", state.getAilmentName(surgeon)); + assertSame(procedure, state.getProcedure(surgeon)); + assertTrue(state.isExamined(surgeon)); + assertTrue(state.hasOperated(surgeon)); + assertEquals("Weak", state.getPulse(surgeon)); + assertEquals("Collapsed", state.getStatus(surgeon)); + assertEquals(104.0, state.getTemperature(surgeon)); + assertEquals("Clean", state.getOperationSite(surgeon)); + assertEquals(2, state.getIncisions(surgeon)); + assertEquals("Missed", state.getSkillFail(surgeon)); + assertTrue(state.isBleeding(surgeon)); + assertEquals(3, state.getBrokenBones(surgeon)); + assertEquals(2, state.getShatteredBones(surgeon)); + assertEquals(1, state.getRevealedBrokenBones(surgeon)); + assertEquals(2, state.getRevealedShatteredBones(surgeon)); + assertEquals(3, state.getCollapseCountdown(surgeon)); + assertTrue(state.isCured(surgeon)); + assertTrue(state.hasAntisepticProtection(surgeon)); + assertTrue(state.hasSpongeEffect(surgeon)); + assertEquals(7, state.getMoveCount(surgeon)); + assertEquals(4, state.getMovesSinceLastSponge(surgeon)); + assertEquals(5, state.getUnconsciousTimer(surgeon)); + assertTrue(state.hasRisingTemp(surgeon)); + assertEquals(2, state.getExtremelyWeakCounter(surgeon)); + assertEquals(1, state.getRedTempCounter(surgeon)); + assertTrue(state.isPatientInSurgery(patient)); + assertEquals(surgeon, state.findSurgeonForPatient(patient)); + assertFalse(state.isPatientInSurgery(other)); + assertNull(state.findSurgeonForPatient(other)); + assertDefaults(state, other); + state.removeCollapseCountdown(surgeon); + assertNull(state.getCollapseCountdown(surgeon)); + state.cleanup(surgeon); + state.cleanup(surgeon); + assertDefaults(state, surgeon); + assertFalse(state.isPatientInSurgery(patient)); + } + + private static void assertDefaults(SurgeryStateManager state, UUID id) { + assertNull(state.getTraitId(id)); + assertNull(state.getPatientUuid(id)); + assertNull(state.getProcedure(id)); + assertEquals("Unknown", state.getPatientName(id)); + assertEquals("Unknown ailment", state.getAilmentName(id)); + assertEquals("Strong", state.getPulse(id)); + assertEquals("Awake", state.getStatus(id)); + assertEquals(98.6, state.getTemperature(id)); + assertEquals("Not sanitized", state.getOperationSite(id)); + assertEquals("", state.getSkillFail(id)); + assertEquals(0, state.getIncisions(id)); + assertEquals(0, state.getBrokenBones(id)); + assertEquals(0, state.getShatteredBones(id)); + assertEquals(0, state.getRevealedBrokenBones(id)); + assertEquals(0, state.getRevealedShatteredBones(id)); + assertEquals(0, state.getMoveCount(id)); + assertEquals(0, state.getMovesSinceLastSponge(id)); + assertEquals(0, state.getExtremelyWeakCounter(id)); + assertEquals(0, state.getRedTempCounter(id)); + assertNull(state.getCollapseCountdown(id)); + assertNull(state.getUnconsciousTimer(id)); + assertFalse(state.isExamined(id)); + assertFalse(state.hasOperated(id)); + assertFalse(state.isBleeding(id)); + assertFalse(state.isCured(id)); + assertFalse(state.hasAntisepticProtection(id)); + assertFalse(state.hasSpongeEffect(id)); + assertFalse(state.hasRisingTemp(id)); + } + + @Test + void pendingOffersExpireAreConsumedOnceAndCanBeReplaced() { + SurgeryRequestManager requests = new SurgeryRequestManager(); + UUID patient = UUID.randomUUID(), surgeon = UUID.randomUUID(); + assertNull(requests.pending(patient, 0)); + assertNull(requests.take(patient, 0)); + requests.offer(patient, surgeon, "leg", 100); + var request = requests.pending(patient, 99); + assertEquals(surgeon, request.surgeonId()); + assertEquals("leg", request.traitId()); + assertEquals(100, request.expiresAt()); + assertSame(request, requests.pending(patient, 100)); + assertSame(request, requests.take(patient, 100)); + assertNull(requests.take(patient, 100)); + requests.offer(patient, surgeon, "leg", 100); + assertNull(requests.pending(patient, 101)); + assertNull(requests.pending(patient, 99)); + requests.offer(patient, surgeon, "leg", 100); + assertNull(requests.take(patient, 101)); + requests.offer(patient, surgeon, "leg", 100); + requests.offer(patient, surgeon, "arm", 200); + assertEquals("arm", requests.pending(patient, 150).traitId()); + } + + @Test + void quittingForgetsBothSentAndReceivedOffersOnlyForThatPlayer() { + SurgeryRequestManager requests = new SurgeryRequestManager(); + UUID quitting = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID(); + UUID unrelated = UUID.randomUUID(); + requests.offer(quitting, first, "leg", 100); + requests.offer(first, quitting, "arm", 100); + requests.offer(second, unrelated, "eye", 100); + requests.forget(quitting); + assertNull(requests.pending(quitting, 0)); + assertNull(requests.pending(first, 0)); + assertEquals(unrelated, requests.pending(second, 0).surgeonId()); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java b/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java index d4a4c53..c4620cc 100644 --- a/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java +++ b/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java @@ -32,4 +32,29 @@ void formatsHoursRoundedUp() { assertEquals("49h", Durations.formatHours(48 * HOUR + 1)); assertEquals("45m", Durations.formatHours(45 * 60_000L)); } + + @Test + void overflowIsAnInvalidDurationInsteadOfAnExceptionOrWrappedPenalty() { + assertEquals(-1L, Durations.parseMs("999999999999999999999999999h")); + assertEquals(-1L, Durations.parseMs(Long.MAX_VALUE + "d")); + assertEquals(-1L, Durations.parseMs("9223372036854775s 1s")); + } + + @Test + void formatsExtremeAndMinuteBoundaryDurationsWithoutOverflow() { + long hour = 3_600_000L; + assertEquals((Long.MAX_VALUE / hour + 1) + "h", Durations.formatHours(Long.MAX_VALUE)); + assertEquals("0m", Durations.formatHours(Long.MIN_VALUE)); + assertEquals("0m", Durations.formatHours(0)); + assertEquals("1m", Durations.formatHours(1)); + assertEquals("1m", Durations.formatHours(60_000)); + assertEquals("2m", Durations.formatHours(60_001)); + } + + @Test + void parsesSecondsAndRejectsJunkBetweenValidParts() { + assertEquals(1_001_000L, Durations.parseMs(" 16m 41s ")); + assertEquals(-1L, Durations.parseMs("1h junk 2m")); + assertEquals(-1L, Durations.parseMs(" ")); + } } diff --git a/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java b/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java index 4aa1bcb..e4c9ab1 100644 --- a/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java +++ b/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java @@ -82,4 +82,14 @@ void warnsAboutUnknownComplications() throws InvalidConfigurationException { assertEquals(1, warnings.size()); assertTrue(registry.get("broken_arm").has(Complication.SHOCK)); } + + @Test + void absentProceduresAndNullTraitUseTheBuiltInDefault() throws InvalidConfigurationException { + ProcedureRegistry registry = load("{}", new ArrayList<>()); + assertEquals("Injury", registry.get(null).name()); + assertEquals(registry.get(null), registry.get("unknown")); + org.junit.jupiter.api.Assertions.assertNull(Complication.fromConfig(null)); + registry.load(new YamlConfiguration(), message -> { throw new AssertionError(message); }); + assertEquals(2, registry.get(null).requiredIncisions()); + } }