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());
+ }
}