diff --git a/core/build.gradle b/core/build.gradle index 98ead9f95..94a5049b1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -18,8 +18,15 @@ neoForge { } } + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + unitTest { enable() + testedMod = mods.getByName(mod_id) } } @@ -44,6 +51,8 @@ dependencies { compileOnly("cc.tweaked:cc-tweaked-${mc_version}-forge-api:${cct_version}") runtimeOnly("cc.tweaked:cc-tweaked-${mc_version}-forge:${cct_version}") + testRuntimeOnly project(":api") // api is compileOnly (JarJar'd into jar), but needed at test runtime since we run from source classes + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' } diff --git a/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java b/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java new file mode 100644 index 000000000..816422a5e --- /dev/null +++ b/core/src/main/java/mrtjp/projectred/core/inventory/OverlayContainer.java @@ -0,0 +1,261 @@ +package mrtjp.projectred.core.inventory; + +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.player.StackedContents; +import net.minecraft.world.inventory.StackedContentsCompatible; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * Container that overlays a real container to allow item modification without effecting the underlying container. + * The data can then be written back if desired. + *

+ * ItemStacks are only copied on access. If underlying is changed before first access, the new change will + * reflect in this layer. + */ +public class OverlayContainer implements Container, StackedContentsCompatible { + + private final ArrayList items; + private final int maxStackSize; + + private OverlayContainer(ArrayList items, int maxStackSize) { + this.items = items; + this.maxStackSize = maxStackSize; + } + + //region Container + + @Override + public int getContainerSize() { + return items.size(); + } + + @Override + public boolean isEmpty() { + for (OverlayItem overlay : items) { + if (!overlay.getItemNoCopy().isEmpty()) { + return false; + } + } + return true; + } + + @Override + public ItemStack getItem(int i) { + return items.get(i).getItem(); + } + + @Override + public ItemStack removeItem(int i, int amount) { + if (amount < 0) { + return ItemStack.EMPTY; + } + var overlay = items.get(i); + if (overlay.getItemNoCopy().isEmpty()) { + return ItemStack.EMPTY; + } + var result = overlay.getItem().split(amount); + setChanged(); + return result; + } + + @Override + public ItemStack removeItemNoUpdate(int i) { + var overlay = items.get(i); + if (overlay.getItemNoCopy().isEmpty()) { + return ItemStack.EMPTY; + } + var result = overlay.getItem(); + overlay.setItem(ItemStack.EMPTY); + return result; + } + + @Override + public void setItem(int i, ItemStack stack) { + var overlay = items.get(i); + overlay.setItem(stack); + stack.limitSize(getMaxStackSize(stack)); + } + + @Override + public void setChanged() { + } + + @Override + public boolean stillValid(Player player) { + return false; + } + + @Override + public void clearContent() { + for (OverlayItem overlay : items) { + overlay.setItem(ItemStack.EMPTY); + } + setChanged(); + } + + @Override + public int getMaxStackSize() { + return maxStackSize; + } + + // Re-implement default to prevent unnecessary copy + @Override + public int countItem(Item item) { + int result = 0; + for (OverlayItem overlay : items) { + var stack = overlay.getItemNoCopy(); + if (stack.getItem().equals(item)) { + result += stack.getCount(); + } + } + return result; + } + //endregion + + //region StackedContentsCompatible + @Override + public void fillStackedContents(StackedContents contents) { + for (OverlayItem overlay : items) { + contents.accountStack(overlay.getItemNoCopy()); + } + } + //endregion + + //region Overlay utils + + /** + * Drop any changes and go back to underlying container's state + */ + public void clearChanges() { + for (OverlayItem overlay : items) { + overlay.clear(); + } + } + + /** + * Write any changes back to the underlying containers. + */ + public void commitChanges() { + for (OverlayItem overlay : items) { + overlay.commit(); + } + } + + /** + * Force-copies all items into this layer even if unchanged. + */ + public void copyUp() { + for (OverlayItem overlay : items) { + overlay.getItem(); + } + } + + /** + * Create another overlay layer. + * + * @return An overlay on top of this overlay + */ + public OverlayContainer newLayer() { + return new Builder().addItems(this).build(); + } + + public static OverlayContainer.Builder builder() { + return new Builder(); + } + + public static OverlayContainer of(Container container) { + return new Builder().addItems(container).build(); + } + //endregion + + public static class Builder { + + private ArrayList items = new ArrayList<>(); + private Set maxStackSizes = new HashSet<>(); + + private Builder() { + } + + public Builder addItems(Container container) { + return addItems(container, 0, container.getContainerSize()); + } + + public Builder addItems(Container src, int srcPos, int length) { + for (int i = srcPos; i < srcPos + length; i++) { + addItem(src, i); + } + return this; + } + + public Builder addItem(Container src, int srcPos) { + items.add(new OverlayItem(src, srcPos)); + maxStackSizes.add(src.getMaxStackSize()); + return this; + } + + public OverlayContainer build() { + if (maxStackSizes.size() > 1) { + throw new RuntimeException("Cannot overlay containers with different max stack sizes: " + maxStackSizes); + } + int maxStackSize = maxStackSizes.isEmpty() ? 64 : maxStackSizes.iterator().next(); + return new OverlayContainer(items, maxStackSize); + } + } + + private static class OverlayItem { + + private final Container src; + private final int srcPos; + + @Nullable + private ItemStack item = null; + + public OverlayItem(Container src, int srcPos) { + this.src = src; + this.srcPos = srcPos; + } + + public ItemStack getItem() { + // Copy-on-read + if (item == null) { + item = src.getItem(srcPos).copy(); + } + return item; + } + + // INTERNAL USE ONLY + public ItemStack getItemNoCopy() { + if (item == null) { + return src.getItem(srcPos); + } + return item; + } + + public void setItem(ItemStack item) { + this.item = item; + } + + public void clear() { + item = null; + } + + public void commit() { + if (item != null) { + src.setItem(srcPos, item); + } + } + + @Override + public String toString() { + // String includes src, srcPos, upper and lower item + return String.format("OverlayItem(src=%s, srcPos=%d, lowerItem=%s, upperItem=%s)", src, srcPos, getItemNoCopy(), item); + } + } +} diff --git a/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java b/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java index 96eb8ad03..54eea1319 100644 --- a/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java +++ b/core/src/main/java/mrtjp/projectred/lib/InventoryLib.java @@ -10,6 +10,7 @@ import net.neoforged.neoforge.items.IItemHandler; import java.util.function.Consumer; +import java.util.function.Predicate; public class InventoryLib { @@ -70,6 +71,30 @@ private static void injectItemStack(Container inventory, ItemStack stack, int st } } + public static int removeItems(Container inventory, Predicate matchFunc, int amount, boolean reverse) { + return removeItems(inventory, matchFunc, amount, 0, inventory.getContainerSize(), reverse); + } + + public static int removeItems(Container inventory, Predicate matchFunc, int amount, int startIndex, int endIndex, boolean reverse) { + int removed = 0; + for (int i = startIndex; i < endIndex; i++) { + int index = reverse ? endIndex - i - 1 : i; + + ItemStack stackInSlot = inventory.getItem(index); + if (stackInSlot.isEmpty() || !matchFunc.test(stackInSlot)) continue; + + int amountToExtract = Math.min(amount, stackInSlot.getCount()); + ItemStack taken = inventory.removeItem(index, amountToExtract); + if (!taken.isEmpty()) { + amount -= taken.getCount(); + removed += taken.getCount(); + } + if (amount <= 0) break; + } + + return removed; + } + //region Worldly Container utilities public static boolean injectWorldly(WorldlyContainer container, ItemStack stack, int side, boolean simulate) { diff --git a/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java b/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java new file mode 100644 index 000000000..8449350dc --- /dev/null +++ b/core/src/test/java/mrtjp/projectred/core/inventory/OverlayContainerTest.java @@ -0,0 +1,367 @@ +package mrtjp.projectred.core.inventory; + +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class OverlayContainerTest { + + //region Read-through / copy-on-read + + @Test + void of_readsUnderlyingItems() { + SimpleContainer src = new SimpleContainer(2); + src.setItem(0, new ItemStack(Items.STICK, 5)); + src.setItem(1, new ItemStack(Items.OAK_LOG, 3)); + + OverlayContainer overlay = OverlayContainer.of(src); + + assertEquals(Items.STICK, overlay.getItem(0).getItem()); + assertEquals(5, overlay.getItem(0).getCount()); + assertEquals(Items.OAK_LOG, overlay.getItem(1).getItem()); + assertEquals(3, overlay.getItem(1).getCount()); + } + + @Test + void getItem_returnsCopy_notSameReference() { + SimpleContainer src = new SimpleContainer(1); + ItemStack original = new ItemStack(Items.STICK, 5); + src.setItem(0, original); + + OverlayContainer overlay = OverlayContainer.of(src); + ItemStack fromOverlay = overlay.getItem(0); + + assertNotSame(original, fromOverlay); + assertEquals(5, fromOverlay.getCount()); + } + + @Test + void underlyingChange_reflectsBeforeFirstRead() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + // Change underlying before any read + src.setItem(0, new ItemStack(Items.OAK_LOG, 3)); + + assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem()); + assertEquals(3, overlay.getItem(0).getCount()); + } + + @Test + void underlyingChange_doesNotReflect_afterFirstRead() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.getItem(0); // trigger copy + + src.setItem(0, new ItemStack(Items.OAK_LOG, 3)); // change underlying after read + + assertEquals(Items.STICK, overlay.getItem(0).getItem()); + assertEquals(5, overlay.getItem(0).getCount()); + } + + //endregion + + //region setItem / isolation + + @Test + void setItem_doesNotAffectUnderlying() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); + + assertEquals(Items.STICK, src.getItem(0).getItem()); + assertEquals(5, src.getItem(0).getCount()); + } + + @Test + void setItem_visibleOnOverlay() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); + + assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem()); + assertEquals(10, overlay.getItem(0).getCount()); + } + + //endregion + + //region commitChanges + + @Test + void commitChanges_writesBackToUnderlying() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); + overlay.commitChanges(); + + assertEquals(Items.OAK_LOG, src.getItem(0).getItem()); + assertEquals(10, src.getItem(0).getCount()); + } + + @Test + void commitChanges_onlyCommitsModifiedSlots() { + SimpleContainer src = new SimpleContainer(2); + src.setItem(0, new ItemStack(Items.STICK, 5)); + src.setItem(1, new ItemStack(Items.OAK_LOG, 3)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.DIAMOND, 1)); // only modify slot 0 + overlay.commitChanges(); + + assertEquals(Items.DIAMOND, src.getItem(0).getItem()); + // slot 1 was never accessed in overlay — should be unchanged + assertEquals(Items.OAK_LOG, src.getItem(1).getItem()); + assertEquals(3, src.getItem(1).getCount()); + } + + //endregion + + //region clearChanges + + @Test + void clearChanges_revertsToUnderlying() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); + overlay.clearChanges(); + + assertEquals(Items.STICK, overlay.getItem(0).getItem()); + assertEquals(5, overlay.getItem(0).getCount()); + } + + @Test + void clearChanges_afterUnderlyingChange_seesNewValue() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.setItem(0, new ItemStack(Items.OAK_LOG, 10)); // dirty overlay + + src.setItem(0, new ItemStack(Items.DIAMOND, 2)); // change underlying + overlay.clearChanges(); // reset — overlay should re-read from src + + assertEquals(Items.DIAMOND, overlay.getItem(0).getItem()); + assertEquals(2, overlay.getItem(0).getCount()); + } + + //endregion + + //region Other Container methods + + @Test + void clearContent_clearsOverlayNotUnderlying() { + SimpleContainer src = new SimpleContainer(2); + src.setItem(0, new ItemStack(Items.STICK, 5)); + src.setItem(1, new ItemStack(Items.OAK_LOG, 3)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.clearContent(); + + assertTrue(overlay.isEmpty()); + assertEquals(Items.STICK, src.getItem(0).getItem()); + assertEquals(Items.OAK_LOG, src.getItem(1).getItem()); + } + + @Test + void removeItem_splitsStack_inOverlay() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 10)); + + OverlayContainer overlay = OverlayContainer.of(src); + ItemStack removed = overlay.removeItem(0, 3); + + assertEquals(3, removed.getCount()); + assertEquals(7, overlay.getItem(0).getCount()); + assertEquals(10, src.getItem(0).getCount()); // underlying unchanged + } + + @Test + void removeItemNoUpdate_clearsSlot() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 10)); + + OverlayContainer overlay = OverlayContainer.of(src); + ItemStack removed = overlay.removeItemNoUpdate(0); + + assertEquals(10, removed.getCount()); + assertTrue(overlay.getItem(0).isEmpty()); + assertEquals(10, src.getItem(0).getCount()); // underlying unchanged + } + + @Test + void isEmpty_false_whenItemsPresent() { + SimpleContainer src = new SimpleContainer(2); + src.setItem(0, new ItemStack(Items.STICK, 1)); + + OverlayContainer overlay = OverlayContainer.of(src); + + assertFalse(overlay.isEmpty()); + } + + @Test + void isEmpty_true_afterClearContent() { + SimpleContainer src = new SimpleContainer(2); + src.setItem(0, new ItemStack(Items.STICK, 5)); + src.setItem(1, new ItemStack(Items.OAK_LOG, 3)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.clearContent(); + + assertTrue(overlay.isEmpty()); + } + + @Test + void countItem_returnsCorrectCount() { + SimpleContainer src = new SimpleContainer(3); + src.setItem(0, new ItemStack(Items.STICK, 10)); + src.setItem(1, new ItemStack(Items.STICK, 20)); + src.setItem(2, new ItemStack(Items.OAK_LOG, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + + assertEquals(30, overlay.countItem(Items.STICK)); + assertEquals(5, overlay.countItem(Items.OAK_LOG)); + } + + //endregion + + //region Builder + + @Test + void builder_addItemsWithRange_onlySelectedSlots() { + SimpleContainer src = new SimpleContainer(4); + src.setItem(0, new ItemStack(Items.STICK, 1)); + src.setItem(1, new ItemStack(Items.OAK_LOG, 2)); + src.setItem(2, new ItemStack(Items.DIAMOND, 3)); + src.setItem(3, new ItemStack(Items.GOLD_INGOT, 4)); + + OverlayContainer overlay = OverlayContainer.builder() + .addItems(src, 1, 2) // slots 1 and 2 + .build(); + + assertEquals(2, overlay.getContainerSize()); + assertEquals(Items.OAK_LOG, overlay.getItem(0).getItem()); + assertEquals(Items.DIAMOND, overlay.getItem(1).getItem()); + } + + @Test + void builder_multipleContainers_concatenates() { + SimpleContainer src1 = new SimpleContainer(2); + src1.setItem(0, new ItemStack(Items.STICK, 1)); + src1.setItem(1, new ItemStack(Items.OAK_LOG, 2)); + + SimpleContainer src2 = new SimpleContainer(2); + src2.setItem(0, new ItemStack(Items.DIAMOND, 3)); + src2.setItem(1, new ItemStack(Items.GOLD_INGOT, 4)); + + OverlayContainer overlay = OverlayContainer.builder() + .addItems(src1) + .addItems(src2) + .build(); + + assertEquals(4, overlay.getContainerSize()); + assertEquals(Items.STICK, overlay.getItem(0).getItem()); + assertEquals(Items.OAK_LOG, overlay.getItem(1).getItem()); + assertEquals(Items.DIAMOND, overlay.getItem(2).getItem()); + assertEquals(Items.GOLD_INGOT, overlay.getItem(3).getItem()); + } + + @Test + void builder_mixedMaxStackSizes_throws() { + SimpleContainer normal = new SimpleContainer(1); // maxStackSize = 64 + SimpleContainer small = new SimpleContainer(1) { + @Override + public int getMaxStackSize() { + return 16; + } + }; + + assertThrows(RuntimeException.class, () -> + OverlayContainer.builder() + .addItems(normal) + .addItems(small) + .build() + ); + } + + @Test + void builder_empty_defaultMaxStackSize64() { + OverlayContainer overlay = OverlayContainer.builder().build(); + + assertEquals(64, overlay.getMaxStackSize()); + assertEquals(0, overlay.getContainerSize()); + } + + //endregion + + //region newLayer + + @Test + void newLayer_isolatesChanges() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer layer1 = OverlayContainer.of(src); + OverlayContainer layer2 = layer1.newLayer(); + + layer2.setItem(0, new ItemStack(Items.OAK_LOG, 3)); + layer2.commitChanges(); + + // layer1 now sees OAK_LOG (layer2 committed into it) + assertEquals(Items.OAK_LOG, layer1.getItem(0).getItem()); + assertEquals(3, layer1.getItem(0).getCount()); + // underlying src is still STICK + assertEquals(Items.STICK, src.getItem(0).getItem()); + assertEquals(5, src.getItem(0).getCount()); + } + + @Test + void newLayer_commitBoth_writesToUnderlying() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer layer1 = OverlayContainer.of(src); + OverlayContainer layer2 = layer1.newLayer(); + + layer2.setItem(0, new ItemStack(Items.OAK_LOG, 3)); + layer2.commitChanges(); + layer1.commitChanges(); + + assertEquals(Items.OAK_LOG, src.getItem(0).getItem()); + assertEquals(3, src.getItem(0).getCount()); + } + + //endregion + + //region copyUp + + @Test + void copyUp_isolatesFromSubsequentUnderlyingChanges() { + SimpleContainer src = new SimpleContainer(1); + src.setItem(0, new ItemStack(Items.STICK, 5)); + + OverlayContainer overlay = OverlayContainer.of(src); + overlay.copyUp(); // force-copy all items + + src.setItem(0, new ItemStack(Items.OAK_LOG, 3)); // change underlying + + assertEquals(Items.STICK, overlay.getItem(0).getItem()); + assertEquals(5, overlay.getItem(0).getCount()); + } + + //endregion +} diff --git a/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java b/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java new file mode 100644 index 000000000..fa78f5d73 --- /dev/null +++ b/core/src/test/java/mrtjp/projectred/core/inventory/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package mrtjp.projectred.core.inventory; + +import net.covers1624.quack.annotation.FieldsAreNonnullByDefault; +import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java b/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java new file mode 100644 index 000000000..51ba5e219 --- /dev/null +++ b/core/src/test/java/mrtjp/projectred/lib/InventoryLibTest.java @@ -0,0 +1,247 @@ +package mrtjp.projectred.lib; + +import net.minecraft.core.NonNullList; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class InventoryLibTest { + + //region injectItemStack + + @Test + void injectItemStack_emptyContainer_fillsSlot0() { + SimpleContainer inv = new SimpleContainer(3); + ItemStack stack = new ItemStack(Items.STICK, 1); + InventoryLib.injectItemStack(inv, stack, false); + + assertEquals(1, inv.getItem(0).getCount()); + assertTrue(inv.getItem(1).isEmpty()); + assertTrue(inv.getItem(2).isEmpty()); + } + + @Test + void injectItemStack_mergesIntoExistingPartialStack_beforeFillingEmpty() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.STICK, 30)); + // slot 1 left empty + + InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 10), false); + + assertEquals(40, inv.getItem(0).getCount()); + assertTrue(inv.getItem(1).isEmpty()); + } + + @Test + void injectItemStack_fillsEmptyWhenNoPartial() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.OAK_LOG, 1)); + + InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), false); + + assertEquals(Items.OAK_LOG, inv.getItem(0).getItem()); + assertEquals(Items.STICK, inv.getItem(1).getItem()); + } + + @Test + void injectItemStack_splitsAcrossMultipleSlots() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.STICK, 60)); + inv.setItem(1, new ItemStack(Items.STICK, 60)); + + ItemStack stack = new ItemStack(Items.STICK, 10); + InventoryLib.injectItemStack(inv, stack, false); + + assertEquals(64, inv.getItem(0).getCount()); + assertEquals(64, inv.getItem(1).getCount()); + assertEquals(2, stack.getCount()); // 4+4 consumed, 2 remaining + } + + @Test + void injectItemStack_respectsMaxStackSize() { + SimpleContainer inv = new SimpleContainer(1); + inv.setItem(0, new ItemStack(Items.STICK, 63)); + + ItemStack stack = new ItemStack(Items.STICK, 10); + InventoryLib.injectItemStack(inv, stack, false); + + assertEquals(64, inv.getItem(0).getCount()); + assertEquals(9, stack.getCount()); + } + + @Test + void injectItemStack_noSpaceAvailable_inputUnchanged() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.IRON_INGOT, 64)); + inv.setItem(1, new ItemStack(Items.IRON_INGOT, 64)); + + ItemStack stack = new ItemStack(Items.STICK, 5); + InventoryLib.injectItemStack(inv, stack, false); + + assertEquals(5, stack.getCount()); + } + + @Test + void injectItemStack_reverse_fillsLastSlot() { + SimpleContainer inv = new SimpleContainer(3); + InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), true); + + assertTrue(inv.getItem(0).isEmpty()); + assertTrue(inv.getItem(1).isEmpty()); + assertEquals(1, inv.getItem(2).getCount()); + } + + @Test + void injectItemStack_reverse_mergesFromBack() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.STICK, 30)); + inv.setItem(1, new ItemStack(Items.STICK, 30)); + + InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 10), true); + + assertEquals(30, inv.getItem(0).getCount()); // slot 0 untouched + assertEquals(40, inv.getItem(1).getCount()); // merged into last slot first + } + + @Test + void injectItemStack_range_onlyTouchesSpecifiedRange() { + SimpleContainer inv = new SimpleContainer(5); + + InventoryLib.injectItemStack(inv, new ItemStack(Items.STICK, 1), 2, 4, false); + + assertTrue(inv.getItem(0).isEmpty()); + assertTrue(inv.getItem(1).isEmpty()); + assertEquals(1, inv.getItem(2).getCount()); + assertTrue(inv.getItem(4).isEmpty()); + } + + //endregion + + //region injectAllItemStacks + + @Test + void injectAll_allFit_returnsTrue() { + SimpleContainer inv = new SimpleContainer(18); + NonNullList stacks = NonNullList.of(ItemStack.EMPTY, + new ItemStack(Items.STICK, 10), + new ItemStack(Items.OAK_LOG, 5)); + + boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false); + + assertTrue(result); + assertTrue(stacks.get(0).isEmpty()); + assertTrue(stacks.get(1).isEmpty()); + } + + @Test + void injectAll_oneCantFit_returnsFalse() { + SimpleContainer inv = new SimpleContainer(1); + inv.setItem(0, new ItemStack(Items.IRON_INGOT, 64)); + + NonNullList stacks = NonNullList.of(ItemStack.EMPTY, + new ItemStack(Items.STICK, 1), + new ItemStack(Items.OAK_LOG, 1)); + + boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false); + + assertFalse(result); + } + + @Test + void injectAll_emptyStackInList_skipped() { + SimpleContainer inv = new SimpleContainer(2); + NonNullList stacks = NonNullList.of(ItemStack.EMPTY, + ItemStack.EMPTY, + new ItemStack(Items.STICK, 1)); + + boolean result = InventoryLib.injectAllItemStacks(inv, stacks, false); + + assertTrue(result); + assertEquals(Items.STICK, inv.getItem(0).getItem()); + assertTrue(inv.getItem(1).isEmpty()); + } + + //endregion + + //region removeItems + + @Test + void removeItems_removesMatchingFromSingleSlot() { + SimpleContainer inv = new SimpleContainer(1); + inv.setItem(0, new ItemStack(Items.STICK, 5)); + + int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 3, false); + + assertEquals(3, removed); + assertEquals(2, inv.getItem(0).getCount()); + } + + @Test + void removeItems_returnsPartialWhenNotEnough() { + SimpleContainer inv = new SimpleContainer(1); + inv.setItem(0, new ItemStack(Items.STICK, 2)); + + int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 5, false); + + assertEquals(2, removed); + assertTrue(inv.getItem(0).isEmpty()); + } + + @Test + void removeItems_spreadsAcrossMultipleSlots() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.STICK, 3)); + inv.setItem(1, new ItemStack(Items.STICK, 3)); + + int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 5, false); + + assertEquals(5, removed); + assertTrue(inv.getItem(0).isEmpty()); + assertEquals(1, inv.getItem(1).getCount()); + } + + @Test + void removeItems_predicateFilters_skipsNonMatching() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.OAK_LOG, 5)); + inv.setItem(1, new ItemStack(Items.STICK, 3)); + + int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 10, false); + + assertEquals(3, removed); + assertEquals(5, inv.getItem(0).getCount()); // untouched + assertTrue(inv.getItem(1).isEmpty()); + } + + @Test + void removeItems_reverse_removesFromLastSlotFirst() { + SimpleContainer inv = new SimpleContainer(2); + inv.setItem(0, new ItemStack(Items.STICK, 5)); + inv.setItem(1, new ItemStack(Items.STICK, 5)); + + int removed = InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 3, true); + + assertEquals(3, removed); + assertEquals(5, inv.getItem(0).getCount()); // untouched + assertEquals(2, inv.getItem(1).getCount()); // removed from here first + } + + @Test + void removeItems_range_onlyRemovesFromRange() { + SimpleContainer inv = new SimpleContainer(5); + for (int i = 0; i < 5; i++) inv.setItem(i, new ItemStack(Items.STICK, 5)); + + InventoryLib.removeItems(inv, s -> s.is(Items.STICK), 10, 2, 4, false); + + assertEquals(5, inv.getItem(0).getCount()); // outside range + assertEquals(5, inv.getItem(1).getCount()); // outside range + assertTrue(inv.getItem(2).isEmpty()); // consumed + assertTrue(inv.getItem(3).isEmpty()); // consumed + assertEquals(5, inv.getItem(4).getCount()); // outside range + } + + //endregion +} diff --git a/core/src/test/java/mrtjp/projectred/lib/package-info.java b/core/src/test/java/mrtjp/projectred/lib/package-info.java new file mode 100644 index 000000000..90305c1ae --- /dev/null +++ b/core/src/test/java/mrtjp/projectred/lib/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package mrtjp.projectred.lib; + +import net.covers1624.quack.annotation.FieldsAreNonnullByDefault; +import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/expansion/build.gradle b/expansion/build.gradle index 9d6967fe4..87c8facff 100644 --- a/expansion/build.gradle +++ b/expansion/build.gradle @@ -15,6 +15,17 @@ neoForge { programArguments.addAll '--mod', mod_id, '--all', '--output', file("src/main/generated").absolutePath, '--existing', file("src/main/resources").absolutePath } } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods.getByName(mod_id) + } } dependencies { @@ -26,4 +37,14 @@ dependencies { compileOnly project(":api") implementation project(":core") + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' + testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2' +} + +test { + useJUnitPlatform() + jvmArgs('-Xmx3G', '-Xms1G') } diff --git a/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java b/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java index 73935d978..c9bf9f144 100644 --- a/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java +++ b/expansion/src/main/java/mrtjp/projectred/expansion/CraftingHelper.java @@ -1,11 +1,10 @@ package mrtjp.projectred.expansion; import mrtjp.projectred.core.inventory.BaseContainer; +import mrtjp.projectred.core.inventory.OverlayContainer; import mrtjp.projectred.lib.InventoryLib; -import net.covers1624.quack.util.LazyValue; import net.minecraft.core.NonNullList; import net.minecraft.world.Container; -import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.CraftingContainer; import net.minecraft.world.inventory.ResultContainer; @@ -18,7 +17,6 @@ import net.neoforged.neoforge.common.CommonHooks; import javax.annotation.Nullable; -import java.util.function.Predicate; public class CraftingHelper { @@ -52,18 +50,30 @@ public NonNullList getItems() { private @Nullable RecipeHolder recipe = null; private CraftingInput.Positioned posCraftingInput = CraftingInput.Positioned.EMPTY; private CraftingResult result = CraftingResult.EMPTY; + private boolean canFitResultsIntoSource = false; public CraftingHelper(InventorySource inputSource) { this.inputSource = inputSource; } //region Inventory events + + /** + * Clears internal state such as located recipe, crafting result, etc. + */ public void clear() { - recipe = null; craftingInventory.clearContent(); + craftResultInventory.clearContent(); + recipe = null; posCraftingInput = CraftingInput.Positioned.EMPTY; + result = CraftingResult.EMPTY; + canFitResultsIntoSource = false; } + /** + * Refreshes recipe from crafting matrix and re-calculates feasibility of + * crafting the recipe output. + */ public void onInventoryChanged() { loadInputs(); loadRecipe(); @@ -81,7 +91,7 @@ public ResultContainer getCraftResultInventory() { } //region - public void loadInputs() { + private void loadInputs() { Container craftingMatrix = inputSource.getCraftingMatrix(); // Copy recipe matrix to internal Crafting Inventory for (int i = 0; i < 9; i++) { @@ -90,57 +100,110 @@ public void loadInputs() { posCraftingInput = craftingInventory.asPositionedCraftInput(); } - public void loadRecipe() { + private void loadRecipe() { recipe = inputSource.getWorld().getRecipeManager() .getRecipeFor(RecipeType.CRAFTING, posCraftingInput.input(), inputSource.getWorld()).orElse(null); craftResultInventory.setItem(0, recipe == null ? ItemStack.EMPTY : recipe.value().assemble(posCraftingInput.input(), inputSource.getWorld().registryAccess())); } - public void loadOutput() { + private void loadOutput() { + OverlayContainer overlay = createAvailableStorageOverlay(); + result = craftFromSource(overlay, null); - result = craftFromStorageOrMatrix(true); + if (result.isCraftable()) { + NonNullList allResults = result.getOutputAndRemaining(); + canFitResultsIntoSource = InventoryLib.injectAllItemStacks(overlay, allResults, true); + } } + //region Public interface + + /** + * Check if crafting matrix holds valid recipe ingredients. + *

+ * Refreshed on {@link #onInventoryChanged()} + * + * @return True if matrix matches recipe + */ public boolean hasRecipe() { return recipe != null; } - public ItemStack getRecipeOutout() { + /** + * Returns output item of the current recipe in the crafting matrix + *

+ * Refreshed on {@link #onInventoryChanged()} + * + * @return The recipe output item + */ + public ItemStack getRecipeOutput() { return craftResultInventory.getItem(0); } + /** + * Checks if the crafting ingredient sources contains enough ingredients to craft the current recipe. + *

+ * Refreshed on {@link #onInventoryChanged()} + * + * @return True if crafting is possible + */ public boolean canTake() { return result.isCraftable(); } + /** + * Checks if sources contain the necessary ingredients to craft, and then also has the space to + * take in the results and remaining items post-craft. + *

+ * Refreshed on {@link #onInventoryChanged()} + * + * @return True if crafting and storing is possible + */ public boolean canTakeIntoStorage() { - return canTake() && result.canStorageAcceptResults(); + return canTake() && canFitResultsIntoSource; } + /** + * A 9-bit mask representing slots of the 3x3 matrix. Bits are high if ingredient is missing. + *

+ * Refreshed on {@link #onInventoryChanged()} + * + * @return Missing ingredient mask + */ public int getMissingIngredientMask() { return result.missingIngredientMask; } + /** + * Executes a player-based craft, typically from an output slot's onTake() method. This will consume ingredients from the source + * containers. + *

+ * Contract: + * - Will succeed and return true if canTake() is true + * - Source containers left unaltered on failure + * + * @param player The crafting player + * @param leaveRemainingInGrid If remaining items should be left in grid. False returns them to storage. + * @return True if crafting was successful (ingredients consumed, remaining stored or dropped) + */ public boolean onCraftedByPlayer(Player player, boolean leaveRemainingInGrid) { if (recipe == null) return false; - CraftingResult result = craftFromStorageOrMatrix(false); + // Attempt to consume ingredients and craft + OverlayContainer overlay = createAvailableStorageOverlay(); + CraftingResult result = craftFromSource(overlay, player); + if (!result.isCraftable()) return false; - if (!result.isCraftable()) { - return false; - } - - // Re-obtain remaining items in case "setCraftingPlayer" changes remaining items - CommonHooks.setCraftingPlayer(player); - NonNullList remainingStacks = recipe.value().getRemainingItems(posCraftingInput.input()); // Skip re-searching for recipe, should be ok - CommonHooks.setCraftingPlayer(null); + // Crafting successful. Finalize removal of ingredients + overlay.commitChanges(); + // Put remaining items back Container craftingGird = inputSource.getCraftingMatrix(); Container storage = inputSource.getStorage(); - for (int i = 0; i < remainingStacks.size(); i++) { - ItemStack remaining = remainingStacks.get(i); + for (int i = 0; i < result.getRemainingItems().size(); i++) { + ItemStack remaining = result.getRemainingItems().get(i); if (remaining.isEmpty()) continue; // If allowed, leave remaining in crafting grid just like Vanilla crafting bench @@ -159,33 +222,40 @@ public boolean onCraftedByPlayer(Player player, boolean leaveRemainingInGrid) { return true; } + /** + * Crafts the recipe and puts result and all remaining items back into storage container. + *

+ * Contracts: + * - Will succeed and return true if canTakeIntoStorage is true + * - Source containers left unaltered on failure + * - Items can be consumed from matrix if enabled, but result and remaining items will NEVER go back to matrix + * + * @return True if successful + */ public boolean onCraftedIntoStorage() { - - CraftingResult result = craftFromStorage(false); - - if (!result.isCraftable() || !result.canFitResultsIntoStorage()) return false; - - NonNullList allResults = result.getCopyOfAllResults(); - InventoryLib.injectAllItemStacks(inputSource.getStorage(), allResults, true); - - return true; - } - - private CraftingResult craftFromStorageOrMatrix(boolean simulate) { - CraftingResult result = craftFromStorage(simulate); - if (!result.isCraftable() && inputSource.canConsumeFromCraftingMatrix()) { - // TODO maybe merge the missingIngredientMasks of these two results? - result = craftFromSource(inputSource.getCraftingMatrix(), simulate); + // Create overlay and attempt to consume ingredients + OverlayContainer overlay = createAvailableStorageOverlay(); + CraftingResult result = craftFromSource(overlay, null); + if (!result.isCraftable()) return false; + + // Try to store result items back into storage after ingredients are consumed + NonNullList allResults = result.getOutputAndRemaining(); + // Note: This directly assumes first X slots are storage (See createAvailableStorageOverlay) + int storageSize = inputSource.getStorage().getContainerSize(); + boolean fits = InventoryLib.injectAllItemStacks(overlay, allResults, 0, storageSize, true); + + // Commit if everything fits + if (fits) { + overlay.commitChanges(); + return true; } - // TODO Hybrid craft that consumes from both sources instead of one or the other? - return result; - } - private CraftingResult craftFromStorage(boolean simulate) { - return craftFromSource(inputSource.getStorage(), simulate); + return false; } + //endregion - private CraftingResult craftFromSource(Container source, boolean simulate) { + //region Utils + private CraftingResult craftFromSource(Container source, @Nullable Player player) { if (recipe == null) return CraftingResult.EMPTY; if (!recipe.value().matches(posCraftingInput.input(), inputSource.getWorld())) return CraftingResult.EMPTY; @@ -193,10 +263,6 @@ private CraftingResult craftFromSource(Container source, boolean simulate) { ItemStack result = recipe.value().assemble(posCraftingInput.input(), inputSource.getWorld().registryAccess()); if (result.isEmpty()) return CraftingResult.EMPTY; - if (simulate) { - source = copyInventory(source); - } - // Try to consume all ingredients int missingIngredientMask = 0; for (int i = 0; i < 9; i++) { @@ -204,7 +270,7 @@ private CraftingResult craftFromSource(Container source, boolean simulate) { ItemStack previousInput = craftingInventory.getItem(slot); if (previousInput.isEmpty()) continue; - boolean isPresent = consumeIngredient(source, 0, input -> { + int removed = InventoryLib.removeItems(source, input -> { // Candidate ingredient must be same item if (!ItemStack.isSameItem(input, previousInput)) return false; @@ -217,9 +283,9 @@ private CraftingResult craftFromSource(Container source, boolean simulate) { craftingInventory.setItem(slot, previousInput); return canStillCraft; - }); + }, 1, false); - if (!isPresent) { + if (removed == 0) { missingIngredientMask |= 1 << i; } } @@ -228,63 +294,50 @@ private CraftingResult craftFromSource(Container source, boolean simulate) { return CraftingResult.missingIngredients(missingIngredientMask); } - return new CraftingResult(result, recipe.value().getRemainingItems(posCraftingInput.input()), 0, simulate ? source : copyInventory(source)); - } - - private boolean consumeIngredient(Container storage, int startIndex, Predicate matchFunc) { - - int i = startIndex; - do { - ItemStack stack = storage.getItem(i); - if (!stack.isEmpty() && matchFunc.test(stack)) { - ItemStack taken = storage.removeItem(i, 1); - if (!taken.isEmpty()) { - return true; - } - } - i = (i + 1) % storage.getContainerSize(); - } while (i != startIndex); + // Obtain remaining items using the crafting player hook if player object was provided. + // (See ResultSlot#onTake(Player, ItemStack)) + //noinspection DataFlowIssue + CommonHooks.setCraftingPlayer(player); + NonNullList remainingStacks = recipe.value().getRemainingItems(posCraftingInput.input()); // Skip re-searching for recipe, should be ok + //noinspection DataFlowIssue + CommonHooks.setCraftingPlayer(null); - return false; + return CraftingResult.success(result, remainingStacks); } - private static Container copyInventory(Container inventory) { - //TODO create more accurate copy - SimpleContainer copy = new SimpleContainer(inventory.getContainerSize()); - for (int i = 0; i < inventory.getContainerSize(); i++) { - copy.setItem(i, inventory.getItem(i).copy()); + private OverlayContainer createAvailableStorageOverlay() { + var builder = OverlayContainer.builder() + .addItems(inputSource.getStorage()); + if (inputSource.canConsumeFromCraftingMatrix()) { + builder.addItems(inputSource.getCraftingMatrix()); } - return copy; + + return builder.build(); } + //endregion + /** + * Holds result of a crafting attempt. Internal. + */ private static final class CraftingResult { - private static final CraftingResult EMPTY = new CraftingResult(ItemStack.EMPTY, NonNullList.create(), 0, null); + private static final CraftingResult EMPTY = new CraftingResult(ItemStack.EMPTY, NonNullList.create(), 0); - public final ItemStack outputStack; - public final NonNullList remainingItems; - public final int missingIngredientMask; - public final @Nullable Container remainingStorage; + private final ItemStack outputStack; + private final NonNullList remainingItems; + private final int missingIngredientMask; - private final LazyValue canStorageAcceptResults = new LazyValue<>(this::canFitResultsIntoStorage); - - public CraftingResult(ItemStack outputStack, NonNullList remainingItems, int missingIngredientMask, @Nullable Container remainingStorage) { + private CraftingResult(ItemStack outputStack, NonNullList remainingItems, int missingIngredientMask) { this.outputStack = outputStack; this.remainingItems = remainingItems; this.missingIngredientMask = missingIngredientMask; - this.remainingStorage = remainingStorage; } public boolean isCraftable() { return !outputStack.isEmpty() && missingIngredientMask == 0; } - public boolean canStorageAcceptResults() { - return canStorageAcceptResults.get(); - } - - public NonNullList getCopyOfAllResults() { - + public NonNullList getOutputAndRemaining() { NonNullList allResults = NonNullList.withSize(remainingItems.size() + 1, ItemStack.EMPTY); int i = 0; allResults.set(i++, outputStack.copy()); @@ -295,17 +348,27 @@ public NonNullList getCopyOfAllResults() { return allResults; } - private boolean canFitResultsIntoStorage() { - assert remainingStorage != null; - Container storage = copyInventory(remainingStorage); // Don't mutate original list - return InventoryLib.injectAllItemStacks(storage, getCopyOfAllResults(), true); + public NonNullList getRemainingItems() { + NonNullList copy = NonNullList.withSize(remainingItems.size(), ItemStack.EMPTY); + for (int i = 0; i < remainingItems.size(); i++) { + copy.set(i, remainingItems.get(i).copy()); + } + return copy; } public static CraftingResult missingIngredients(int missingIngredientMask) { - return new CraftingResult(ItemStack.EMPTY, NonNullList.create(), missingIngredientMask, null); + return new CraftingResult(ItemStack.EMPTY, NonNullList.create(), missingIngredientMask); + } + + public static CraftingResult success(ItemStack outputStack, NonNullList remainingItems) { + return new CraftingResult(outputStack, remainingItems, 0); } } + /** + * The holder of a CraftingHelper. Provides access to required objects such as crafting matrix, + * storage container, and level. Also provides some configurations. + */ public interface InventorySource { Container getCraftingMatrix(); diff --git a/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java b/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java index 8be8ccb6f..3fe2b109d 100644 --- a/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java +++ b/expansion/src/main/java/mrtjp/projectred/expansion/tile/ProjectBenchBlockEntity.java @@ -196,7 +196,7 @@ private void writePlan() { if (craftingHelper.hasRecipe() && !isPlanRecipe) { ItemStack planStack = planInventory.getItem(0); - ItemStack result = craftingHelper.getRecipeOutout(); + ItemStack result = craftingHelper.getRecipeOutput(); if (!planStack.isEmpty() && !result.isEmpty()) { ItemStack[] inputs = new ItemStack[9]; diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java new file mode 100644 index 000000000..b81ddab0c --- /dev/null +++ b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperScenarioTest.java @@ -0,0 +1,415 @@ +package mrtjp.projectred.expansion; + +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.*; +import net.minecraft.world.level.ItemLike; +import net.minecraft.world.level.Level; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CraftingHelperScenarioTest { + + @Mock Level level; + @Mock RecipeManager recipeManager; + @Mock RegistryAccess registryAccess; + @Mock Player player; + + //region Scenario model + fluent builder + + enum CraftAction { INTO_STORAGE, BY_PLAYER } + + record Scenario( + int matrixSize, + int storageSize, + CraftingRecipe recipe, + Map matrixSetup, + Map storageSetup, + boolean canConsumeFromMatrix, + CraftAction action, + boolean leaveRemainingInGrid, + boolean expectedSuccess, + ItemStack expectedTakenOutput, + Map expectedMatrixSlots, + Map expectedStorageSlots + ) {} + + static final class Builder { + private int matrixSize = 9; + private int storageSize = 18; + @Nullable private CraftingRecipe recipe; + private final Map matrixSetup = new LinkedHashMap<>(); + private final Map storageSetup = new LinkedHashMap<>(); + private boolean canConsumeFromMatrix = false; + private CraftAction action = CraftAction.INTO_STORAGE; + private boolean leaveRemainingInGrid = false; + private boolean expectedSuccess = true; + private ItemStack expectedTakenOutput = ItemStack.EMPTY; + private final Map expectedMatrixSlots = new LinkedHashMap<>(); + private final Map expectedStorageSlots = new LinkedHashMap<>(); + + // Starting conditions and settings + Builder recipe(CraftingRecipe recipe) { this.recipe = recipe; return this; } + Builder matrix(int slot, Item item) { strictPut(matrixSetup, slot, new ItemStack(item)); return this; } + Builder matrix(int slot, ItemStack s) { strictPut(matrixSetup, slot, s.copy()); return this; } + Builder storage(int slot, Item item) { strictPut(storageSetup, slot, new ItemStack(item)); return this; } + Builder storage(int slot, ItemStack s) { strictPut(storageSetup, slot, s.copy()); return this; } + /** Pack every unset storage slot with iron ingots (simulates full storage). */ + Builder fillEmptyStorage(ItemStack stack) { for (int i = 0; i < storageSize; i++) storageSetup.computeIfAbsent(i, n -> stack.copy()); return this; } + Builder canConsumeFromMatrix() { canConsumeFromMatrix = true; return this; } + Builder craftIntoStorage() { action = CraftAction.INTO_STORAGE; return this; } + Builder craftByPlayer() { return craftByPlayer(false); } + Builder craftByPlayer(boolean leaveInGrid) { action = CraftAction.BY_PLAYER; leaveRemainingInGrid = leaveInGrid; return this; } + + // Expectations + Builder expectFail() { expectedSuccess = false; return this; } + Builder expectTakenOutput(ItemStack s) { expectedTakenOutput = s.copy(); return this; } + Builder expectMatrixSlot(int slot, ItemLike s, int count) { return expectMatrixSlot(slot, new ItemStack(s, count)); } + Builder expectMatrixSlotEmpty(int slot) { return expectMatrixSlot(slot, ItemStack.EMPTY); } + Builder expectMatrixSlot(int slot, ItemStack s) { strictPut(expectedMatrixSlots, slot, s.copy()); return this; } + Builder expectMatrixSlotRange(int a, int b, ItemStack s) { for (int i = a; i <= b; i++) expectMatrixSlot(i, s); return this; } + Builder expectMatrixEmpty() { for (int i = 0; i < matrixSize; i++) expectMatrixSlotEmpty(i); return this; } + Builder expectStorageSlot(int slot, ItemLike s, int count) { return expectStorageSlot(slot, new ItemStack(s, count)); } + Builder expectStorageSlotEmpty(int slot) { return expectStorageSlot(slot, ItemStack.EMPTY); } + Builder expectStorageSlot(int slot, ItemStack s) { strictPut(expectedStorageSlots, slot, s.copy()); return this; } + Builder expectStorageSlotRange(int a, int b, ItemStack s) { for (int i = a; i <= b; i++) expectStorageSlot(i, s); return this; } + Builder expectStorageEmpty() { for (int i = 0; i < storageSize; i++) expectStorageSlotEmpty(i); return this; } + + Builder expectStorageTotal(Item item, int count) { return this; } + + private void strictPut(Map dest, K key, V value) { + assertFalse(dest.containsKey(key), "Duplicate key found: " + key); + dest.put(key, value); + } + + private void assertNonEmpty(Map map, String message) { + for (var entry : map.entrySet()) { + if (!entry.getValue().isEmpty()) { + return; + } + } + fail(message); + } + + Scenario build() { + assertNotNull(recipe); + return new Scenario(matrixSize, storageSize, recipe, + Map.copyOf(matrixSetup), Map.copyOf(storageSetup), canConsumeFromMatrix, + action, leaveRemainingInGrid, expectedSuccess, + expectedTakenOutput, Map.copyOf(expectedMatrixSlots), Map.copyOf(expectedStorageSlots)); + } + } + + static Builder scenario() { return new Builder(); } + + //endregion + + //region Recipe helpers + + static ShapedRecipe shaped(ItemStack output, Map key, String... pattern) { + return new ShapedRecipe("", CraftingBookCategory.MISC, ShapedRecipePattern.of(key, pattern), output); + } + + static ShapedRecipe shapedSingle(ItemStack output, Ingredient input) { + return shaped(output, Map.of('A', input), "A"); + } + + //endregion + + //region Auto Crafter tests + + //region Ingredient consumption + + @Test + void craftIntoStorage_ingredientConsumedOutputStored() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + .storage(0, Items.OAK_LOG) + .craftIntoStorage() + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.OAK_PLANKS, 4) // Reverse-written into storage + .build()); + } + + @Test + void craftIntoStorage_consumingIngredientsCreatesStorageSpace() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + .storage(0, new ItemStack(Items.OAK_LOG, 1)) // Slot available after consuming ingredients + .fillEmptyStorage(new ItemStack(Items.STICK)) + .craftIntoStorage() + .expectStorageSlot(0, new ItemStack(Items.OAK_PLANKS, 4)) // Emptied slot gets item + .expectStorageSlotRange(1, 17, new ItemStack(Items.STICK)) // Other slots unchanged + .build()); + } + + @Test + void craftIntoStorage_consumeFromMultipleSlots() { + runScenario(scenario() + .recipe(shaped(new ItemStack(Items.CRAFTING_TABLE), Map.of('P', Ingredient.of(Items.OAK_PLANKS)), "PP", "PP")) + .matrix(4, Items.OAK_PLANKS).matrix(5, Items.OAK_PLANKS) + .matrix(7, Items.OAK_PLANKS).matrix(8, Items.OAK_PLANKS) + .storage(0, Items.OAK_PLANKS).storage(1, Items.OAK_PLANKS) + .storage(2, Items.OAK_PLANKS).storage(3, Items.OAK_PLANKS) + .craftIntoStorage() + .expectStorageSlotEmpty(0).expectStorageSlotEmpty(1) + .expectStorageSlotEmpty(2).expectStorageSlotEmpty(3) + .expectStorageSlot(17, Items.CRAFTING_TABLE, 1) + .build()); + } + + @Test + void craftIntoStorage_consumeFromSingleSlot() { + runScenario(scenario() + .recipe(shaped(new ItemStack(Items.CRAFTING_TABLE), Map.of('P', Ingredient.of(Items.OAK_PLANKS)), "PP", "PP")) + .matrix(4, Items.OAK_PLANKS).matrix(5, Items.OAK_PLANKS) + .matrix(7, Items.OAK_PLANKS).matrix(8, Items.OAK_PLANKS) + .storage(0, new ItemStack(Items.OAK_PLANKS, 4)) // All ingredients in 1 slot + .craftIntoStorage() + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.CRAFTING_TABLE, 1) + .build()); + } + + @Test + void craftIntoStorage_failsWhenIngredientMissing() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + // storage intentionally empty + .craftIntoStorage() + .expectFail() + .build()); + } + + @Test + void craftIntoStorage_failsWhenStorageFull() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + .storage(0, new ItemStack(Items.OAK_LOG, 2)) // Too many to consume and make empty slot + .fillEmptyStorage(new ItemStack(Items.STICK)) + .craftIntoStorage() + .expectFail() + .build()); + } + + //endregion + + //region Matrix consumption + + @Test + void craftIntoStorage_consumesFromMatrixWhenEnabled() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + // storage empty — ingredient only in matrix + .canConsumeFromMatrix() + .craftIntoStorage() + .expectMatrixEmpty() + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.OAK_PLANKS, 4) + .build()); + } + + //endregion + + //region Remaining items + + @Test + void craftIntoStorage_remainingItemStoredAlongsideOutput() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET))) + .matrix(0, Items.WATER_BUCKET) + .storage(0, Items.WATER_BUCKET) + .craftIntoStorage() + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.CAKE, 1) + .expectStorageSlot(16, Items.BUCKET, 1) + .build()); + } + + //endregion + + //endregion + + //region Project Bench tests + + //region Ingredient consumption + + //CASE: Some ingredients in storage but some in matrix + + @Test + void craftByPlayer_ingredientConsumedOnSuccess() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.OAK_PLANKS, 4), Ingredient.of(Items.OAK_LOG))) + .matrix(0, Items.OAK_LOG) + .storage(0, Items.OAK_LOG) + .craftByPlayer() + .expectTakenOutput(new ItemStack(Items.OAK_PLANKS, 4)) + .expectStorageEmpty() + .build()); + } + + //endregion + + //region Remaining items + + @Test + void craftByPlayer_remainingSentToStorageWhenLeaveRemainingFalse() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET))) + .matrix(0, Items.WATER_BUCKET) + .storage(0, Items.WATER_BUCKET) + .craftByPlayer(false) + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.BUCKET, 1) + .expectTakenOutput(new ItemStack(Items.CAKE, 1)) + .build()); + } + + @Test + void craftByPlayer_remainingLeftInMatrixWhenConsumingFromMatrix() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET))) + .matrix(0, Items.WATER_BUCKET) + // storage empty; consume from matrix so slot 0 is freed for the remaining bucket + .canConsumeFromMatrix() + .craftByPlayer(true) + .expectMatrixSlot(0, new ItemStack(Items.BUCKET)) + .expectStorageEmpty() + .expectTakenOutput(new ItemStack(Items.CAKE, 1)) + .build()); + } + + @Test + void craftByPlayer_remainingNotLeftInMatrixWhenNotConsumingFromMatrix() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET))) + .matrix(0, Items.WATER_BUCKET) + .storage(0, Items.WATER_BUCKET) + .craftByPlayer(true) // set leave in matrix + .expectStorageSlotEmpty(0) + .expectStorageSlot(17, Items.BUCKET, 1) // Matrix still has original bucket, so falls back here + .expectTakenOutput(new ItemStack(Items.CAKE, 1)) + .build()); + } + + @Test + void craftByPlayer_remainingFallsBackToPlayerWhenStorageFull() { + runScenario(scenario() + .recipe(shapedSingle(new ItemStack(Items.CAKE), Ingredient.of(Items.WATER_BUCKET))) + .matrix(0, Items.WATER_BUCKET) + .canConsumeFromMatrix() + .fillEmptyStorage(new ItemStack(Items.STICK)) + .craftByPlayer(false) + .expectMatrixEmpty() + .expectTakenOutput(new ItemStack(Items.CAKE, 1)) + .build()); + + //TODO test this thru scenario + verify(player).addItem(argThat(s -> s.getItem() == Items.BUCKET)); + } + + //endregion + + //endregion + + //region Scenario runner + + private void runScenario(Scenario s) { + // Set up containers + SimpleContainer matrix = new SimpleContainer(s.matrixSize()); + SimpleContainer storage = new SimpleContainer(s.storageSize()); + s.matrixSetup().forEach((slot, stack) -> matrix.setItem(slot, stack.copy())); + s.storageSetup().forEach((slot, stack) -> storage.setItem(slot, stack.copy())); + + // Wire up mocks; recipeManager delegates matching to the recipe + RecipeHolder recipeHolder = new RecipeHolder<>( + ResourceLocation.parse("test:recipe"), s.recipe()); + + lenient().when(level.getRecipeManager()).thenReturn(recipeManager); + lenient().when(level.registryAccess()).thenReturn(registryAccess); + lenient().when(recipeManager.getRecipeFor(eq(RecipeType.CRAFTING), any(CraftingInput.class), any())) + .thenAnswer(inv -> s.recipe().matches(inv.getArgument(1), level) + ? Optional.of(recipeHolder) : Optional.empty()); + + CraftingHelper helper = new CraftingHelper(new CraftingHelper.InventorySource() { + @Override public Container getCraftingMatrix() { return matrix; } + @Override public Container getStorage() { return storage; } + @Override public boolean canConsumeFromCraftingMatrix() { return s.canConsumeFromMatrix(); } + @Override public Level getWorld() { return level; } + }); + helper.onInventoryChanged(); + + boolean testResult = false; + boolean result = false; + + switch (s.action) { + case INTO_STORAGE -> { + testResult = helper.canTakeIntoStorage(); + result = helper.onCraftedIntoStorage(); + } + case BY_PLAYER -> { + testResult = helper.canTake(); + ItemStack taken = helper.getRecipeOutput(); + result = helper.onCraftedByPlayer(player, s.leaveRemainingInGrid()); + + assertTrue(ItemStack.matches(taken, s.expectedTakenOutput()), "Player taken crafting output does not match expected"); + } + } + + assertEquals(testResult, result, "Craft pre-check result does not match final result"); + assertEquals(s.expectedSuccess(), result, "craft action return value"); + assertContainerChanges(matrix, s.matrixSetup(), s.expectedMatrixSlots(), "Invalid matrix changes"); + assertContainerChanges(storage, s.storageSetup(), s.expectedStorageSlots(), "Invalid storage changes"); + } + + //endregion + + //region Helpers + + private static void assertContainerContents(Container container, Map map, String containerName) { + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack actual = container.getItem(i); + ItemStack expected = map.getOrDefault(i, ItemStack.EMPTY); + assertTrue(ItemStack.matches(expected, actual), + containerName + "[" + i + "] expected " + expected + ", got " + actual); + } + } + + private static void assertContainerChanges(Container container, Map initial, Map expectedChanges, String message) { + // All slots should not change unless explicitly expected + for (int i = 0; i < container.getContainerSize(); i++) { + ItemStack actual = container.getItem(i); + ItemStack expected = expectedChanges.getOrDefault(i, initial.getOrDefault(i, ItemStack.EMPTY)); + String expectation = expectedChanges.containsKey(i) ? "change to ": "remain "; + + assertTrue(ItemStack.matches(expected, actual), + message + ": Slot [" + i + "] expected to " + expectation + expected + ", but got " + actual); + } + } + + //endregion +} diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java new file mode 100644 index 000000000..158b28da4 --- /dev/null +++ b/expansion/src/test/java/mrtjp/projectred/expansion/CraftingHelperTest.java @@ -0,0 +1,406 @@ +package mrtjp.projectred.expansion; + +import net.minecraft.core.NonNullList; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.RecipeType; +import net.minecraft.world.level.Level; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class CraftingHelperTest { + + SimpleContainer matrix; // 9 slots — recipe pattern + SimpleContainer storage; // 18 slots — ingredient source + + @Mock Level level; + @Mock RecipeManager recipeManager; + @Mock RegistryAccess registryAccess; + @Mock Player player; + @Mock CraftingRecipe mockRecipe; + + RecipeHolder recipeHolder; + CraftingHelper helper; + + @BeforeEach + void setUp() { + matrix = new SimpleContainer(9); + storage = new SimpleContainer(18); + + recipeHolder = new RecipeHolder<>(ResourceLocation.parse("test:recipe"), mockRecipe); + + lenient().when(level.getRecipeManager()).thenReturn(recipeManager); + lenient().when(level.registryAccess()).thenReturn(registryAccess); + lenient().when(recipeManager.getRecipeFor(eq(RecipeType.CRAFTING), any(CraftingInput.class), any())) + .thenReturn(Optional.of(recipeHolder)); + lenient().when(mockRecipe.matches(any(CraftingInput.class), any(Level.class))).thenReturn(true); + lenient().when(mockRecipe.assemble(any(CraftingInput.class), any())).thenReturn(new ItemStack(Items.STICK, 4)); + lenient().when(mockRecipe.getRemainingItems(any(CraftingInput.class))).thenReturn(NonNullList.withSize(9, ItemStack.EMPTY)); + + helper = makeHelper(false); + } + + // Build a helper with configurable canConsumeFromCraftingMatrix + private CraftingHelper makeHelper(boolean consumeFromMatrix) { + return new CraftingHelper(new CraftingHelper.InventorySource() { + @Override public Container getCraftingMatrix() { return matrix; } + @Override public Container getStorage() { return storage; } + @Override public boolean canConsumeFromCraftingMatrix() { return consumeFromMatrix; } + @Override public Level getWorld() { return level; } + }); + } + + // Fill all 9 matrix slots and corresponding storage slots with 1 stick each + private void setUpFullMatrixAndStorageWithSticks() { + for (int i = 0; i < 9; i++) { + matrix.setItem(i, new ItemStack(Items.STICK)); + storage.setItem(i, new ItemStack(Items.STICK)); + } + } + + //region clear() + + @Test + public void testClear_resetsAllState() { + setUpFullMatrixAndStorageWithSticks(); + helper.onInventoryChanged(); + helper.clear(); + + assertFalse(helper.hasRecipe()); + assertTrue(helper.getRecipeOutput().isEmpty()); + assertFalse(helper.canTake()); + assertFalse(helper.canTakeIntoStorage()); + assertEquals(0, helper.getMissingIngredientMask()); + assertTrue(helper.getCraftingInventory().isEmpty()); + assertTrue(helper.getCraftResultInventory().isEmpty()); + } + + //endregion + + //region onInventoryChanged() + + @Test + public void testOnInventoryChanged_copiesMatrixToInternalInventory() { + matrix.setItem(3, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertEquals(Items.STICK, helper.getCraftingInventory().getItem(3).getItem()); + assertTrue(helper.getCraftingInventory().getItem(0).isEmpty()); + } + + @Test + public void testOnInventoryChanged_noRecipe_stateIsClean() { + when(recipeManager.getRecipeFor(any(), any(CraftingInput.class), any())) + .thenReturn(Optional.empty()); + matrix.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertFalse(helper.hasRecipe()); + assertTrue(helper.getRecipeOutput().isEmpty()); + assertFalse(helper.canTake()); + assertFalse(helper.canTakeIntoStorage()); + } + + @Test + public void testOnInventoryChanged_withRecipe_setsHasRecipeAndOutput() { + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertTrue(helper.hasRecipe()); + assertEquals(Items.STICK, helper.getRecipeOutput().getItem()); + } + + //endregion + + //region canTake() + + @Test + public void testCanTake_trueWhenIngredientInStorage() { + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertTrue(helper.canTake()); + } + + @Test + public void testCanTake_falseWhenStorageEmpty() { + matrix.setItem(0, new ItemStack(Items.STICK)); + // storage left empty + helper.onInventoryChanged(); + + assertFalse(helper.canTake()); + } + + @Test + public void testCanTake_withMatrixConsumption_usesMatrixItems() { + helper = makeHelper(true); + matrix.setItem(0, new ItemStack(Items.STICK)); + // storage is EMPTY — ingredient only in matrix + helper.onInventoryChanged(); + + assertTrue(helper.canTake()); + } + + @Test + public void testCanTake_withoutMatrixConsumption_ignoresMatrixItems() { + // default helper — canConsumeFromCraftingMatrix=false + matrix.setItem(0, new ItemStack(Items.STICK)); + // storage is EMPTY + helper.onInventoryChanged(); + + assertFalse(helper.canTake()); + } + + //endregion + + //region getMissingIngredientMask() + + @Test + public void testMissingIngredientMask_zeroWhenAllPresent() { + setUpFullMatrixAndStorageWithSticks(); + helper.onInventoryChanged(); + + assertEquals(0, helper.getMissingIngredientMask()); + } + + @Test + public void testMissingIngredientMask_bit0WhenSlot0Missing() { + matrix.setItem(0, new ItemStack(Items.STICK)); + // storage empty + helper.onInventoryChanged(); + + assertEquals(0b000000001, helper.getMissingIngredientMask()); + } + + @Test + public void testMissingIngredientMask_multipleBitsForMultipleMissing() { + matrix.setItem(0, new ItemStack(Items.STICK)); + matrix.setItem(1, new ItemStack(Items.STICK)); + matrix.setItem(2, new ItemStack(Items.STICK)); + // storage empty + helper.onInventoryChanged(); + + assertEquals(0b000000111, helper.getMissingIngredientMask()); + } + + //endregion + + //region canTakeIntoStorage() + + @Test + public void testCanTakeIntoStorage_trueWhenStorageHasRoom() { + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); // ingredient; slots 1-17 free + helper.onInventoryChanged(); + + assertTrue(helper.canTakeIntoStorage()); + } + + @Test + public void testCanTakeIntoStorage_falseWhenStorageFull() { + // Output = diamond sword (max stack 1) so it can't merge anywhere + when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD)); + + matrix.setItem(0, new ItemStack(Items.STICK)); + // Slot 0 has 2 sticks: consuming 1 leaves 1 stick (slot NOT empty) + storage.setItem(0, new ItemStack(Items.STICK, 2)); + // All other 17 slots packed full + for (int i = 1; i < 18; i++) { + storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64)); + } + helper.onInventoryChanged(); + + // No room for the sword — every slot is occupied and it can't stack with sticks/ingots + assertFalse(helper.canTakeIntoStorage()); + } + + //endregion + + //region onCraftedByPlayer() + + @Test + public void testOnCraftedByPlayer_returnsFalseWithNoRecipe() { + when(recipeManager.getRecipeFor(any(), any(CraftingInput.class), any())) + .thenReturn(Optional.empty()); + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertFalse(helper.onCraftedByPlayer(player, false)); + assertEquals(Items.STICK, storage.getItem(0).getItem()); // untouched + } + + @Test + public void testOnCraftedByPlayer_consumesIngredientFromStorage() { + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertTrue(helper.onCraftedByPlayer(player, false)); + assertTrue(storage.getItem(0).isEmpty()); + } + + @Test + public void testOnCraftedByPlayer_remainingGoesToGrid_whenMatrixConsumed() { + helper = makeHelper(true); // matrix items are also consumed + + // Remaining: a bucket appears at crafting-input slot 0 + NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY); + remaining.set(0, new ItemStack(Items.BUCKET)); + when(mockRecipe.getRemainingItems(any())).thenReturn(remaining); + + // Full 3x3 matrix of sticks so CraftingInput is 3x3 at (left=0, top=0) — slot 0 maps to matrix slot 0 + for (int i = 0; i < 9; i++) matrix.setItem(i, new ItemStack(Items.STICK)); + // Storage empty; ingredients consumed from matrix via overlay + helper.onInventoryChanged(); + + assertTrue(helper.onCraftedByPlayer(player, true)); + + // After consuming the stick from matrix slot 0, the slot is empty. + // The bucket remaining item is then placed back into slot 0. + assertEquals(Items.BUCKET, matrix.getItem(0).getItem()); + } + + @Test + public void testOnCraftedByPlayer_remainingGoesToStorage_whenLeaveRemainingFalse() { + NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY); + remaining.set(0, new ItemStack(Items.BUCKET)); + when(mockRecipe.getRemainingItems(any())).thenReturn(remaining); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + helper.onCraftedByPlayer(player, false); + + boolean bucketInStorage = false; + for (int i = 0; i < 18; i++) { + if (storage.getItem(i).getItem() == Items.BUCKET) { bucketInStorage = true; break; } + } + assertTrue(bucketInStorage); + } + + @Test + public void testOnCraftedByPlayer_remainingFallsBackToPlayer_whenStorageFull() { + NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY); + remaining.set(0, new ItemStack(Items.DIAMOND_SWORD)); // can't merge into anything + when(mockRecipe.getRemainingItems(any())).thenReturn(remaining); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK, 2)); // consume 1 → 1 left, slot not empty + for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64)); + + helper.onInventoryChanged(); + helper.onCraftedByPlayer(player, false); + + verify(player).addItem(argThat(s -> s.getItem() == Items.DIAMOND_SWORD)); + } + + @Test + public void testOnCraftedByPlayer_sourceUnalteredOnFailure() { + // Recipe found, but matches() = false → craftFromSource returns EMPTY + when(mockRecipe.matches(any(), any())).thenReturn(false); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertFalse(helper.onCraftedByPlayer(player, false)); + assertEquals(1, storage.getItem(0).getCount()); // overlay never committed + } + + //endregion + + //region onCraftedIntoStorage() + + @Test + public void testOnCraftedIntoStorage_storesOutputInStorage() { + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); // ingredient; slots 1-17 free for output + helper.onInventoryChanged(); + + assertTrue(helper.onCraftedIntoStorage()); + + // 4 sticks output injected; original 1-stick ingredient consumed → net = 4 sticks + int totalSticks = 0; + for (int i = 0; i < 18; i++) { + if (storage.getItem(i).getItem() == Items.STICK) { + totalSticks += storage.getItem(i).getCount(); + } + } + assertEquals(4, totalSticks); + } + + @Test + public void testOnCraftedIntoStorage_storesRemainingItemsInStorage() { + NonNullList remaining = NonNullList.withSize(9, ItemStack.EMPTY); + remaining.set(0, new ItemStack(Items.BUCKET)); + when(mockRecipe.getRemainingItems(any())).thenReturn(remaining); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK)); + helper.onInventoryChanged(); + + assertTrue(helper.onCraftedIntoStorage()); + + boolean bucketInStorage = false; + for (int i = 0; i < 18; i++) { + if (storage.getItem(i).getItem() == Items.BUCKET) { bucketInStorage = true; break; } + } + assertTrue(bucketInStorage); + } + + @Test + public void testOnCraftedIntoStorage_returnsFalseWhenNoRoom() { + when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD)); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK, 2)); + for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64)); + helper.onInventoryChanged(); + + assertFalse(helper.onCraftedIntoStorage()); + } + + @Test + public void testOnCraftedIntoStorage_sourceUnalteredOnFailure() { + when(mockRecipe.assemble(any(), any())).thenReturn(new ItemStack(Items.DIAMOND_SWORD)); + + matrix.setItem(0, new ItemStack(Items.STICK)); + storage.setItem(0, new ItemStack(Items.STICK, 2)); + for (int i = 1; i < 18; i++) storage.setItem(i, new ItemStack(Items.IRON_INGOT, 64)); + helper.onInventoryChanged(); + + int[] beforeCounts = new int[18]; + for (int i = 0; i < 18; i++) beforeCounts[i] = storage.getItem(i).getCount(); + + helper.onCraftedIntoStorage(); + + for (int i = 0; i < 18; i++) { + assertEquals(beforeCounts[i], storage.getItem(i).getCount(), + "storage slot " + i + " was unexpectedly modified"); + } + } + + //endregion +} diff --git a/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java b/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java new file mode 100644 index 000000000..4a53b0367 --- /dev/null +++ b/expansion/src/test/java/mrtjp/projectred/expansion/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package mrtjp.projectred.expansion; + +import net.covers1624.quack.annotation.FieldsAreNonnullByDefault; +import net.covers1624.quack.annotation.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;