diff --git a/pom.xml b/pom.xml index e8a3e6d..a8cd1c4 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,12 @@ 5.14.0 test + + org.mockito + mockito-core + 5.20.0 + test + net.tfminecraft magic diff --git a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java index 21a5027..4b45d2b 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java @@ -334,7 +334,13 @@ ItemStack createStatus(Node n) { i.setType(Material.RED_STAINED_GLASS_PANE); ItemMeta m = i.getItemMeta(); m.setDisplayName("§cINACTIVE"); - if(n.getErrors().size() > 0) { + if(n.hasPendingRefund()) { + m.setDisplayName("§eRetry Refund"); + List lore = new ArrayList<>(n.getErrors()); + lore.add("§7Restore the barrel and hopper, then click to retry."); + lore.add("§7Refunds must finish before changing or activating this node."); + m.setLore(lore); + } else if(n.getErrors().size() > 0) { m.setLore(n.getErrors()); } i.setItemMeta(m); diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index e916ca5..0b9e595 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -231,6 +231,7 @@ public void validate() { } } public void confirmClick(Player p, Node n, ConfirmType t) { + if(t != ConfirmType.DEACTIVATE && blockPendingRefund(p, n)) return; if(t.equals(ConfirmType.DEACTIVATE)) { n.deActivate(); InventoryManager inv = new InventoryManager(); @@ -254,6 +255,11 @@ public void confirmClick(Player p, Node n, ConfirmType t) { currentNode.put(p, n); } } + private boolean blockPendingRefund(Player p, Node n) { + if(!n.hasPendingRefund()) return false; + p.sendMessage("§cRestore the barrel and hopper, then click Retry Refund before changing this node."); + return true; + } @EventHandler(ignoreCancelled = true) public void placeVanillaNode(BlockPlaceEvent e) { NodeBlock b = BlockLoader.getByBlock(e.getBlock().getType()); @@ -387,6 +393,7 @@ public void invenClick(InventoryClickEvent e) { InventoryManager inv = new InventoryManager(); if(e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+ " Node")) { e.setCancelled(true); + if(e.getClickedInventory() != e.getView().getTopInventory()) return; if(!n.hasGuild()) { n.breakNode(); nodes.remove(n); @@ -399,6 +406,7 @@ public void invenClick(InventoryClickEvent e) { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } + if(e.getSlot() != 17 && blockPendingRefund(p, n)) return; if(e.getSlot() == 8) { if(n.getIsActive()) { p.sendMessage("§cCannot upgrade while node is active"); @@ -436,6 +444,11 @@ public void invenClick(InventoryClickEvent e) { if(n.getIsActive()) { confirm.put(p, ConfirmType.DEACTIVATE); inv.confirmView(p); + } else if(n.hasPendingRefund()) { + n.deActivate(); + p.sendMessage(n.hasPendingRefund() + ? "§cRefund still pending. Restore the barrel and hopper, then retry." + : "§aRefund complete. The node remains inactive."); } else { n.activate(); if(n.getIsActive()) { @@ -482,6 +495,8 @@ public void invenClick(InventoryClickEvent e) { } } else if(currentSlot.get(p) != null && e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+" Node: "+WordUtils.capitalize(currentSlot.get(p).getId().replace("_", " ")))) { e.setCancelled(true); + if(e.getClickedInventory() != e.getView().getTopInventory()) return; + if(e.getSlot() != 26 && blockPendingRefund(p, n)) return; if(!n.hasGuild()) { n.breakNode(); nodes.remove(n); @@ -517,6 +532,8 @@ public void invenClick(InventoryClickEvent e) { currentNode.put(p, n); } else if(e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+" Node: Type")) { e.setCancelled(true); + if(e.getClickedInventory() != e.getView().getTopInventory()) return; + if(e.getSlot() != 26 && blockPendingRefund(p, n)) return; if(!n.hasGuild()) { n.breakNode(); nodes.remove(n); @@ -550,6 +567,7 @@ public void invenClick(InventoryClickEvent e) { inv.confirmView(p); } else if(e.getView().getTitle().equalsIgnoreCase("§7Confirm Action")) { e.setCancelled(true); + if(e.getClickedInventory() != e.getView().getTopInventory()) return; if(!n.hasGuild()) { n.breakNode(); nodes.remove(n); diff --git a/src/main/java/net/tfminecraft/dowsing/objects/Node.java b/src/main/java/net/tfminecraft/dowsing/objects/Node.java index 9da7d0a..d1a86ee 100644 --- a/src/main/java/net/tfminecraft/dowsing/objects/Node.java +++ b/src/main/java/net/tfminecraft/dowsing/objects/Node.java @@ -404,6 +404,10 @@ public void refund() { ng.refund(this);; } public void activate() { + if(hasPendingRefund()) { + deActivate(); + return; + } if(isClaimable()) return; NodeEngine ng = new NodeEngine(); Boolean failed = false; @@ -456,12 +460,20 @@ public void activate() { ng.takeInputs(this); NodeManager.requestNodeBenefitSync(); } + /** Whether an inactive node still owes refunds from an interrupted cycle. */ + public boolean hasPendingRefund() { + return !this.isActive && this.inputCounter > 0 + && this.cycleTime >= 0 && this.cycleTime < Cache.cycleLength; + } public void deActivate() { this.isActive = false; if(isClaimable()) return; - if(this.cycleTime > 0 && this.cycleTime < Cache.cycleLength) { + if(this.cycleTime >= 0 && this.cycleTime < Cache.cycleLength) { while(this.getInputCounter() > 0) { + int pendingInputs = this.getInputCounter(); refund(); + // A missing barrel or hopper prevents refunds. Keep the pending inputs for retry. + if(this.getInputCounter() >= pendingInputs) break; } } NodeManager.requestNodeBenefitSync(); diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java new file mode 100644 index 0000000..5a963b0 --- /dev/null +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java @@ -0,0 +1,155 @@ +package net.tfminecraft.dowsing.managers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +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.junit.jupiter.params.provider.ValueSource; + +import net.tfminecraft.dowsing.enums.ConfirmType; +import net.tfminecraft.dowsing.objects.Node; +import net.tfminecraft.dowsing.objects.NodeBlock; +import net.tfminecraft.dowsing.objects.NodeSlot; + +class NodeRefundMenuTest { + private NodeManager manager; + private Node node; + private Player player; + private InventoryClickEvent click; + private InventoryView view; + + @BeforeEach + void setUp() { + manager = new NodeManager(); + node = mock(Node.class); + player = mock(Player.class); + click = mock(InventoryClickEvent.class); + view = mock(InventoryView.class); + NodeBlock block = mock(NodeBlock.class); + when(node.getBlock()).thenReturn(block); + when(block.getResource()).thenReturn("Iron"); + when(node.hasGuild()).thenReturn(true); + when(node.hasPendingRefund()).thenReturn(true); + when(node.getIsActive()).thenReturn(false); + when(player.hasPermission("dowsing.admin")).thenReturn(true); + when(click.getWhoClicked()).thenReturn(player); + when(click.getView()).thenReturn(view); + when(view.getTitle()).thenReturn("§7Iron Node"); + Inventory top = mock(Inventory.class); + when(click.getClickedInventory()).thenReturn(top); + when(view.getTopInventory()).thenReturn(top); + manager.currentNode.put(player, node); + } + + @ParameterizedTest + @ValueSource(strings = {"§7Iron Node", "§7Iron Node: Type", "§7Iron Node: Fuel", "§7Confirm Action"}) + void lowerInventoryClicksCannotTriggerNodeActions(String title) { + NodeSlot slot = mock(NodeSlot.class); + when(slot.getId()).thenReturn("fuel"); + manager.currentSlot.put(player, slot); + when(view.getTitle()).thenReturn(title); + when(click.getSlot()).thenReturn(17); + when(click.getClickedInventory()).thenReturn(mock(Inventory.class)); + + manager.invenClick(click); + + verify(click).setCancelled(true); + verify(node, never()).deActivate(); + verify(node, never()).activate(); + verify(node, never()).update(); + verify(node, never()).breakNode(); + verify(player, never()).sendMessage(anyString()); + } + + @ParameterizedTest + @ValueSource(ints = {6, 8, 9, 18, 24, 26, 10}) + void pendingRefundBlocksMainMenuChanges(int slot) { + when(click.getSlot()).thenReturn(slot); + + manager.invenClick(click); + + verify(click).setCancelled(true); + verify(player).sendMessage(contains("Retry Refund")); + verify(node, never()).setGuild(any()); + verify(node, never()).breakNode(); + verify(node, never()).activate(); + } + + @ParameterizedTest + @EnumSource(value = ConfirmType.class, names = {"DELETE_NODE", "CHANGE_TYPE"}) + void pendingRefundBlocksStaleConfirmation(ConfirmType action) { + manager.confirmClick(player, node, action); + + verify(player).sendMessage(contains("Retry Refund")); + verify(node, never()).breakNode(); + verify(node, never()).setCurrentType(any()); + } + + @ParameterizedTest + @ValueSource(strings = {"§7Iron Node: Type", "§7Iron Node: Fuel"}) + void pendingRefundBlocksAlreadyOpenSubmenus(String title) { + NodeSlot slot = mock(NodeSlot.class); + when(slot.getId()).thenReturn("fuel"); + manager.currentSlot.put(player, slot); + when(view.getTitle()).thenReturn(title); + + manager.invenClick(click); + + verify(click).setCancelled(true); + verify(player).sendMessage(contains("Retry Refund")); + verify(slot, never()).setActivePm(any()); + } + + @Test + void statusButtonRetriesRefundWithoutActivating() { + when(click.getSlot()).thenReturn(17); + + manager.invenClick(click); + + verify(node).deActivate(); + verify(node, never()).activate(); + verify(player).sendMessage(contains("Refund still pending")); + } + + @Test + void successfulRetryLeavesNodeInactive() { + when(click.getSlot()).thenReturn(17); + when(node.hasPendingRefund()).thenReturn(true, false); + + manager.invenClick(click); + + verify(node).deActivate(); + verify(node, never()).activate(); + verify(player).sendMessage(contains("Refund complete")); + } + + @Test + void statusButtonStillActivatesWhenNoRefundIsPending() { + when(click.getSlot()).thenReturn(17); + when(node.hasPendingRefund()).thenReturn(false); + // Stop at the production boundary before Bukkit's server-backed sound registry is needed. + RuntimeException activationReached = new RuntimeException("Activation reached"); + doThrow(activationReached).when(node).activate(); + + assertSame(activationReached, assertThrows(RuntimeException.class, () -> manager.invenClick(click))); + + verify(node).activate(); + verify(node, never()).deActivate(); + verify(player, never()).sendMessage(anyString()); + } +} diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java new file mode 100644 index 0000000..d1ec256 --- /dev/null +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.dowsing.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +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 net.tfminecraft.dowsing.Cache; + +class NodeDeactivationTest { + + private Integer previousCycleLength; + + @BeforeEach + void setCycleLength() { + previousCycleLength = Cache.cycleLength; + Cache.cycleLength = 60; + } + + @AfterEach + void restoreCycleLength() { + Cache.cycleLength = previousCycleLength; + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + void failedRefundStopsAndKeepsPendingInputsForRetry(int cycleTime) { + RefundNode node = new RefundNode(3, 0); + node.setCycleTime(cycleTime); + + node.deActivate(); + + assertFalse(node.getIsActive()); + assertEquals(1, node.attempts); + assertEquals(3, node.getInputCounter()); + assertTrue(node.hasPendingRefund()); + + node.successfulRefundsRemaining = 3; + node.deActivate(); + + assertEquals(4, node.attempts); + assertEquals(0, node.getInputCounter()); + assertFalse(node.hasPendingRefund()); + } + + @Test + void activationRetriesPendingRefundWithoutStartingANewCycle() { + RefundNode node = new RefundNode(3, 0); + node.deActivate(); + node.successfulRefundsRemaining = 3; + + node.activate(); + + assertEquals(0, node.getInputCounter()); + assertFalse(node.getIsActive()); + assertFalse(node.hasPendingRefund()); + } + + @Test + void onlyInterruptedInactiveCyclesHavePendingRefunds() { + RefundNode node = new RefundNode(3, 0); + assertFalse(node.hasPendingRefund()); + node.isActive = false; + assertTrue(node.hasPendingRefund()); + node.setCycleTime(0); + assertTrue(node.hasPendingRefund()); + node.setCycleTime(-1); + assertFalse(node.hasPendingRefund()); + node.setCycleTime(Cache.cycleLength); + assertFalse(node.hasPendingRefund()); + } + + @Test + void partialRefundStopsAtFirstFailure() { + RefundNode node = new RefundNode(3, 1); + + node.deActivate(); + + assertFalse(node.getIsActive()); + assertEquals(2, node.attempts); + assertEquals(2, node.getInputCounter()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + void successfulRefundsDrainPendingInputs(int cycleTime) { + RefundNode node = new RefundNode(3, 3); + node.setCycleTime(cycleTime); + + node.deActivate(); + + assertFalse(node.getIsActive()); + assertEquals(3, node.attempts); + assertEquals(0, node.getInputCounter()); + } + + @Test + void noPendingInputsSkipsRefunds() { + RefundNode node = new RefundNode(0, 0); + + node.deActivate(); + + assertFalse(node.getIsActive()); + assertEquals(0, node.attempts); + } + + private static final class RefundNode extends Node { + private int attempts; + private int successfulRefundsRemaining; + private final int maximumAttempts; + + RefundNode(int pendingInputs, int successfulRefunds) { + super(UUID.randomUUID(), null, null, null, true, 1, 1, null, 10, pendingInputs, 50); + successfulRefundsRemaining = successfulRefunds; + maximumAttempts = pendingInputs + 1; + } + + @Override + public boolean isClaimable() { + return false; + } + + @Override + public void update() { + // No world or production configuration is needed for the refund loop. + } + + @Override + public void refund() { + // Fail deterministically on the old loop instead of hanging the test JVM. + assertTrue(++attempts <= maximumAttempts, "Deactivation kept retrying a failed refund"); + if (successfulRefundsRemaining > 0) { + successfulRefundsRemaining--; + setInputCounter(getInputCounter() - 1); + } + // Missing barrel/hopper returns without consuming a pending input. + } + } +}