Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 61 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@
<url>tfminecraft.net</url>

<properties>
<argLine></argLine>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<rpcharacters.version>2.4.0</rpcharacters.version>
<tlibs.version>2.0.0</tlibs.version>
</properties>

<dependencies>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<!-- Paper API -->
<dependency>
<groupId>io.papermc.paper</groupId>
Expand Down Expand Up @@ -57,7 +64,13 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<version>5.14.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockbukkit.mockbukkit</groupId>
<artifactId>mockbukkit-v1.21</artifactId>
<version>4.95.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Expand Down Expand Up @@ -89,13 +102,57 @@
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.7.0</version>
<executions>
<execution><goals><goal>properties</goal></goals></execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<version>3.5.4</version>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution>
<id>coverage-report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>coverage-check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>MISSEDCOUNT</value>
<maximum>0</maximum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
</plugins>
</build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 15 additions & 9 deletions src/main/java/net/tfminecraft/surgery/procedures/Durations.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
}
}
175 changes: 175 additions & 0 deletions src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java
Original file line number Diff line number Diff line change
@@ -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> 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());
}
}
Loading