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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.LongSupplier;

Expand All @@ -19,7 +20,6 @@

import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;

/** Owns optional eligibility observations and bounded replay wake-ups, never reward payloads. */
public final class RewardEligibility {
Expand Down Expand Up @@ -94,8 +94,8 @@ public CompletionStage<Void> nativeSessionReady(Player player) {

/**
* Schedules an opted-in player action with explicit Folia retirement handling.
* Callers share their action claim with both terminal callbacks so a late
* task cannot execute after its unstarted work has been retained for retry.
* Dispatch and terminal callbacks share a claim so a late task cannot execute
* after its unstarted work has been retained for retry.
*/
public static void runPlayerTask(AdvancedCorePlugin plugin, Player player, Runnable action,
Runnable retired, Consumer<Throwable> rejected) {
Expand All @@ -105,14 +105,25 @@ public static void runPlayerTask(AdvancedCorePlugin plugin, Player player, Runna
scheduler.runTask(plugin, action, player);
return;
}
var result = folia.getImpl().runAtEntityWithFallback(player, ignored -> action.run(), retired);
AtomicBoolean claimed = new AtomicBoolean();
Runnable retireOnce = () -> {
if (claimed.compareAndSet(false, true)) retired.run();
};
Consumer<Throwable> rejectOnce = failure -> {
if (claimed.compareAndSet(false, true)) rejected.accept(failure);
};
// SimpleAPI relocates FoliaLib. Its enum names are stable across shading;
// the shaded and original enum classes are different Java types.
CompletionStage<? extends Enum<?>> result = folia.getScheduler().runAtEntityWithFallback(player,
ignored -> { if (claimed.compareAndSet(false, true)) action.run(); }, retireOnce);
if (result == null) throw new IllegalStateException("Entity scheduler returned no admission result");
result.whenComplete((status, failure) -> {
if (failure != null) rejected.accept(failure);
else if (status == EntityTaskResult.ENTITY_RETIRED || status == EntityTaskResult.SCHEDULER_RETIRED) {
retired.run();
} else if (status != EntityTaskResult.SUCCESS) {
rejected.accept(new IllegalStateException("Entity scheduler returned an unknown admission result"));
if (failure != null) rejectOnce.accept(failure);
else {
String name = status == null ? "" : status.name();
if (name.equals("ENTITY_RETIRED") || name.equals("SCHEDULER_RETIRED")) retireOnce.run();
else if (!name.equals("SUCCESS")) rejectOnce.accept(
new IllegalStateException("Entity scheduler returned an unknown admission result"));
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package com.bencodez.advancedcore.tests.api.rewards;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;

import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;

import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.advancedcore.api.rewards.RewardEligibility;
import com.bencodez.simpleapi.folialib.FoliaLib;
import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;

class PlayerTaskAdmissionTest {
private enum RelocatedResult { SUCCESS, ENTITY_RETIRED, SCHEDULER_RETIRED, UNEXPECTED }

@Test
void successDispatchesOnlyOnTheOwnerCallback() {
Fixture fixture = new Fixture();
fixture.submit();
verifyNoInteractions(fixture.action, fixture.retired, fixture.rejected);
fixture.owner.accept(null);
fixture.result.complete(EntityTaskResult.SUCCESS);
verify(fixture.action).run();
verifyNoInteractions(fixture.retired, fixture.rejected);
}

@Test
void fallbackAndFutureRetirementNotifyOnlyOnceAndPreventLateDispatch() {
Fixture fixture = new Fixture();
fixture.submit();
fixture.fallback.run();
fixture.result.complete(EntityTaskResult.ENTITY_RETIRED);
fixture.fallback.run();
fixture.owner.accept(null);
verify(fixture.retired).run();
verifyNoInteractions(fixture.action, fixture.rejected);
}

@Test
void immediateSchedulerRetirementDoesNotRequireTheFallback() {
Fixture fixture = new Fixture();
fixture.result.complete(EntityTaskResult.SCHEDULER_RETIRED);
fixture.submit();
fixture.fallback.run();
verify(fixture.retired).run();
verifyNoInteractions(fixture.action, fixture.rejected);
}

@Test
void actionClaimPreventsLateRetirementOrExceptionalRejection() {
Fixture fixture = new Fixture();
fixture.submit();
fixture.owner.accept(null);
fixture.owner.accept(null);
fixture.fallback.run();
fixture.result.completeExceptionally(new IllegalStateException("Synthetic late failure"));
verify(fixture.action).run();
verifyNoInteractions(fixture.retired, fixture.rejected);
}

@Test
void exceptionalCompletionRejectsOnceAndPreventsLateCallbacks() {
Fixture fixture = new Fixture();
RuntimeException failure = new IllegalStateException("Synthetic admission failure");
fixture.submit();
fixture.result.completeExceptionally(failure);
fixture.fallback.run();
fixture.owner.accept(null);
verify(fixture.rejected).accept(failure);
verifyNoInteractions(fixture.action, fixture.retired);
}

@Test
void nullStatusRejectsInsteadOfLosingThePendingAction() {
Fixture fixture = new Fixture();
fixture.submit();
fixture.result.complete(null);
verify(fixture.rejected).accept(isA(IllegalStateException.class));
verifyNoInteractions(fixture.action, fixture.retired);
}

@Test
void enumNamesRemainCompatibleAfterRelocationAndUnknownStatusRejects() {
for (RelocatedResult status : RelocatedResult.values()) {
Fixture fixture = new Fixture();
CompletableFuture<RelocatedResult> relocated = new CompletableFuture<>();
fixture.returnRelocated(relocated);
fixture.submit();
relocated.complete(status);
if (status == RelocatedResult.SUCCESS) {
fixture.owner.accept(null);
verify(fixture.action).run();
verifyNoInteractions(fixture.retired, fixture.rejected);
} else if (status == RelocatedResult.UNEXPECTED) {
verify(fixture.rejected).accept(isA(IllegalStateException.class));
verifyNoInteractions(fixture.action, fixture.retired);
} else {
verify(fixture.retired).run();
verifyNoInteractions(fixture.action, fixture.rejected);
}
}
}

@Test
void missingFutureAndSynchronousRejectionKeepTheCallerFailureContract() {
Fixture missing = new Fixture();
when(missing.platform.runAtEntityWithFallback(any(), any(), any())).thenReturn(null);
assertThrows(IllegalStateException.class, missing::submit);
Fixture rejected = new Fixture();
RejectedExecutionException failure = new RejectedExecutionException("Synthetic rejection");
when(rejected.platform.runAtEntityWithFallback(any(), any(), any())).thenThrow(failure);
assertSame(failure, assertThrows(RejectedExecutionException.class, rejected::submit));
verifyNoInteractions(rejected.action, rejected.retired, rejected.rejected);
}

@Test
void nonFoliaKeepsTheEstablishedSchedulerPath() {
Fixture fixture = new Fixture();
when(fixture.folia.isFolia()).thenReturn(false);
fixture.submit();
verify(fixture.scheduler).runTask(fixture.plugin, fixture.action, fixture.player);
verifyNoInteractions(fixture.platform, fixture.action, fixture.retired, fixture.rejected);
}

private static final class Fixture {
final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class);
final Player player = mock(Player.class);
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
final FoliaLib folia = mock(FoliaLib.class);
final PlatformScheduler platform = mock(PlatformScheduler.class);
final Runnable action = mock(Runnable.class);
final Runnable retired = mock(Runnable.class);
final Consumer<Throwable> rejected = mock();
final CompletableFuture<EntityTaskResult> result = new CompletableFuture<>();
Consumer<WrappedTask> owner;
Runnable fallback;

Fixture() {
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
when(scheduler.getFoliaLib()).thenReturn(folia);
when(folia.isFolia()).thenReturn(true);
when(folia.getImpl()).thenReturn(platform);
when(folia.getScheduler()).thenReturn(platform);
when(platform.runAtEntityWithFallback(any(), any(), any())).thenAnswer(call -> {
owner = call.getArgument(1);
fallback = call.getArgument(2);
return result;
});
}

void submit() {
RewardEligibility.runPlayerTask(plugin, player, action, retired, rejected);
}

// Emulate a dependency relocation at the scheduler boundary only. Production
// code must handle enum names without casting to a particular vendor enum.
void returnRelocated(CompletableFuture<RelocatedResult> relocated) {
when(platform.runAtEntityWithFallback(any(), any(), any())).thenAnswer(call -> {
owner = call.getArgument(1);
fallback = call.getArgument(2);
return relocated;
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.simpleapi.folialib.FoliaLib;
import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;

Expand All @@ -35,10 +35,11 @@ private enum Admission { NORMAL, IMMEDIATE_RETIREMENT, LATER_RETIREMENT, REJECTE
this.ownerQueue = ownerQueue;
this.retirementQueue = retirementQueue;
FoliaLib folia = mock(FoliaLib.class);
ServerImplementation implementation = mock(ServerImplementation.class);
PlatformScheduler implementation = mock(PlatformScheduler.class);
when(scheduler.getFoliaLib()).thenReturn(folia);
when(folia.isFolia()).thenReturn(true);
when(folia.getImpl()).thenReturn(implementation);
when(folia.getScheduler()).thenReturn(implementation);
// Exercise the real wrapper that discards runAtEntity's returned future.
doCallRealMethod().when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
when(implementation.runAtEntity(any(Entity.class), any())).thenAnswer(call ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.lang.reflect.ParameterizedType;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.jar.JarFile;

import org.bukkit.entity.Entity;

import org.junit.jupiter.api.Test;

/** Verifies the actual minimized artifact rather than Maven's test classpath. */
/** Verifies the actual shaded artifact rather than Maven's test classpath. */
public class PackagedArtifactTest {

@Test
Expand All @@ -31,6 +39,43 @@ void keepsRequiredClassesAndRelocations() throws Exception {
Files.size(artifactPath));
}

@Test
void packagedSchedulerUsesOneRelocatedEnumContract() throws Exception {
Path artifactPath = packagedJar();
String namespace = "com.bencodez.simpleapi.folialib.";
// Isolate FoliaLib from the dependency JAR on the test classpath. Bukkit
// stays in the parent loader; no server or live scheduler is started.
try (URLClassLoader loader = new URLClassLoader(new URL[] { artifactPath.toUri().toURL() },
getClass().getClassLoader()) {
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (!name.startsWith(namespace)) return super.loadClass(name, resolve);
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) loaded = findClass(name);
if (resolve) resolveClass(loaded);
return loaded;
}
}
}; JarFile artifact = new JarFile(artifactPath.toFile())) {
Class<?> folia = loader.loadClass(namespace + "FoliaLib");
Class<?> platform = loader.loadClass(namespace + "impl.PlatformScheduler");
Class<?> status = loader.loadClass(namespace + "enums.EntityTaskResult");
assertEquals(loader, folia.getClassLoader());
assertEquals(platform, folia.getMethod("getScheduler").getReturnType());
assertEquals(platform, folia.getMethod("getImpl").getReturnType());
var admission = platform.getMethod("runAtEntityWithFallback", Entity.class,
java.util.function.Consumer.class, Runnable.class);
ParameterizedType result = (ParameterizedType) admission.getGenericReturnType();
assertEquals(java.util.concurrent.CompletableFuture.class, result.getRawType());
assertEquals(status, result.getActualTypeArguments()[0]);
assertEquals(List.of("SUCCESS", "ENTITY_RETIRED", "SCHEDULER_RETIRED"),
Arrays.stream(status.getEnumConstants()).map(value -> ((Enum<?>) value).name()).toList());
assertTrue(artifact.stream().noneMatch(entry -> entry.getName().startsWith("com/tcoded/folialib/")),
"Original FoliaLib classes must not coexist with SimpleAPI's relocated classes");
}
}

private static Path packagedJar() {
String configured = System.getProperty("advancedcore.packagedJar");
assertNotNull(configured, "Run this test through the Maven package lifecycle");
Expand Down
Loading