Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 24a220ae4a feat(crafting): add cobweb recipe from string
Release / release (push) Successful in 1m30s
CI / build (push) Successful in 2m15s
US-007: 69 tests pass; both exact-head CI checks passed.
2026-10-08 00:24:19 +00:00
dmg ae899926fe feat(crafting): add cobweb recipe from string
CI / build (pull_request) Successful in 2m19s
CI / build (push) Successful in 6m46s
2026-10-07 19:54:57 -04:00
dmg 534cab061c feat(crafting): add stackable dispenser recipe
Release / release (push) Successful in 4m12s
CI / build (push) Successful in 3m23s
2026-09-22 21:20:47 -04:00
18 changed files with 1487 additions and 21 deletions

No files matched your search

+6 -2
View File
@@ -1,6 +1,6 @@
# Purpur Inventory Helper
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory.
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
@@ -24,7 +24,7 @@ verifies SHA-256
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both variants.
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
non-blocking live checks.
@@ -45,6 +45,10 @@ idempotent; reloading the plugin retains player recipe-book knowledge and ordina
If the two recipes cannot register as one complete group, recipe-book discovery is disabled while
existing manual crafting and inventory behavior remain available.
## Alternative Dispenser recipe
One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration.
## Sorting inventories
Hold an authentic Sorting Stick and right-click:
@@ -0,0 +1,70 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */
public final class CobwebRecipeRegistrar implements RecipeRegistrar {
private final NamespacedKey recipeKey;
private Server registeredServer;
private boolean owned;
public CobwebRecipeRegistrar(NamespacedKey recipeKey) {
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
}
@Override
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
if (owned) {
return true;
}
registeredServer = server;
if (server.getRecipe(recipeKey) != null) {
return false;
}
ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB));
recipe.shape("S S", " S ", "S S");
recipe.setIngredient('S', Material.STRING);
recipe.setCategory(CraftingBookCategory.BUILDING);
try {
owned = server.addRecipe(recipe);
return owned;
} catch (RuntimeException exception) {
try {
owned = server.getRecipe(recipeKey) != null;
unregister();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
throw exception;
}
}
@Override
public List<NamespacedKey> recipeKeys() {
return List.of(recipeKey);
}
@Override
public void unregister() {
if (!owned || registeredServer == null) {
return;
}
if (!registeredServer.removeRecipe(recipeKey)) {
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
}
owned = false;
}
@Override
public String description() {
return "Cobweb crafting recipe";
}
}
@@ -0,0 +1,86 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
public final class DispenserRecipeRegistrar implements RecipeRegistrar {
private final NamespacedKey recipeKey;
private final Supplier<ItemStack> resultFactory;
private final RecipeFactory recipeFactory;
private Server registeredServer;
private boolean owned;
public DispenserRecipeRegistrar(NamespacedKey recipeKey) {
this(recipeKey, () -> new ItemStack(Material.DISPENSER), ShapelessRecipe::new);
}
DispenserRecipeRegistrar(
NamespacedKey recipeKey,
Supplier<ItemStack> resultFactory,
RecipeFactory recipeFactory
) {
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
this.resultFactory = Objects.requireNonNull(resultFactory, "resultFactory");
this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory");
}
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
if (owned) {
return true;
}
registeredServer = server;
if (server.getRecipe(recipeKey) != null) {
return false;
}
ItemStack result = resultFactory.get();
ShapelessRecipe recipe = recipeFactory.create(recipeKey, result);
recipe.addIngredient(Material.DROPPER);
recipe.addIngredient(Material.TRIPWIRE_HOOK);
recipe.setCategory(CraftingBookCategory.REDSTONE);
try {
owned = server.addRecipe(recipe);
return owned;
} catch (RuntimeException exception) {
try {
owned = server.getRecipe(recipeKey) != null;
unregister();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
throw exception;
}
}
@Override
public List<NamespacedKey> recipeKeys() {
return List.of(recipeKey);
}
public void unregister() {
if (!owned || registeredServer == null) {
return;
}
if (!registeredServer.removeRecipe(recipeKey)) {
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
}
owned = false;
}
@Override
public String description() {
return "alternative Dispenser recipe";
}
@FunctionalInterface
interface RecipeFactory {
ShapelessRecipe create(NamespacedKey key, ItemStack result);
}
}
@@ -12,10 +12,16 @@ import org.bukkit.event.player.PlayerJoinEvent;
public final class RecipeDiscovery implements Listener {
private final List<NamespacedKey> recipeKeys;
private final String description;
private final Logger logger;
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
this(recipeKeys, "recipes", logger);
}
public RecipeDiscovery(List<NamespacedKey> recipeKeys, String description, Logger logger) {
this.recipeKeys = List.copyOf(recipeKeys);
this.description = Objects.requireNonNull(description, "description");
this.logger = Objects.requireNonNull(logger, "logger");
}
@@ -41,12 +47,12 @@ public final class RecipeDiscovery implements Listener {
.filter(key -> !player.hasDiscoveredRecipe(key))
.toList();
if (!stillUnknown.isEmpty()) {
logger.warning("Could not discover Sorting Stick recipes for "
logger.warning("Could not discover " + description + " for "
+ player.getName() + ": " + stillUnknown);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not discover Sorting Stick recipes for " + player.getName(), exception);
"Could not discover " + description + " for " + player.getName(), exception);
}
}
}
@@ -12,14 +12,14 @@ import org.bukkit.plugin.Plugin;
public final class RecipeLifecycle {
private final Server server;
private final Plugin plugin;
private final SortingStickRecipeRegistrar recipes;
private final RecipeRegistrar recipes;
private final Logger logger;
private RecipeDiscovery discovery;
public RecipeLifecycle(
Server server,
Plugin plugin,
SortingStickRecipeRegistrar recipes,
RecipeRegistrar recipes,
Logger logger
) {
this.server = Objects.requireNonNull(server, "server");
@@ -34,12 +34,13 @@ public final class RecipeLifecycle {
}
try {
if (!recipes.register(server)) {
logger.severe("Sorting Stick recipes were not registered as a complete group; "
+ "discovery is disabled.");
logger.severe(recipes.description()
+ " were not registered; discovery is disabled.");
return false;
}
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
discovery = new RecipeDiscovery(
recipes.recipeKeys(), recipes.description(), logger);
server.getPluginManager().registerEvents(discovery, plugin);
discovery.discoverAll(onlinePlayers);
return true;
@@ -51,8 +52,7 @@ public final class RecipeLifecycle {
exception.addSuppressed(cleanupFailure);
}
logger.log(Level.SEVERE,
"Sorting Stick recipes were not registered as a complete group; "
+ "discovery is disabled.",
recipes.description() + " were not registered; discovery is disabled.",
exception);
return false;
}
@@ -0,0 +1,33 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
public final class RecipeLifecycleGroup {
private final List<RecipeLifecycle> lifecycles;
public RecipeLifecycleGroup(List<RecipeLifecycle> lifecycles) {
this.lifecycles = List.copyOf(lifecycles);
}
public void enableAll() {
lifecycles.forEach(RecipeLifecycle::enable);
}
public void disableAll() {
RuntimeException failure = null;
for (int index = lifecycles.size() - 1; index >= 0; index--) {
try {
lifecycles.get(index).disable();
} catch (RuntimeException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
}
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,15 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
public interface RecipeRegistrar {
boolean register(Server server);
List<NamespacedKey> recipeKeys();
void unregister();
String description();
}
@@ -11,7 +11,7 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
public final class SortingStickRecipeRegistrar {
public final class SortingStickRecipeRegistrar implements RecipeRegistrar {
private final NamespacedKey recipeKey;
private final SortingStickItemFactory itemFactory;
private final SortingStickRecipeSpec recipeSpec;
@@ -81,6 +81,11 @@ public final class SortingStickRecipeRegistrar {
rollback();
}
@Override
public String description() {
return "Sorting Stick recipes";
}
private void rollback() {
if (registeredServer == null) {
return;
@@ -1,11 +1,24 @@
package games.dmg.spigotinventoryhelper;
import java.io.File;
import java.util.List;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.plugin.java.JavaPluginLoader;
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
private SortingStickItemFactory sortingSticks;
private RecipeLifecycle recipeLifecycle;
private RecipeLifecycleGroup recipeLifecycles;
public SpigotInventoryHelperPlugin() {}
// Bukkit's explicit initialization path keeps native tests on the real enable callback.
SpigotInventoryHelperPlugin(
JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file
) {
super(loader, description, dataFolder, file);
}
@Override
public void onEnable() {
@@ -15,8 +28,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
sortingSticks,
SortingStickRecipeSpec.defaultRecipe()
);
recipeLifecycle = new RecipeLifecycle(getServer(), this, recipes, getLogger());
recipeLifecycle.enable();
RecipeLifecycle sortingRecipes = new RecipeLifecycle(
getServer(), this, recipes, getLogger());
RecipeLifecycle dispenserRecipe = new RecipeLifecycle(
getServer(),
this,
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
getLogger()
);
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
getServer(),
this,
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
getLogger()
);
recipeLifecycles = new RecipeLifecycleGroup(
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
recipeLifecycles.enableAll();
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
getServer().getPluginManager().registerEvents(
new SortingStickListener(sortingSticks, sorting),
@@ -31,9 +59,9 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (recipeLifecycle != null) {
recipeLifecycle.disable();
recipeLifecycle = null;
if (recipeLifecycles != null) {
recipeLifecycles.disableAll();
recipeLifecycles = null;
}
}
@@ -0,0 +1,309 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.InvalidDescriptionException;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPluginLoader;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
class NativeCobwebPluginWiringTest {
private static final NamespacedKey COBWEB = key("string_cobweb");
private static final Set<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
private static Server previousServer;
private static Server serverUnderTest;
@TempDir Path directory;
private Map<NamespacedKey, Recipe> registry;
private PaperPluginManagerImpl events;
private Logger logger;
private List<LogRecord> logs;
private Handler logHandler;
private SpigotInventoryHelperPlugin createdPlugin;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
serverUnderTest = mock(Server.class);
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
logger = Logger.getAnonymousLogger();
logs = new ArrayList<>();
logHandler = new Handler() {
@Override public void publish(LogRecord record) { logs.add(record); }
@Override public void flush() {}
@Override public void close() {}
};
logger.addHandler(logHandler);
when(serverUnderTest.getLogger()).thenReturn(logger);
registry = new HashMap<>();
registry.put(VANILLA, mock(Recipe.class));
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.get(invocation.getArgument(0)));
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
events = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
when(serverUnderTest.getPluginManager()).thenReturn(events);
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
}
@AfterEach
void removeLogHandler() {
logger.removeHandler(logHandler);
if (createdPlugin != null) {
createdPlugin.getLogger().removeHandler(logHandler);
}
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
@Test
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
AtomicInteger onlineGrants = new AtomicInteger();
AtomicInteger joiningGrants = new AtomicInteger();
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
Recipe vanilla = registry.get(VANILLA);
var plugin = plugin();
try {
plugin.setEnabled(true);
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(registry.containsKey(COBWEB));
assertEquals(expectedKnown(), onlineKnown);
plugin.setEnabled(true);
events.callEvent(new PlayerJoinEvent(joining, "joined"));
events.callEvent(new PlayerJoinEvent(joining, "joined again"));
assertEquals(expectedKnown(), joiningKnown);
assertEquals(1, onlineGrants.get());
assertEquals(1, joiningGrants.get());
plugin.setEnabled(false);
assertEquals(Set.of(VANILLA), registry.keySet());
assertSame(vanilla, registry.get(VANILLA));
assertEquals(expectedKnown(), onlineKnown);
assertEquals(expectedKnown(), joiningKnown);
Set<NamespacedKey> afterDisable = new LinkedHashSet<>();
events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable,
new AtomicInteger(), new AtomicBoolean()), "joined"));
assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes");
plugin.setEnabled(true);
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(registry.containsKey(COBWEB));
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
assertNoSevereLogs();
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
Recipe conflict = mock(Recipe.class);
registry.put(COBWEB, conflict);
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
AtomicInteger grants = new AtomicInteger();
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
.when(serverUnderTest).getOnlinePlayers();
var plugin = plugin();
try {
plugin.setEnabled(true);
assertSame(conflict, registry.get(COBWEB));
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(known.containsAll(EXISTING));
assertFalse(known.contains(COBWEB));
assertEquals(0, grants.get());
plugin.setEnabled(false);
assertEquals(Set.of(VANILLA, COBWEB), registry.keySet());
assertSame(conflict, registry.get(COBWEB));
registry.remove(COBWEB);
plugin.setEnabled(true);
assertTrue(registry.containsKey(COBWEB));
assertTrue(known.contains(COBWEB));
assertEquals(1, grants.get());
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
"Only the deliberately injected key conflict is severe");
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
AtomicBoolean fail = new AtomicBoolean(true);
AtomicInteger failedGrants = new AtomicInteger();
Player failed = player("Failed", failedKnown, failedGrants, fail);
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
var plugin = plugin();
try {
plugin.setEnabled(true);
assertFalse(failedKnown.contains(COBWEB));
assertEquals(expectedKnown(), healthyKnown);
assertTrue(failedKnown.containsAll(EXISTING));
fail.set(false);
events.callEvent(new PlayerJoinEvent(failed, "retry"));
assertEquals(expectedKnown(), failedKnown);
assertEquals(1, failedGrants.get());
assertNoSevereLogs();
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() {
PluginManager throwing = mock(PluginManager.class);
AtomicInteger discoveries = new AtomicInteger();
doAnswer(invocation -> {
Listener listener = invocation.getArgument(0);
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
events.registerEvents(listener, owner);
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 3) {
throw new IllegalStateException("simulated post-listener-registration failure");
}
return null;
}).when(throwing).registerEvents(any(), any());
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
var plugin = plugin();
try {
plugin.setEnabled(true);
assertFalse(registry.containsKey(COBWEB));
assertTrue(registry.keySet().containsAll(EXISTING));
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
new AtomicBoolean()), "joined"));
assertTrue(known.containsAll(EXISTING));
assertFalse(known.contains(COBWEB));
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
"Only the deliberately injected listener failure is severe");
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
private SpigotInventoryHelperPlugin plugin() {
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile());
createdPlugin.getLogger().addHandler(logHandler);
return createdPlugin;
} catch (java.io.IOException | InvalidDescriptionException exception) {
throw new AssertionError("Could not read generated plugin metadata", exception);
}
}
private static Player player(String name, Set<NamespacedKey> known,
AtomicInteger cobwebGrants, AtomicBoolean failCobweb) {
Player player = mock(Player.class);
when(player.getName()).thenReturn(name);
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> known.contains(invocation.getArgument(0)));
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
if (requested.contains(COBWEB)) {
if (failCobweb.get()) {
throw new IllegalStateException("simulated player discovery failure");
}
cobwebGrants.incrementAndGet();
}
known.addAll(requested);
return requested.size();
});
return player;
}
private void assertNoSevereLogs() {
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
}
private static Set<NamespacedKey> expectedKnown() {
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
expected.add(COBWEB);
expected.add(VANILLA);
return expected;
}
private static NamespacedKey key(String name) {
return new NamespacedKey("spigotinventoryhelper", name);
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,405 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
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;
/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */
class NativeCobwebRecipeTest {
private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb");
private static final List<Integer> STRING_SLOTS = List.of(0, 2, 4, 6, 8);
private static Server previousServer;
private static Server serverUnderTest;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
serverUnderTest = mock(Server.class);
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
@Test
void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() {
ShapedRecipe recipe = registeredRecipe();
assertEquals(KEY, recipe.getKey());
assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape()));
assertEquals(List.of(Material.STRING),
((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices());
assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory());
assertOrdinaryCobweb(recipe.getResult(), 1);
var nativeRecipe = nativeRecipe(recipe);
assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
nativeRecipe.recipeBookCategory());
var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay)
nativeRecipe.display().getFirst();
assertEquals(3, display.width());
assertEquals(3, display.height());
for (int slot = 0; slot < 9; slot++) {
if (STRING_SLOTS.contains(slot)) {
var expected = CraftRecipe.toIngredient(
new RecipeChoice.MaterialChoice(Material.STRING), false).display();
assertEquals(expected, display.ingredients().get(slot));
} else {
assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE,
display.ingredients().get(slot));
}
}
var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay)
display.result();
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1);
}
@Test
void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() {
var recipe = nativeRecipe(registeredRecipe());
var level = mock(net.minecraft.world.level.Level.class);
List<net.minecraft.world.item.ItemStack> valid = pattern();
assertTrue(recipe.matches(input(valid), level));
for (int slot = 0; slot < 9; slot++) {
List<net.minecraft.world.item.ItemStack> wrong = new ArrayList<>(valid);
wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY
: CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot);
}
List<net.minecraft.world.item.ItemStack> wrongMaterial = new ArrayList<>(valid);
wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT)));
assertFalse(recipe.matches(input(wrongMaterial), level));
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2,
java.util.stream.IntStream.range(0, 4).mapToObj(i ->
CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level));
}
@Test
void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() {
var recipe = nativeRecipe(registeredRecipe());
var fixture = crafting();
ItemStack unrelated = marked(Material.DIAMOND, 7);
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
for (int slot : STRING_SLOTS) {
fixture.menu().craftSlots.setItem(slot,
CraftItemStack.asNMSCopy(marked(Material.STRING, 2)));
}
var grid = fixture.menu().craftSlots;
assertTrue(recipe.matches(nativeInput(grid), fixture.player().level()));
List<ItemStack> before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
var assembled = recipe.assemble(nativeInput(grid));
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1);
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(),
"Preview must neither consume ingredients nor mutate metadata");
setResult(fixture.menu(), recipe, assembled);
var slot = fixture.menu().getResultSlot();
var taken = slot.remove(1);
slot.onTake(fixture.player(), taken);
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1);
assertEquals(5, count(grid, Material.STRING));
for (int index : STRING_SLOTS) {
assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index)));
}
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
assertTrue(fixture.menu().resultSlots.isEmpty());
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
}
@ParameterizedTest
@ValueSource(ints = {1, 3, 64})
void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) {
var recipe = nativeRecipe(registeredRecipe());
var fixture = crafting();
for (int i = 0; i < 5; i++) {
fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts)));
}
ItemStack unrelated = marked(Material.DIAMOND, 7);
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
var action = autofill(fixture, recipe);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING));
assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output");
assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved");
for (int attempt = 0; attempt < crafts; attempt++) {
assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level()));
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
var moved = fixture.menu().quickMoveStack(fixture.player(), 0);
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1);
assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING));
}
assertTrue(fixture.menu().craftSlots.isEmpty());
assertEquals(crafts, count(fixture.inventory(), Material.COBWEB));
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
assertEquals(2, count(fixture.inventory(), Material.DIRT));
}
@Test
void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() {
var recipe = nativeRecipe(registeredRecipe());
var fixture = crafting();
for (int slot = 0; slot < 36; slot++) {
fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)));
}
for (int slot : STRING_SLOTS) {
fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
}
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty());
assertEquals(5, count(fixture.menu().craftSlots, Material.STRING));
assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT));
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1);
}
@Test
void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() {
var fixture = crafting();
fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4)));
var action = autofill(fixture, nativeRecipe(registeredRecipe()));
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action);
assertEquals(4, count(fixture.inventory(), Material.STRING));
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
assertTrue(fixture.menu().craftSlots.isEmpty());
assertTrue(fixture.menu().resultSlots.isEmpty());
}
@Test
void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() {
var recipe = nativeRecipe(registeredRecipe());
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe);
var book = new TestRecipeBook((key, consumer) -> consumer.accept(
new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
new net.minecraft.world.item.crafting.display.RecipeDisplayId(0),
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
Optional.of(recipe.placementInfo().ingredients()))));
var player = mock(net.minecraft.server.level.ServerPlayer.class);
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
PluginManager events = mock(PluginManager.class);
when(serverUnderTest.getPluginManager()).thenReturn(events);
AtomicBoolean cancel = new AtomicBoolean(true);
doAnswer(invocation -> {
var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0);
event.setCancelled(cancel.get());
return null;
}).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class));
assertEquals(0, book.addRecipes(List.of(holder), player));
assertFalse(book.contains(nativeKey()));
cancel.set(false);
assertEquals(1, book.addRecipes(List.of(holder), player));
assertEquals(0, book.addRecipes(List.of(holder), player));
assertTrue(book.contains(nativeKey()));
}
@Test
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() {
Map<NamespacedKey, Recipe> registry = new HashMap<>();
NamespacedKey foreign = new NamespacedKey("minecraft", "stick");
Recipe unrelated = mock(Recipe.class);
registry.put(foreign, unrelated);
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.get(invocation.getArgument(0)));
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registry.put(KEY, invocation.getArgument(0));
throw new IllegalStateException("simulated post-insertion failure");
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
var registrar = new CobwebRecipeRegistrar(KEY);
assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest));
registrar.unregister();
assertEquals(Map.of(foreign, unrelated), registry);
}
@Test
void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() {
AtomicInteger registrations = new AtomicInteger();
AtomicBoolean allow = new AtomicBoolean();
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registrations.incrementAndGet();
return allow.get();
});
var registrar = new CobwebRecipeRegistrar(KEY);
assertFalse(registrar.register(serverUnderTest));
registrar.unregister();
allow.set(true);
assertTrue(registrar.register(serverUnderTest));
assertTrue(registrar.register(serverUnderTest));
assertEquals(2, registrations.get(), "Owned registration must be idempotent");
AtomicInteger removals = new AtomicInteger();
when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1);
assertThrows(IllegalStateException.class, registrar::unregister);
registrar.unregister();
registrar.unregister();
assertEquals(2, removals.get());
}
private static ShapedRecipe registeredRecipe() {
List<Recipe> registered = new ArrayList<>();
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest));
assertEquals(1, registered.size());
return (ShapedRecipe) registered.getFirst();
}
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
recipe.getChoiceMap().forEach((symbol, choice) -> {
if (choice != null) {
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false));
}
});
return new net.minecraft.world.item.crafting.ShapedRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()),
CraftItemStack.asTemplate(recipe.getResult()));
}
private static CraftingFixture crafting() {
var player = mock(net.minecraft.world.entity.player.Player.class);
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
var inventory = new net.minecraft.world.entity.player.Inventory(
player, new net.minecraft.world.entity.EntityEquipment());
when(player.getInventory()).thenReturn(inventory);
return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory));
}
private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(
CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) {
var grid = fixture.menu().craftSlots;
var slots = fixture.menu().getInputGridSlots();
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
net.minecraft.world.item.crafting.ShapedRecipe>() {
@Override public void fillCraftSlotsStackedContents(
net.minecraft.world.entity.player.StackedItemContents contents) {
grid.fillStackedContents(contents);
}
@Override public void clearCraftingContent() { grid.clearContent(); }
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
return candidate.value().matches(nativeInput(grid), fixture.player().level());
}
};
return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots,
fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe),
true, false);
}
private static void setResult(net.minecraft.world.inventory.CraftingMenu menu,
net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) {
var holder = new net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe);
menu.craftSlots.setCurrentRecipe(holder);
menu.resultSlots.setRecipeUsed(holder);
menu.resultSlots.setItem(0, result);
}
private static List<net.minecraft.world.item.ItemStack> pattern() {
return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot)
? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))
: net.minecraft.world.item.ItemStack.EMPTY).toList();
}
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) {
return input(java.util.stream.IntStream.range(0, grid.getContainerSize())
.mapToObj(grid::getItem).toList());
}
private static net.minecraft.world.item.crafting.CraftingInput input(List<net.minecraft.world.item.ItemStack> items) {
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
}
private static long count(net.minecraft.world.Container container, Material material) {
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
.mapToInt(ItemStack::getAmount).sum();
}
private static ItemStack marked(Material material, int amount) {
ItemStack item = new ItemStack(material, amount);
var meta = item.getItemMeta();
meta.displayName(Component.text("Keep metadata"));
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
PersistentDataType.STRING, "unchanged");
item.setItemMeta(meta);
return item;
}
private static void assertOrdinaryCobweb(ItemStack item, int amount) {
assertEquals(Material.COBWEB, item.getType());
assertEquals(amount, item.getAmount());
assertFalse(item.hasItemMeta());
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey() {
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey()));
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook {
TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); }
}
private record CraftingFixture(net.minecraft.world.entity.player.Player player,
net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {}
}
@@ -0,0 +1,273 @@
package games.dmg.spigotinventoryhelper;
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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Pinned Purpur coverage for the alternative Dispenser's registered native path. */
class NativeDispenserRecipeTest {
private static Server previousServer;
private static Server serverUnderTest;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
serverUnderTest = mock(Server.class);
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
@Test
void registeredRecipeDiscoversIndependentlyAndPreservesVanillaDispenserRecipe() {
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
NamespacedKey vanilla = new NamespacedKey("minecraft", "dispenser");
Map<NamespacedKey, Recipe> registry = new HashMap<>();
Recipe vanillaRecipe = mock(Recipe.class);
registry.put(vanilla, vanillaRecipe);
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.get(invocation.getArgument(0)));
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
var events = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
when(serverUnderTest.getPluginManager()).thenReturn(events);
Set<NamespacedKey> known = new LinkedHashSet<>();
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
Plugin plugin = enabledPlugin();
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(key);
RecipeLifecycle lifecycle = new RecipeLifecycle(
serverUnderTest, plugin, registrar, plugin.getLogger());
assertTrue(lifecycle.enable());
ShapelessRecipe recipe = (ShapelessRecipe) registry.get(key);
assertEquals(CraftingBookCategory.REDSTONE, recipe.getCategory());
assertEquals(Material.DISPENSER, recipe.getResult().getType());
assertEquals(1, recipe.getResult().getAmount());
assertFalse(recipe.getResult().hasItemMeta());
assertEquals(List.of(Material.DROPPER, Material.TRIPWIRE_HOOK),
recipe.getChoiceList().stream()
.map(org.bukkit.inventory.RecipeChoice.MaterialChoice.class::cast)
.map(choice -> choice.getChoices().getFirst())
.toList());
assertEquals(Set.of(key), known);
lifecycle.disable();
assertEquals(Set.of(vanilla), registry.keySet());
assertEquals(vanillaRecipe, registry.get(vanilla));
assertEquals(Set.of(key), known, "Disable must retain native recipe-book knowledge");
}
@Test
void nativeAutofillAndRepeatedCompletionConsumeOnePairPerDispenser() {
when(serverUnderTest.getPluginManager()).thenReturn(mock(org.bukkit.plugin.PluginManager.class));
List<ShapelessRecipe> registered = new ArrayList<>();
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add((ShapelessRecipe) invocation.getArgument(0));
return true;
});
assertTrue(new DispenserRecipeRegistrar(new NamespacedKey(
"spigotinventoryhelper", "dropper_dispenser")).register(serverUnderTest));
ShapelessRecipe bukkit = registered.getFirst();
var nativeRecipe = nativeRecipe(bukkit);
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
when(nativePlayer.getBukkitEntity()).thenReturn(
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
var inventory = new net.minecraft.world.entity.player.Inventory(
nativePlayer, new net.minecraft.world.entity.EntityEquipment());
when(nativePlayer.getInventory()).thenReturn(inventory);
inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DROPPER, 3)));
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.TRIPWIRE_HOOK, 3)));
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
mock(net.minecraft.world.inventory.AbstractContainerMenu.class),
3, 3, nativePlayer);
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
List<net.minecraft.world.inventory.Slot> slots = java.util.stream.IntStream.range(0, 9)
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
.toList();
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
net.minecraft.world.item.crafting.ShapelessRecipe>() {
@Override
public void fillCraftSlotsStackedContents(
net.minecraft.world.entity.player.StackedItemContents contents) {
grid.fillStackedContents(contents);
}
@Override
public void clearCraftingContent() {
grid.clearContent();
}
@Override
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.ShapelessRecipe> candidate) {
return candidate.value().matches(nativeInput(grid),
mock(net.minecraft.world.level.Level.class));
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
access, 3, 3, slots, slots, inventory, holder, true, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
assertEquals(3, count(grid, Material.DROPPER));
assertEquals(3, count(grid, Material.TRIPWIRE_HOOK));
assertEquals(2, inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == Material.DIRT)
.mapToInt(ItemStack::getAmount).sum());
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
int crafted = 0;
for (int attempt = 0; attempt < 3; attempt++) {
assertTrue(nativeRecipe.matches(nativeInput(grid),
mock(net.minecraft.world.level.Level.class)));
net.minecraft.world.item.ItemStack assembled = nativeRecipe.assemble(nativeInput(grid));
ItemStack preview = CraftItemStack.asBukkitCopy(assembled);
assertEquals(Material.DISPENSER, preview.getType());
assertEquals(1, preview.getAmount());
assertFalse(preview.hasItemMeta());
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
var result = new net.minecraft.world.inventory.ResultContainer();
result.setRecipeUsed(holder);
result.setItem(0, assembled);
var slot = new net.minecraft.world.inventory.ResultSlot(
nativePlayer, grid, result, 0, 0, 0);
net.minecraft.world.item.ItemStack taken = slot.remove(1);
slot.onTake(nativePlayer, taken);
crafted += taken.getCount();
}
assertEquals(3, crafted);
assertTrue(grid.isEmpty());
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertFalse(inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.anyMatch(item -> item.getType() == Material.DISPENSER),
"Preview and autofill must not create an extra result");
}
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
net.minecraft.world.Container grid) {
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
java.util.stream.IntStream.range(0, grid.getContainerSize())
.mapToObj(grid::getItem).toList());
}
private static long count(net.minecraft.world.Container grid, Material material) {
return java.util.stream.IntStream.range(0, grid.getContainerSize())
.mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == material)
.mapToInt(ItemStack::getAmount).sum();
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>
nativeRecipeKey(NamespacedKey key) {
return net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.RECIPE,
net.minecraft.resources.Identifier.fromNamespaceAndPath(
key.getNamespace(), key.getKey()));
}
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(
ShapelessRecipe recipe) {
List<net.minecraft.world.item.crafting.Ingredient> ingredients = recipe.getChoiceList()
.stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
return new net.minecraft.world.item.crafting.ShapelessRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
}
private static org.bukkit.entity.Player discoveryPlayer(
String name, Set<NamespacedKey> known) {
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
when(player.getName()).thenReturn(name);
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> known.contains(invocation.getArgument(0)));
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
known.addAll(requested);
return requested.size();
});
return player;
}
private static Plugin enabledPlugin() {
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeDispenserRecipe"));
PluginDescriptionFile description = new PluginDescriptionFile(
"SpigotInventoryHelper", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
return plugin;
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,113 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class DispenserRecipeRegistrarTest {
@Test
void registersOrdinaryDispenserRecipeInRedstoneCategory() {
Server server = mock(Server.class);
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
ItemStack dispenser = mock(ItemStack.class);
when(dispenser.getType()).thenReturn(Material.DISPENSER);
when(dispenser.getAmount()).thenReturn(1);
DispenserRecipeRegistrar.RecipeFactory recipes =
mock(DispenserRecipeRegistrar.RecipeFactory.class);
when(recipes.create(any(), any())).thenReturn(recipe);
when(server.addRecipe(recipe)).thenReturn(true);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
key, () -> dispenser, recipes);
assertTrue(registrar.register(server));
ArgumentCaptor<ItemStack> result = ArgumentCaptor.forClass(ItemStack.class);
verify(recipes).create(org.mockito.ArgumentMatchers.eq(key), result.capture());
assertEquals(Material.DISPENSER, result.getValue().getType());
assertEquals(1, result.getValue().getAmount());
assertFalse(result.getValue().hasItemMeta());
verify(recipe).addIngredient(Material.DROPPER);
verify(recipe).addIngredient(Material.TRIPWIRE_HOOK);
verify(recipe).setCategory(CraftingBookCategory.REDSTONE);
verify(server).addRecipe(recipe);
}
@Test
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedRecipe() {
Server server = mock(Server.class);
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
ItemStack result = mock(ItemStack.class);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
when(server.getRecipe(key)).thenReturn(null, recipe);
when(server.addRecipe(recipe)).thenThrow(new IllegalStateException("registry failed"));
when(server.removeRecipe(key)).thenReturn(true);
DispenserRecipeRegistrar.RecipeFactory recipes =
mock(DispenserRecipeRegistrar.RecipeFactory.class);
when(recipes.create(any(), any())).thenReturn(recipe);
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
key, () -> result, recipes);
assertThrows(IllegalStateException.class, () -> registrar.register(server));
registrar.unregister();
verify(server).removeRecipe(key);
}
@Test
void failedRemovalRetainsOwnershipForRetry() {
Server server = mock(Server.class);
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
ItemStack result = mock(ItemStack.class);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
when(server.addRecipe(recipe)).thenReturn(true);
when(server.removeRecipe(key)).thenReturn(false, true);
DispenserRecipeRegistrar.RecipeFactory recipes =
mock(DispenserRecipeRegistrar.RecipeFactory.class);
when(recipes.create(any(), any())).thenReturn(recipe);
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
key, () -> result, recipes);
assertTrue(registrar.register(server));
assertThrows(IllegalStateException.class, registrar::unregister);
registrar.unregister();
verify(server, times(2)).removeRecipe(key);
}
@Test
void conflictingRecipeIsPreservedAndNotClaimed() {
Server server = mock(Server.class);
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
ItemStack result = mock(ItemStack.class);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
when(server.getRecipe(key)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
DispenserRecipeRegistrar.RecipeFactory recipes =
mock(DispenserRecipeRegistrar.RecipeFactory.class);
when(recipes.create(any(), any())).thenReturn(recipe);
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
key, () -> result, recipes);
assertFalse(registrar.register(server));
registrar.unregister();
verify(server, never()).addRecipe(any());
verify(server, never()).removeRecipe(any());
}
}
@@ -27,11 +27,11 @@ final class PluginFoundationTest {
}
@Test
void pluginEntrypointOwnsEnableDisableRecipeLifecycleWiring() throws Exception {
Field lifecycle = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycle");
void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception {
Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles");
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
assertEquals(RecipeLifecycle.class, lifecycle.getType());
assertEquals(RecipeLifecycleGroup.class, lifecycles.getType());
assertEquals(void.class, disable.getReturnType());
}
}
@@ -29,6 +29,10 @@ class PurpurDistributionTest {
"games/dmg/spigotinventoryhelper/RecipeDiscovery.class"));
assertNotNull(jar.getJarEntry(
"games/dmg/spigotinventoryhelper/RecipeLifecycle.class"));
assertNotNull(jar.getJarEntry(
"games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.class"));
assertNotNull(jar.getJarEntry(
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|| entry.getName().contains("NativeRuntime")));
@@ -42,6 +46,8 @@ class PurpurDistributionTest {
assertTrue(readme.contains("ServerPlaceRecipe"));
assertTrue(readme.contains("ServerRecipeBook"));
assertTrue(readme.contains("ResultSlot"));
assertTrue(readme.contains("Dropper + Tripwire Hook"));
assertTrue(readme.contains("Redstone category"));
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
}
@@ -2,6 +2,7 @@ package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
@@ -28,6 +29,21 @@ final class RecipeDiscoveryTest {
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
@Test
void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() {
Set<NamespacedKey> known = new LinkedHashSet<>();
Player player = player("Partial", known);
doAnswer(invocation -> 0).when(player).discoverRecipes(any());
List<LogRecord> warnings = new ArrayList<>();
RecipeDiscovery discovery = new RecipeDiscovery(
keys, "alternative Dispenser recipe", logger(warnings));
discovery.discover(player);
assertEquals(1, warnings.size());
assertTrue(warnings.getFirst().getMessage().contains("alternative Dispenser recipe"));
}
@Test
void onlineDiscoveryAwardsOnlyUnknownRecipesAndRepeatedDiscoveryIsIdempotent() {
Set<NamespacedKey> known = new LinkedHashSet<>();
@@ -0,0 +1,45 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
final class RecipeLifecycleGroupTest {
@Test
void cleanupFailureDoesNotPreventOtherRecipeCleanup() {
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
org.mockito.Mockito.doThrow(new IllegalStateException("cleanup failed"))
.when(dispenser).disable();
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
assertThrows(IllegalStateException.class, group::disableAll);
verify(dispenser).disable();
verify(sorting).disable();
}
@Test
void oneFailedRecipeLifecycleDoesNotBlockTheOther() {
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
when(sorting.enable()).thenReturn(true);
when(dispenser.enable()).thenReturn(false);
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
group.enableAll();
group.disableAll();
verify(sorting).enable();
verify(dispenser).enable();
InOrder disabled = inOrder(dispenser, sorting);
disabled.verify(dispenser).disable();
disabled.verify(sorting).disable();
}
}
@@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -31,6 +32,55 @@ final class RecipeLifecycleTest {
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
@Test
void failedRegistrationWithholdsDiscoveryUntilALaterRetrySucceeds() {
Server server = mock(Server.class);
Plugin plugin = mock(Plugin.class);
PluginManager events = mock(PluginManager.class);
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
when(recipes.register(server)).thenReturn(false, true);
when(recipes.recipeKeys()).thenReturn(List.of(key));
when(recipes.description()).thenReturn("alternative Dispenser recipe");
when(server.getPluginManager()).thenReturn(events);
Set<NamespacedKey> known = new LinkedHashSet<>();
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
RecipeLifecycle lifecycle = new RecipeLifecycle(
server, plugin, recipes, Logger.getAnonymousLogger());
assertFalse(lifecycle.enable());
assertTrue(known.isEmpty());
assertTrue(lifecycle.enable());
assertEquals(Set.of(key), known);
verify(recipes, times(2)).register(server);
verify(events).registerEvents(any(RecipeDiscovery.class),
org.mockito.ArgumentMatchers.eq(plugin));
}
@Test
void independentlyDiscoversARegisteredAlternativeRecipe() {
Server server = mock(Server.class);
Plugin plugin = mock(Plugin.class);
PluginManager events = mock(PluginManager.class);
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
when(recipes.register(server)).thenReturn(true);
when(recipes.recipeKeys()).thenReturn(List.of(key));
when(recipes.description()).thenReturn("alternative Dispenser recipe");
when(server.getPluginManager()).thenReturn(events);
Set<NamespacedKey> known = new LinkedHashSet<>();
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
RecipeLifecycle lifecycle = new RecipeLifecycle(
server, plugin, recipes, Logger.getAnonymousLogger());
assertTrue(lifecycle.enable());
assertEquals(Set.of(key), known);
verify(events).registerEvents(any(RecipeDiscovery.class),
org.mockito.ArgumentMatchers.eq(plugin));
}
@Test
void registrationExceptionCleansUpAndLeavesDiscoveryInactive() {
Server server = mock(Server.class);
@@ -58,6 +108,7 @@ final class RecipeLifecycleTest {
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
when(recipes.register(server)).thenReturn(true);
when(recipes.recipeKeys()).thenReturn(keys);
when(recipes.description()).thenReturn("Sorting Stick recipes");
when(server.getPluginManager()).thenReturn(events);
doReturn(List.of()).when(server).getOnlinePlayers();
RecipeLifecycle lifecycle = new RecipeLifecycle(
@@ -77,6 +128,7 @@ final class RecipeLifecycleTest {
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
when(recipes.register(server)).thenReturn(true);
when(recipes.recipeKeys()).thenReturn(keys);
when(recipes.description()).thenReturn("Sorting Stick recipes");
when(server.getPluginManager()).thenReturn(events);
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();