fix(test): bound native fixture and acknowledgement lifetimes
Release / release (push) Failing after 15m44s

This commit is contained in:
dmg committed 2026-10-04 16:20:29 -04:00
1 parent 8b14eddf43
commit e54896cb38
3 files changed
+141 -9

No files matched your search

@@ -1630,6 +1630,9 @@ class FantasyStickLifecycleNativeTest {
HandlerList.unregisterAll(plugin); plugin.getLogger().removeHandler(logHandler);
loader.close(); configScope.close(); platform.close(); protocolScope.close();
}
// Only after successful shutdown/drain and scoped cleanup: Mockito's strong
// stubbing value can retain the fixture (and its own weak-key protocol mock).
org.mockito.Mockito.framework().clearInlineMock(protocol);
}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Approved US-011 verification enabler: dispose this fixture's mock, not global native/mock state. */
class FixtureDisposalNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@org.junit.jupiter.api.Test
void ownedCleanupRemainsPendingUntilRealCampOutboxWorkerFinishes()throws Exception{
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){
fixture.plugin.setEnabled(true);
var bridge=FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"campAuditEvents");
var outbox=FantasyStickLifecycleNativeTest.Fixture.field(bridge,"outbox");
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(outbox,"worker");
var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);
worker.execute(()->{entered.countDown();try{assertTrue(release.await(4,java.util.concurrent.TimeUnit.SECONDS));}
catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new AssertionError(interrupted);}});
assertTrue(entered.await(2,java.util.concurrent.TimeUnit.SECONDS));
try{
fixture.plugin.setEnabled(false);fixture.awaitWorkers();
assertFalse(ownedCleanup(fixture).toCompletableFuture().isDone(),
"A terminated authority worker is not acknowledgement of a still-running fixture-owned camp outbox");
}finally{release.countDown();ownedCleanup(fixture).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
}
static java.util.concurrent.CompletionStage<Void> ownedCleanup(FantasyStickLifecycleNativeTest.Fixture fixture){
var stages=new java.util.ArrayList<java.util.concurrent.CompletableFuture<?>>();
for(var field:fixture.plugin.getClass().getDeclaredFields()){
if(!field.getName().endsWith("Drain")||!java.util.concurrent.CompletionStage.class.isAssignableFrom(field.getType()))continue;
field.setAccessible(true);
try{
var stage=(java.util.concurrent.CompletionStage<?>)field.get(fixture.plugin);
assertNotNull(stage,"Existing fixture-owned drain must be initialized: "+field.getName());stages.add(stage.toCompletableFuture());
}catch(IllegalAccessException failure){throw new AssertionError("Could not capture existing fixture-owned drain",failure);}
}
assertFalse(stages.isEmpty(),"Actual plugin must expose its owned cleanup stages");
return java.util.concurrent.CompletableFuture.allOf(stages.toArray(java.util.concurrent.CompletableFuture<?>[]::new));
}
@ParameterizedTest @ValueSource(strings={"normal","ProtocolPresentation","presentation-timer"})
void disposedFixtureReleasesOnlyOwnedProtocolTrackingAfterRealCleanupAndPreservesUnrelatedMocks(String stage)throws Exception{
var unrelated=mock(java.util.concurrent.ExecutorService.class);when(unrelated.isShutdown()).thenReturn(false);
var fixture=new FantasyStickLifecycleNativeTest.Fixture(data);var owned=fixture.protocol;
try(fixture){
if(stage.equals("normal")){
fixture.plugin.setEnabled(true);assertTrue(fixture.plugin.isEnabled());
verify(owned).addPacketListener(fixture.packetListener);
assertDoesNotThrow(owned::getMinecraftVersion,"Live fixture mock tracking must remain usable for real native verification");
fixture.plugin.setEnabled(false);fixture.awaitWorkers();
}else{
fixture.failRegistrationAt=stage;assertThrows(IllegalStateException.class,()->fixture.plugin.setEnabled(true));
assertDoesNotThrow(owned::getMinecraftVersion,"Partial-enable cleanup verification must run before disposal");
}
verify(owned).removePacketListener(fixture.packetListener);
assertNotNull(mockingDetails(owned).getMockHandler().getInvocationContainer(),
"In-scope listener verification retains this fixture's invocation state until disposal");
assertFalse(java.util.Arrays.stream(org.bukkit.event.inventory.CraftItemEvent.getHandlerList().getRegisteredListeners()).anyMatch(listener->listener.getPlugin()==fixture.plugin));
assertFalse(unrelated.isShutdown());
ownedCleanup(fixture).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
}
assertTrue(mockingDetails(unrelated).isMock());assertFalse(unrelated.isShutdown());verify(unrelated,times(2)).isShutdown();
assertNotNull(mockingDetails(unrelated).getMockHandler().getInvocationContainer());
assertThrows(org.mockito.exceptions.misusing.DisabledMockException.class,
()->mockingDetails(owned).getMockHandler(),
"Disposed fixture-owned tracked handler must no longer expose the invocation/stubbing state retaining its closed plugin");
}
}
@@ -82,19 +82,19 @@ class MagicCoreEventsNativeTest {
rare.buy(a.player,shop.id(),market.current().toCompletableFuture().join().revision(),RareProduct.MAGIC_CORE,noStock::set);
ReskillEventsNativeTest.await(f,()->noStock.get()!=null);assertEquals(RareShop.Status.SOLD_OUT,noStock.get().status());
useCore(f,a,initial.add(BigInteger.ONE));
PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 STR]"));
openAcknowledgedBook(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 STR]"));
ReskillEventsNativeTest.await(f,()->current(f,a).stats().get(Stat.STR).equals(BigInteger.valueOf(13)));
useCore(f,a,initial.add(BigInteger.ONE));
PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","great_sword"));
openAcknowledgedBook(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","great_sword"));
ReskillEventsNativeTest.await(f,()->current(f,a).learned().contains("great_sword"));
useCore(f,a,initial.add(BigInteger.ONE));
PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","quicker_regeneration_i"));
openAcknowledgedBook(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","quicker_regeneration_i"));
ReskillEventsNativeTest.await(f,()->current(f,a).passives().contains("quicker_regeneration_i"));
useCore(f,a,initial.add(BigInteger.ONE));
buy(f,a,shop.id(),13);
ReskillEventsNativeTest.await(f,()->inventory.quantity(character,RareProduct.RESKILL).equals(BigInteger.ONE)
&&current(f,a).coins().signum()==0);
PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Use]","reskill"));
openAcknowledgedBook(f,a);PartyBookNativeTest.click(f,a,button(a,"[Use]","reskill"));
ReskillEventsNativeTest.await(f,()->PartyBookNativeTest.text(a).contains("Confirm Reskill"));
PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[Confirm Reskill]"));
ReskillEventsNativeTest.await(f,()->inventory.quantity(character,RareProduct.RESKILL).signum()==0
@@ -127,6 +127,32 @@ class MagicCoreEventsNativeTest {
"Restart retains the full global catalog; Core does not emit another refresh");
}
@Test void unacknowledgedCoreBookCannotReuseOldNativeButton()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=eligibleActor(f,BigInteger.valueOf(100));
var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");
inventory.add(a.core.record.id(),RareProduct.MAGIC_CORE,UUID.randomUUID()).join();
var old=coreBookAction(f,a);int presented=a.books.size();
var controller=FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"books");
var scheduler=controller.getClass().getDeclaredField("nextTick");scheduler.setAccessible(true);
Object original=scheduler.get(controller);var held=new java.util.concurrent.LinkedBlockingQueue<Runnable>();
try{
scheduler.set(controller,(java.util.function.Consumer<Runnable>)held::add);
assertThrows(AssertionError.class,()->coreBookAction(f,a),
"A requested-but-withheld native book is not acknowledged; never return the old displayed Core payload");
assertEquals(presented,a.books.size());assertFalse(held.isEmpty());
PartyBookNativeTest.click(f,a,old);PartyBookNativeTest.drain(f);
assertEquals(BigInteger.valueOf(100),current(f,a).unspentPoints());
assertEquals(BigInteger.ONE,inventory.quantity(a.core.record.id(),RareProduct.MAGIC_CORE));
}finally{
scheduler.set(controller,original);Runnable callback;while((callback=held.poll())!=null)callback.run();
}
useCore(f,a,BigInteger.valueOf(101));
assertEquals(BigInteger.ZERO,inventory.quantity(a.core.record.id(),RareProduct.MAGIC_CORE));
f.plugin.setEnabled(false);f.awaitWorkers();FixtureDisposalNativeTest.ownedCleanup(f).toCompletableFuture().get(5,TimeUnit.SECONDS);
}
}
@Test void failedPointSaveRefundsCoreAndThrowingAuditCannotDuplicateOrUndoCredit() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=eligibleActor(f,BigInteger.valueOf(100));
@@ -134,7 +160,7 @@ class MagicCoreEventsNativeTest {
inventory.add(a.core.record.id(),RareProduct.MAGIC_CORE,UUID.randomUUID()).join();
try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Use]","magic-core"));
assertTrue(lock.isValid());openAcknowledgedBook(f,a);PartyBookNativeTest.click(f,a,button(a,"[Use]","magic-core"));
ReskillEventsNativeTest.await(f,()->a.messages.stream().anyMatch(message->message.contains("Magic Core could not be acknowledged")));
assertEquals(BigInteger.valueOf(100),current(f,a).unspentPoints());
assertEquals(BigInteger.ONE,inventory.quantity(a.core.record.id(),RareProduct.MAGIC_CORE));assertTrue(credits().isEmpty());
@@ -168,16 +194,42 @@ class MagicCoreEventsNativeTest {
return sessions.currentRecord(sessions.active(a.core.id).orElseThrow()).orElseThrow();
}
private void useCore(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,BigInteger expected) throws Exception {
PartyBookNativeTest.open(f,a);var action=button(a,"[Use]","magic-core");PartyBookNativeTest.click(f,a,action);
ReskillEventsNativeTest.await(f,()->current(f,a).unspentPoints().equals(expected));
PartyBookNativeTest.click(f,a,action);
var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");
var before=current(f,a);var quantity=inventory.quantity(before.id(),RareProduct.MAGIC_CORE);int messages=a.messages.size();
var action=coreBookAction(f,a);PartyBookNativeTest.click(f,a,action);
awaitAcknowledgement(f,a,()->a.messages.subList(messages,a.messages.size()).stream().anyMatch(message->
message.startsWith("Used Magic Core")||message.startsWith("Magic Core use refused")||message.startsWith("Magic Core could not")),
"Core result callback");
assertTrue(a.messages.subList(messages,a.messages.size()).contains("Used Magic Core and gained 1 ordinary unspent point."),
"Fresh acknowledged Core action must report APPLIED, not a refused/stale/failed production outcome");
assertEquals(expected,current(f,a).unspentPoints());assertEquals(before.unspentPoints().add(BigInteger.ONE),expected);
assertEquals(quantity.subtract(BigInteger.ONE),inventory.quantity(before.id(),RareProduct.MAGIC_CORE));
var saved=new YamlCharacterRepository(data).load(before.id()).orElseThrow();assertEquals(expected,saved.unspentPoints());
assertEquals(before.experience(),saved.experience());assertEquals(before.level(),saved.level());
PartyBookNativeTest.click(f,a,action);PartyBookNativeTest.drain(f);
assertEquals(expected,current(f,a).unspentPoints());assertEquals(quantity.subtract(BigInteger.ONE),inventory.quantity(before.id(),RareProduct.MAGIC_CORE));
}
private static void openAcknowledgedBook(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{
int before=a.books.size();PartyBookNativeTest.open(f,a);
awaitAcknowledgement(f,a,()->a.books.size()>before,"new native book presentation");
}
private static void awaitAcknowledgement(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,
java.util.function.BooleanSupplier acknowledged,String phase)throws Exception{
long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);
while(!acknowledged.getAsBoolean()&&System.nanoTime()<deadline){var callback=f.tasks.poll(25,TimeUnit.MILLISECONDS);if(callback!=null)callback.run();}
assertTrue(acknowledged.getAsBoolean(),()->phase+" not acknowledged; books="+a.books.size()+", queued callbacks="+f.tasks.size()+", messages="+a.messages.size());
}
private static net.minecraft.network.chat.ClickEvent.Custom coreBookAction(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{
openAcknowledgedBook(f,a);return button(a,"[Use]","magic-core");
}
private static void buy(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,UUID shop,int slot) throws Exception {
var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");int size=a.inventories.size();
menus.open(a.player,shop);ReskillEventsNativeTest.await(f,()->a.inventories.size()>size);
var menu=a.inventories.getLast();var click=mock(InventoryClickEvent.class);
when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(a.player);when(click.getCurrentItem()).thenReturn(menu.getItem(slot));
menus.click(click);
int messages=a.messages.size();menus.click(click);
awaitAcknowledgement(f,a,()->a.messages.subList(messages,a.messages.size()).stream().anyMatch(message->message.startsWith("Purchased ")||message.startsWith("Rare purchase refused:")),"purchase result callback");
assertTrue(a.messages.subList(messages,a.messages.size()).stream().anyMatch(message->message.startsWith("Purchased ")),"Exact stock/debit assertions must follow an acknowledged purchase, not a refusal");
}
private static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String identity){
var content=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())