mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 05:42:58 +02:00
* test: add bounded awaitUntil helper for timing-sensitive tests (#109) One small class per module (core and app-desktop share no test sources): polls a condition every 10 ms and fails with a message when the timeout elapses, so tests no longer guess with fixed Thread.sleep pauses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(map): let TileFetcher take its nano clock as a test seam (#109) A package-private constructor accepts a LongSupplier for the monotonic clock the retry pause is measured against; the public constructors delegate with System::nanoTime, so production behaviour is unchanged. TileFetcherTest advances a fake clock instead of sleeping 20 ms three times and hoping the 1 ms pause has elapsed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(view): put CoalescingTrigger on a DelayTimer seam (#109) DelayTimer is a package-private one-shot timer (restart/stop) with a Factory; DelayTimer.SWING wraps javax.swing.Timer and is what the public constructor uses, so production behaviour is unchanged. A package-private constructor takes the factory. CoalescingTriggerTest fires a FakeDelayTimer itself: the negative checks (no second run, cancel prevents the run) are now deterministic and the four Thread.sleep calls are gone. One test keeps the real Swing timer end to end, waiting with awaitUntil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(view): put LayoutAutosave on the DelayTimer seam (#109) The public constructor still builds a javax.swing.Timer via DelayTimer.SWING with the same 750 ms delay; a package-private constructor takes the factory. LayoutAutosaveTest fires a FakeDelayTimer on the event thread itself, so "cancelled means not written" no longer rests on a 1.2 s sleep and the burst test no longer sleeps 300 ms to prove the absence of a second write. The foreign-thread test keeps the real Swing timer path with awaitUntil. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(controller): inject scheduler and clock into On4KstConnectionManager (#109) A package-private constructor takes the ScheduledExecutorService and a LongSupplier for wall-clock milliseconds; the existing constructor delegates with the same single-thread supervisor and System::currentTimeMillis, so production behaviour is unchanged. Session's idle clocks start from the injected clock instead of a field initializer. The static parseMessageTimestamp keeps the wall clock: it sanity-checks server timestamps and is not part of the supervisor's timing. On4KstConnectionProbeTest drives unansweredProbeUsesExistingReconnectFlow through a ManualScheduler (immediate work on a real thread, delays and the five-second tick released on demand) and a fake clock: 90 s of silence sends the CK probe, which the test reads off the socket, 210 s fails the session, and the released backoff opens the second connection. The reflection-based state setup is gone because the state is now reached the production way; the test takes well under a second instead of seven. The two ignored await(2 s) results in the server stubs are asserted now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: close the profile picker as soon as it shows instead of after 1.5 s (#109) The two display-bound picker tests disposed the modal dialog from a Swing timer after a fixed 1.5 s, which was long enough on a fast machine only and cost 3 s per run. The closer now waits with awaitUntil for the dialog to appear and disposes it on the event thread; the suite drops from 3.0 s to 0.26 s. Remaining fixed sleep: TopPriorityCandidatesWindowTest waits 400 ms for Compose's double-tap timeout; whether ImageComposeScene's time can advance it deterministically is still to be checked. Fixes #109 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2b0fdd5b4
commit
91863fee1f
14 files changed
+741
-159
No files matched your search
@@ -1,6 +1,5 @@
|
|||||||
package kst4contest.view;
|
package kst4contest.view;
|
||||||
|
|
||||||
import javax.swing.Timer;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,16 +13,24 @@ import java.util.Objects;
|
|||||||
*/
|
*/
|
||||||
public final class CoalescingTrigger {
|
public final class CoalescingTrigger {
|
||||||
|
|
||||||
private final Timer timer;
|
private final DelayTimer timer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param delayMs how long to wait after the last request
|
* @param delayMs how long to wait after the last request
|
||||||
* @param action runs on the user-interface thread once the delay elapses
|
* @param action runs on the user-interface thread once the delay elapses
|
||||||
*/
|
*/
|
||||||
public CoalescingTrigger(final int delayMs, final Runnable action) {
|
public CoalescingTrigger(final int delayMs, final Runnable action) {
|
||||||
|
this(delayMs, action, DelayTimer.SWING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the same trigger on a timer whose firing the caller controls.
|
||||||
|
*
|
||||||
|
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
|
||||||
|
*/
|
||||||
|
CoalescingTrigger(final int delayMs, final Runnable action, final DelayTimer.Factory timers) {
|
||||||
Objects.requireNonNull(action, "action");
|
Objects.requireNonNull(action, "action");
|
||||||
this.timer = new Timer(delayMs, event -> action.run());
|
this.timer = timers.create(delayMs, action);
|
||||||
this.timer.setRepeats(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests a run, restarting the delay. */
|
/** Requests a run, restarting the delay. */
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-shot delay that can be restarted and stopped.
|
||||||
|
*
|
||||||
|
* <p>The thin seam between the two coalescing classes in this package and the
|
||||||
|
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
|
||||||
|
* implementation; tests substitute one whose firing they control, so "nothing ran" can
|
||||||
|
* be asserted without waiting for a delay that might not have elapsed yet.</p>
|
||||||
|
*/
|
||||||
|
interface DelayTimer {
|
||||||
|
|
||||||
|
/** Starts the delay, or starts it over if it is already running. */
|
||||||
|
void restart();
|
||||||
|
|
||||||
|
/** Drops a pending run. Idempotent. */
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
/** Creates one-shot timers for a fixed delay and action. */
|
||||||
|
interface Factory {
|
||||||
|
/**
|
||||||
|
* @param delayMs how long the timer waits after a {@link #restart()}
|
||||||
|
* @param onFire what runs once the delay elapses
|
||||||
|
*/
|
||||||
|
DelayTimer create(int delayMs, Runnable onFire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
|
||||||
|
* thread where the user interface lives.
|
||||||
|
*/
|
||||||
|
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
|
||||||
|
|
||||||
|
/** {@code javax.swing.Timer} behind the interface. */
|
||||||
|
final class Swing implements DelayTimer {
|
||||||
|
|
||||||
|
private final Timer timer;
|
||||||
|
|
||||||
|
Swing(final int delayMs, final Runnable onFire) {
|
||||||
|
Objects.requireNonNull(onFire, "onFire");
|
||||||
|
this.timer = new Timer(delayMs, event -> onFire.run());
|
||||||
|
this.timer.setRepeats(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restart() {
|
||||||
|
timer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
timer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package kst4contest.view;
|
|||||||
|
|
||||||
import kst4contest.observe.UiDispatcher;
|
import kst4contest.observe.UiDispatcher;
|
||||||
|
|
||||||
import javax.swing.Timer;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +21,7 @@ public final class LayoutAutosave {
|
|||||||
|
|
||||||
private final Runnable writeLayout;
|
private final Runnable writeLayout;
|
||||||
private final UiDispatcher uiDispatcher;
|
private final UiDispatcher uiDispatcher;
|
||||||
private final Timer saveDelay;
|
private final DelayTimer saveDelay;
|
||||||
private boolean pending;
|
private boolean pending;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,16 +29,28 @@ public final class LayoutAutosave {
|
|||||||
* @param uiDispatcher confines the pending state to one thread
|
* @param uiDispatcher confines the pending state to one thread
|
||||||
*/
|
*/
|
||||||
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
|
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
|
||||||
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
|
|
||||||
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
|
|
||||||
/*
|
/*
|
||||||
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
|
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
|
||||||
* AWT event thread, which is where the user interface lives now, and restart()
|
* AWT event thread, which is where the user interface lives now, and restart()
|
||||||
* has the same meaning playFromStart() had. A PauseTransition would need the
|
* has the same meaning playFromStart() had. A PauseTransition would need the
|
||||||
* JavaFX toolkit to be running, which it no longer is.
|
* JavaFX toolkit to be running, which it no longer is.
|
||||||
*/
|
*/
|
||||||
this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending());
|
this(writeLayout, uiDispatcher, DelayTimer.SWING);
|
||||||
this.saveDelay.setRepeats(false);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the same debounce on a timer whose firing the caller controls.
|
||||||
|
*
|
||||||
|
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
|
||||||
|
*/
|
||||||
|
LayoutAutosave(
|
||||||
|
final Runnable writeLayout,
|
||||||
|
final UiDispatcher uiDispatcher,
|
||||||
|
final DelayTimer.Factory timers
|
||||||
|
) {
|
||||||
|
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
|
||||||
|
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
|
||||||
|
this.saveDelay = timers.create(SAVE_DELAY_MS, this::flushPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asks for a save, restarting the delay. Safe to call from any thread. */
|
/** Asks for a save, restarting the delay. Safe to call from any thread. */
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package kst4contest.testing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded polling for tests that wait on another thread.
|
||||||
|
*
|
||||||
|
* <p>Replaces fixed {@code Thread.sleep} pauses: a condition that is already true returns
|
||||||
|
* at once, and one that never becomes true fails with a message instead of letting the
|
||||||
|
* test pass or fail on the speed of the machine.</p>
|
||||||
|
*/
|
||||||
|
public final class Await {
|
||||||
|
|
||||||
|
private static final long POLL_INTERVAL_MS = 10L;
|
||||||
|
|
||||||
|
private Await() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, BooleanSupplier condition) {
|
||||||
|
awaitUntil(timeout, "condition not met within " + timeout, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param message what the failure should say when the timeout elapses
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, String message, BooleanSupplier condition) {
|
||||||
|
awaitUntil(timeout, () -> message, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param message builds the failure message at the moment of failure, so it can
|
||||||
|
* describe the state that was actually seen
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, Supplier<String> message, BooleanSupplier condition) {
|
||||||
|
long deadline = System.nanoTime() + timeout.toNanos();
|
||||||
|
while (true) {
|
||||||
|
if (condition.getAsBoolean()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
throw new AssertionError(message.get());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(POLL_INTERVAL_MS);
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError("interrupted while waiting: " + message.get(), interrupted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ package kst4contest.view;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.awt.EventQueue;
|
import java.awt.EventQueue;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static kst4contest.testing.Await.awaitUntil;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,85 +18,89 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
|
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
|
||||||
* wrong are invisible in a build: too eager and the table redraws on every message under
|
* wrong are invisible in a build: too eager and the table redraws on every message under
|
||||||
* contest load, never and the table stops updating altogether.</p>
|
* contest load, never and the table stops updating altogether.</p>
|
||||||
|
*
|
||||||
|
* <p>All but one test run on a {@link FakeDelayTimer} the test fires itself, so "it did
|
||||||
|
* not run" is a fact and not a 400 ms guess. The one exception goes through the real
|
||||||
|
* Swing timer so the default wiring stays covered.</p>
|
||||||
*/
|
*/
|
||||||
class CoalescingTriggerTest {
|
class CoalescingTriggerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aBurstOfTriggersProducesOneRun() throws Exception {
|
void aBurstOfTriggersProducesOneRun() {
|
||||||
AtomicInteger runs = new AtomicInteger();
|
AtomicInteger runs = new AtomicInteger();
|
||||||
CountDownLatch ran = new CountDownLatch(1);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
|
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
|
||||||
runs.incrementAndGet();
|
|
||||||
ran.countDown();
|
|
||||||
});
|
|
||||||
|
|
||||||
// What a busy chat looks like: many updates, one intended redraw.
|
// What a busy chat looks like: many updates, one intended redraw.
|
||||||
for (int i = 0; i < 25; i++) {
|
for (int i = 0; i < 25; i++) {
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
}
|
}
|
||||||
|
assertEquals(0, runs.get(), "nothing may run before the delay elapses");
|
||||||
|
|
||||||
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
|
timers.timer().fire();
|
||||||
Thread.sleep(300);
|
|
||||||
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
|
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
|
||||||
|
|
||||||
|
timers.timer().fire();
|
||||||
|
assertEquals(1, runs.get(), "the timer is one-shot; a burst must not queue a second run");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The real Swing timer, end to end: the action has to arrive on the event thread. */
|
||||||
@Test
|
@Test
|
||||||
void theActionRunsOnTheEventThread() throws Exception {
|
void theActionRunsOnTheEventThread() {
|
||||||
AtomicBoolean onEventThread = new AtomicBoolean();
|
AtomicBoolean onEventThread = new AtomicBoolean();
|
||||||
CountDownLatch ran = new CountDownLatch(1);
|
AtomicBoolean ran = new AtomicBoolean();
|
||||||
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
|
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
|
||||||
onEventThread.set(EventQueue.isDispatchThread());
|
onEventThread.set(EventQueue.isDispatchThread());
|
||||||
ran.countDown();
|
ran.set(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
|
|
||||||
assertTrue(ran.await(5, TimeUnit.SECONDS));
|
awaitUntil(Duration.ofSeconds(5), "the Swing timer never fired", ran::get);
|
||||||
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
|
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aLaterTriggerPostponesTheRun() throws Exception {
|
void aLaterTriggerPostponesTheRun() {
|
||||||
AtomicInteger runs = new AtomicInteger();
|
AtomicInteger runs = new AtomicInteger();
|
||||||
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
|
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet, timers);
|
||||||
|
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
Thread.sleep(150);
|
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
Thread.sleep(150);
|
|
||||||
|
|
||||||
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
|
assertEquals(2, timers.timer().restarts(),
|
||||||
assertEquals(0, runs.get(),
|
|
||||||
"a new trigger must restart the delay, not let the old one fire");
|
"a new trigger must restart the delay, not let the old one fire");
|
||||||
|
assertEquals(0, runs.get(), "nothing has elapsed yet, so nothing may have run");
|
||||||
|
assertEquals(250, timers.delayMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
|
void cancellingBeforeTheDelayElapsesSuppressesTheRun() {
|
||||||
AtomicInteger runs = new AtomicInteger();
|
AtomicInteger runs = new AtomicInteger();
|
||||||
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
|
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
|
||||||
|
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
trigger.cancel();
|
trigger.cancel();
|
||||||
Thread.sleep(400);
|
assertFalse(timers.timer().isArmed(), "cancel must stop the timer");
|
||||||
|
|
||||||
|
timers.timer().fire();
|
||||||
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
|
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggeringAgainAfterACancelStillWorks() throws Exception {
|
void triggeringAgainAfterACancelStillWorks() {
|
||||||
AtomicInteger runs = new AtomicInteger();
|
AtomicInteger runs = new AtomicInteger();
|
||||||
CountDownLatch ran = new CountDownLatch(1);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
|
CoalescingTrigger trigger = new CoalescingTrigger(80, runs::incrementAndGet, timers);
|
||||||
runs.incrementAndGet();
|
|
||||||
ran.countDown();
|
|
||||||
});
|
|
||||||
|
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
trigger.cancel();
|
trigger.cancel();
|
||||||
trigger.trigger();
|
trigger.trigger();
|
||||||
|
assertTrue(timers.timer().isArmed(), "a cancel must not disable the trigger for good");
|
||||||
|
|
||||||
assertTrue(ran.await(5, TimeUnit.SECONDS),
|
timers.timer().fire();
|
||||||
"a cancel must not disable the trigger for good");
|
|
||||||
assertEquals(1, runs.get());
|
assertEquals(1, runs.get());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link DelayTimer} that fires only when the test says so.
|
||||||
|
*
|
||||||
|
* <p>Models exactly what the Swing timer promises and nothing about wall time: a
|
||||||
|
* {@code restart()} arms it, a {@code stop()} disarms it, and {@link #fire()} runs the
|
||||||
|
* action once if it is armed. That turns "the run did not happen" from a wait-and-hope
|
||||||
|
* into an assertion.</p>
|
||||||
|
*/
|
||||||
|
final class FakeDelayTimer implements DelayTimer {
|
||||||
|
|
||||||
|
private final Runnable onFire;
|
||||||
|
private boolean armed;
|
||||||
|
private int restarts;
|
||||||
|
|
||||||
|
FakeDelayTimer(final Runnable onFire) {
|
||||||
|
this.onFire = onFire;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restart() {
|
||||||
|
armed = true;
|
||||||
|
restarts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
armed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the Swing timer does when the delay elapses: run once, then disarm. */
|
||||||
|
void fire() {
|
||||||
|
if (!armed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armed = false;
|
||||||
|
onFire.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isArmed() {
|
||||||
|
return armed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int restarts() {
|
||||||
|
return restarts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands out one fake timer and keeps it for the test to fire. */
|
||||||
|
static final class Factory implements DelayTimer.Factory {
|
||||||
|
|
||||||
|
private FakeDelayTimer created;
|
||||||
|
private int createdDelayMs;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DelayTimer create(final int delayMs, final Runnable onFire) {
|
||||||
|
created = new FakeDelayTimer(onFire);
|
||||||
|
createdDelayMs = delayMs;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeDelayTimer timer() {
|
||||||
|
assertNotNull(created, "the class under test did not create its timer");
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
int delayMs() {
|
||||||
|
return createdDelayMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import kst4contest.observe.UiDispatcher;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.awt.EventQueue;
|
import java.awt.EventQueue;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static kst4contest.testing.Await.awaitUntil;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,59 +19,72 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
|
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
|
||||||
* silent: a timer that never fires means the operator's column widths are simply not
|
* silent: a timer that never fires means the operator's column widths are simply not
|
||||||
* saved, with nothing to see until the next start.</p>
|
* saved, with nothing to see until the next start.</p>
|
||||||
|
*
|
||||||
|
* <p>The negative checks run on a {@link FakeDelayTimer} the test fires itself, on the
|
||||||
|
* event thread as the Swing timer would, so "nothing was written" is a fact rather than a
|
||||||
|
* 1.2 s guess. The first test goes through the real Swing timer so the default wiring
|
||||||
|
* stays covered.</p>
|
||||||
*/
|
*/
|
||||||
class LayoutAutosaveTest {
|
class LayoutAutosaveTest {
|
||||||
|
|
||||||
private final UiDispatcher dispatcher = new AwtUiDispatcher();
|
private final UiDispatcher dispatcher = new AwtUiDispatcher();
|
||||||
|
|
||||||
|
/** The real Swing timer, end to end, from a thread that is not the event thread. */
|
||||||
@Test
|
@Test
|
||||||
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
|
void aRequestFromAForeignThreadEventuallyWrites() {
|
||||||
CountDownLatch written = new CountDownLatch(1);
|
AtomicBoolean written = new AtomicBoolean();
|
||||||
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
|
LayoutAutosave autosave = new LayoutAutosave(() -> written.set(true), dispatcher);
|
||||||
|
|
||||||
autosave.requestSave();
|
autosave.requestSave();
|
||||||
|
|
||||||
assertTrue(written.await(5, TimeUnit.SECONDS),
|
awaitUntil(Duration.ofSeconds(5),
|
||||||
"a save requested off the user-interface thread must still happen");
|
"a save requested off the user-interface thread must still happen",
|
||||||
|
written::get);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
|
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
|
||||||
AtomicInteger writes = new AtomicInteger();
|
AtomicInteger writes = new AtomicInteger();
|
||||||
CountDownLatch written = new CountDownLatch(1);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
LayoutAutosave autosave = new LayoutAutosave(() -> {
|
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
|
||||||
writes.incrementAndGet();
|
|
||||||
written.countDown();
|
|
||||||
}, dispatcher);
|
|
||||||
|
|
||||||
// What dragging a column divider looks like: many events, one intended save.
|
// What dragging a column divider looks like: many events, one intended save.
|
||||||
for (int i = 0; i < 20; i++) {
|
EventQueue.invokeAndWait(() -> {
|
||||||
autosave.requestSave();
|
for (int i = 0; i < 20; i++) {
|
||||||
}
|
autosave.requestSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals(20, timers.timer().restarts(), "every request must restart the delay");
|
||||||
|
assertEquals(0, writes.get(), "nothing may be written before the delay elapses");
|
||||||
|
|
||||||
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
|
EventQueue.invokeAndWait(timers.timer()::fire);
|
||||||
Thread.sleep(300);
|
|
||||||
assertEquals(1, writes.get(), "a burst must collapse into a single write");
|
assertEquals(1, writes.get(), "a burst must collapse into a single write");
|
||||||
|
|
||||||
|
EventQueue.invokeAndWait(timers.timer()::fire);
|
||||||
|
assertEquals(1, writes.get(), "the timer is one-shot; a burst must not queue a second write");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
|
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
|
||||||
AtomicInteger writes = new AtomicInteger();
|
AtomicInteger writes = new AtomicInteger();
|
||||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
|
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
|
||||||
|
|
||||||
EventQueue.invokeAndWait(() -> {
|
EventQueue.invokeAndWait(() -> {
|
||||||
autosave.requestSave();
|
autosave.requestSave();
|
||||||
autosave.cancelPending();
|
autosave.cancelPending();
|
||||||
});
|
});
|
||||||
|
assertFalse(timers.timer().isArmed(), "cancelling must stop the timer");
|
||||||
|
|
||||||
Thread.sleep(1_200);
|
EventQueue.invokeAndWait(timers.timer()::fire);
|
||||||
assertEquals(0, writes.get(), "a cancelled save must not be written");
|
assertEquals(0, writes.get(), "a cancelled save must not be written");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
|
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
|
||||||
AtomicInteger writes = new AtomicInteger();
|
AtomicInteger writes = new AtomicInteger();
|
||||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
|
||||||
|
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
|
||||||
|
|
||||||
EventQueue.invokeAndWait(() -> {
|
EventQueue.invokeAndWait(() -> {
|
||||||
autosave.requestSave();
|
autosave.requestSave();
|
||||||
@@ -79,5 +94,7 @@ class LayoutAutosaveTest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
|
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
|
||||||
|
assertFalse(timers.timer().isArmed(), "a flush must also stop the pending timer");
|
||||||
|
assertTrue(timers.timer().restarts() > 0, "the request must have armed the timer first");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+30
-15
@@ -1,6 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.model.OperatorProfile
|
import kst4contest.model.OperatorProfile
|
||||||
|
import kst4contest.testing.Await.awaitUntil
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Assumptions.assumeFalse
|
import org.junit.jupiter.api.Assumptions.assumeFalse
|
||||||
@@ -8,9 +9,10 @@ import org.junit.jupiter.api.Test
|
|||||||
import java.awt.EventQueue
|
import java.awt.EventQueue
|
||||||
import java.awt.GraphicsEnvironment
|
import java.awt.GraphicsEnvironment
|
||||||
import java.awt.Window
|
import java.awt.Window
|
||||||
|
import java.time.Duration
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Future
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.swing.Timer
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The picker must be openable from the user-interface thread.
|
* The picker must be openable from the user-interface thread.
|
||||||
@@ -29,31 +31,45 @@ class OperatorProfilePickerWindowTest {
|
|||||||
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
|
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Closes whatever window the picker opened, the way the operator's X would. */
|
/** The picker's dialog, once it is on screen, or null while it is not. */
|
||||||
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
|
private fun showingPickerDialog(): Window? =
|
||||||
Timer(delayMs) {
|
Window.getWindows().firstOrNull { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
|
||||||
Window.getWindows()
|
|
||||||
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
|
/**
|
||||||
.forEach { it.dispose() }
|
* Closes the picker the way the operator's X would, as soon as it is showing.
|
||||||
}.apply { isRepeats = true }
|
*
|
||||||
|
* Runs on its own thread because both callers block the test thread: one inside
|
||||||
|
* `invokeAndWait`, the other inside the modal `showAndSelect` itself. The dispose is
|
||||||
|
* handed to the event thread, which a modal dialog keeps pumping. Waiting for the dialog
|
||||||
|
* to appear replaces a fixed 1.5 s timer that was long enough on a fast machine only.
|
||||||
|
*/
|
||||||
|
private fun disposePickerOnceShowing(): Future<*> =
|
||||||
|
closer.submit {
|
||||||
|
awaitUntil(Duration.ofSeconds(10), "the picker's dialog never appeared") {
|
||||||
|
showingPickerDialog() != null
|
||||||
|
}
|
||||||
|
EventQueue.invokeLater { showingPickerDialog()?.dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val closer = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun opensAndReturnsWhenCalledOnTheEventThread() {
|
fun opensAndReturnsWhenCalledOnTheEventThread() {
|
||||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||||
|
|
||||||
val closer = disposeOpenWindowsAfter(1_500)
|
|
||||||
val worker = Executors.newSingleThreadExecutor()
|
val worker = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
closer.start()
|
val closing = disposePickerOnceShowing()
|
||||||
// invokeAndWait so the call really happens ON the event thread, as the menu does.
|
// invokeAndWait so the call really happens ON the event thread, as the menu does.
|
||||||
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
|
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
|
||||||
|
|
||||||
// The deadlock had no timeout of its own: without a fix this never completes.
|
// The deadlock had no timeout of its own: without a fix this never completes.
|
||||||
call.get(20, TimeUnit.SECONDS)
|
call.get(20, TimeUnit.SECONDS)
|
||||||
|
closing.get(1, TimeUnit.SECONDS)
|
||||||
} finally {
|
} finally {
|
||||||
closer.stop()
|
|
||||||
worker.shutdownNow()
|
worker.shutdownNow()
|
||||||
|
closer.shutdownNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +77,16 @@ class OperatorProfilePickerWindowTest {
|
|||||||
fun opensAndReturnsWhenCalledOffTheEventThread() {
|
fun opensAndReturnsWhenCalledOffTheEventThread() {
|
||||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||||
|
|
||||||
val closer = disposeOpenWindowsAfter(1_500)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
closer.start()
|
val closing = disposePickerOnceShowing()
|
||||||
// The startup path: main() resolves the profile before any window exists.
|
// The startup path: main() resolves the profile before any window exists.
|
||||||
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
|
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
|
||||||
|
|
||||||
// Dismissed rather than confirmed, so the caller is told to quit.
|
// Dismissed rather than confirmed, so the caller is told to quit.
|
||||||
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
|
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
|
||||||
|
closing.get(1, TimeUnit.SECONDS)
|
||||||
} finally {
|
} finally {
|
||||||
closer.stop()
|
closer.shutdownNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.util.concurrent.ScheduledExecutorService;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ final class On4KstConnectionManager {
|
|||||||
|
|
||||||
private final ChatController controller;
|
private final ChatController controller;
|
||||||
private final ScheduledExecutorService scheduler;
|
private final ScheduledExecutorService scheduler;
|
||||||
|
private final LongSupplier millisClock;
|
||||||
private final AtomicLong generation = new AtomicLong();
|
private final AtomicLong generation = new AtomicLong();
|
||||||
private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
|
private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
|
||||||
|
|
||||||
@@ -64,12 +66,35 @@ final class On4KstConnectionManager {
|
|||||||
private int reconnectAttempt;
|
private int reconnectAttempt;
|
||||||
|
|
||||||
On4KstConnectionManager(ChatController controller) {
|
On4KstConnectionManager(ChatController controller) {
|
||||||
|
this(controller,
|
||||||
|
Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}),
|
||||||
|
System::currentTimeMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the supervisor executor and the clock the idle and handshake timeouts
|
||||||
|
* are measured against.
|
||||||
|
*
|
||||||
|
* <p>Everything the manager schedules, including the five-second session monitor,
|
||||||
|
* goes through {@code scheduler}, so a test can run the tick and the reconnect
|
||||||
|
* backoff on demand instead of waiting for them.</p>
|
||||||
|
*
|
||||||
|
* @param scheduler runs all supervisor work; single-threaded in production
|
||||||
|
* @param millisClock wall-clock milliseconds, {@link System#currentTimeMillis()}
|
||||||
|
* in production
|
||||||
|
*/
|
||||||
|
On4KstConnectionManager(
|
||||||
|
ChatController controller,
|
||||||
|
ScheduledExecutorService scheduler,
|
||||||
|
LongSupplier millisClock
|
||||||
|
) {
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
this.scheduler = scheduler;
|
||||||
Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
|
this.millisClock = millisClock;
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
this.scheduler.scheduleAtFixedRate(
|
this.scheduler.scheduleAtFixedRate(
|
||||||
this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
|
this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
|
||||||
LOGGER.fine("ON4KST connection supervisor initialized");
|
LOGGER.fine("ON4KST connection supervisor initialized");
|
||||||
@@ -188,7 +213,7 @@ final class On4KstConnectionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
long now = millisClock.getAsLong();
|
||||||
session.lastInboundMillis.set(now);
|
session.lastInboundMillis.set(now);
|
||||||
session.lastProgressMillis.set(now);
|
session.lastProgressMillis.set(now);
|
||||||
|
|
||||||
@@ -236,7 +261,7 @@ final class On4KstConnectionManager {
|
|||||||
session.initialMembers
|
session.initialMembers
|
||||||
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
|
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
|
||||||
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
|
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
|
||||||
session.lastProgressMillis.set(System.currentTimeMillis());
|
session.lastProgressMillis.set(millisClock.getAsLong());
|
||||||
}
|
}
|
||||||
|
|
||||||
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
|
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
|
||||||
@@ -277,7 +302,8 @@ final class On4KstConnectionManager {
|
|||||||
token,
|
token,
|
||||||
socket,
|
socket,
|
||||||
receiveQueue,
|
receiveQueue,
|
||||||
transmitQueue);
|
transmitQueue,
|
||||||
|
millisClock.getAsLong());
|
||||||
|
|
||||||
MessageHistoryRecorder historyRecorder =
|
MessageHistoryRecorder historyRecorder =
|
||||||
controller.getMessageHistoryRecorder();
|
controller.getMessageHistoryRecorder();
|
||||||
@@ -360,7 +386,7 @@ final class On4KstConnectionManager {
|
|||||||
historyFrom);
|
historyFrom);
|
||||||
|
|
||||||
session.loginSent = true;
|
session.loginSent = true;
|
||||||
session.lastProgressMillis.set(System.currentTimeMillis());
|
session.lastProgressMillis.set(millisClock.getAsLong());
|
||||||
LOGGER.log(Level.INFO,
|
LOGGER.log(Level.INFO,
|
||||||
"Sending ON4KST login for session {0}, main category {1}",
|
"Sending ON4KST login for session {0}, main category {1}",
|
||||||
new Object[] {sessionId, mainCategory});
|
new Object[] {sessionId, mainCategory});
|
||||||
@@ -392,7 +418,7 @@ final class On4KstConnectionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.authenticated = true;
|
session.authenticated = true;
|
||||||
session.lastProgressMillis.set(System.currentTimeMillis());
|
session.lastProgressMillis.set(millisClock.getAsLong());
|
||||||
LOGGER.log(Level.INFO,
|
LOGGER.log(Level.INFO,
|
||||||
"ON4KST login accepted for session {0}", sessionId);
|
"ON4KST login accepted for session {0}", sessionId);
|
||||||
|
|
||||||
@@ -528,7 +554,7 @@ final class On4KstConnectionManager {
|
|||||||
}
|
}
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
session.online = true;
|
session.online = true;
|
||||||
session.lastProgressMillis.set(System.currentTimeMillis());
|
session.lastProgressMillis.set(millisClock.getAsLong());
|
||||||
LOGGER.log(Level.INFO,
|
LOGGER.log(Level.INFO,
|
||||||
"ON4KST session {0} is authenticated and synchronized",
|
"ON4KST session {0} is authenticated and synchronized",
|
||||||
session.id);
|
session.id);
|
||||||
@@ -668,7 +694,7 @@ final class On4KstConnectionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
long now = millisClock.getAsLong();
|
||||||
if (!session.online
|
if (!session.online
|
||||||
&& now - session.lastProgressMillis.get()
|
&& now - session.lastProgressMillis.get()
|
||||||
> HANDSHAKE_TIMEOUT_MILLIS) {
|
> HANDSHAKE_TIMEOUT_MILLIS) {
|
||||||
@@ -890,11 +916,8 @@ final class On4KstConnectionManager {
|
|||||||
private final Socket socket;
|
private final Socket socket;
|
||||||
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
|
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
|
||||||
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
|
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
|
||||||
private final long connectedMillis = System.currentTimeMillis();
|
private final AtomicLong lastInboundMillis;
|
||||||
private final AtomicLong lastInboundMillis =
|
private final AtomicLong lastProgressMillis;
|
||||||
new AtomicLong(connectedMillis);
|
|
||||||
private final AtomicLong lastProgressMillis =
|
|
||||||
new AtomicLong(connectedMillis);
|
|
||||||
private final ClientLivenessProbeState clientLivenessProbe =
|
private final ClientLivenessProbeState clientLivenessProbe =
|
||||||
new ClientLivenessProbeState();
|
new ClientLivenessProbeState();
|
||||||
private final Map<Integer, Map<String, ChatMember>> initialMembers =
|
private final Map<Integer, Map<String, ChatMember>> initialMembers =
|
||||||
@@ -910,16 +933,23 @@ final class On4KstConnectionManager {
|
|||||||
private volatile boolean online;
|
private volatile boolean online;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param connectedMillis the manager's clock at connect time; both idle clocks
|
||||||
|
* start from it
|
||||||
|
*/
|
||||||
private Session(
|
private Session(
|
||||||
long id,
|
long id,
|
||||||
Socket socket,
|
Socket socket,
|
||||||
LinkedBlockingQueue<ChatMessage> receiveQueue,
|
LinkedBlockingQueue<ChatMessage> receiveQueue,
|
||||||
LinkedBlockingQueue<ChatMessage> transmitQueue
|
LinkedBlockingQueue<ChatMessage> transmitQueue,
|
||||||
|
long connectedMillis
|
||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.receiveQueue = receiveQueue;
|
this.receiveQueue = receiveQueue;
|
||||||
this.transmitQueue = transmitQueue;
|
this.transmitQueue = transmitQueue;
|
||||||
|
this.lastInboundMillis = new AtomicLong(connectedMillis);
|
||||||
|
this.lastProgressMillis = new AtomicLong(connectedMillis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supplies map tiles, from memory, from disk, or from OpenStreetMap, in that order.
|
* Supplies map tiles, from memory, from disk, or from OpenStreetMap, in that order.
|
||||||
@@ -81,6 +82,9 @@ public final class TileFetcher {
|
|||||||
*/
|
*/
|
||||||
private final Duration retryPause;
|
private final Duration retryPause;
|
||||||
|
|
||||||
|
/** Monotonic clock the retry pause is measured against. */
|
||||||
|
private final LongSupplier nanoClock;
|
||||||
|
|
||||||
/** When each recently failed tile may be asked for again. */
|
/** When each recently failed tile may be asked for again. */
|
||||||
private final Map<String, Long> retryNotBeforeNanos = new java.util.concurrent.ConcurrentHashMap<>();
|
private final Map<String, Long> retryNotBeforeNanos = new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -97,9 +101,19 @@ public final class TileFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public TileFetcher(Path cacheDirectory, TileSource source, Duration retryPause) {
|
public TileFetcher(Path cacheDirectory, TileSource source, Duration retryPause) {
|
||||||
|
this(cacheDirectory, source, retryPause, System::nanoTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam: the clock the retry pause is measured against.
|
||||||
|
*
|
||||||
|
* @param nanoClock monotonic time in nanoseconds, {@link System#nanoTime()} in production
|
||||||
|
*/
|
||||||
|
TileFetcher(Path cacheDirectory, TileSource source, Duration retryPause, LongSupplier nanoClock) {
|
||||||
this.cacheDirectory = cacheDirectory;
|
this.cacheDirectory = cacheDirectory;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.retryPause = retryPause;
|
this.retryPause = retryPause;
|
||||||
|
this.nanoClock = nanoClock;
|
||||||
this.executor = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS, runnable -> {
|
this.executor = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS, runnable -> {
|
||||||
Thread thread = new Thread(runnable, "map-tile-fetch");
|
Thread thread = new Thread(runnable, "map-tile-fetch");
|
||||||
thread.setDaemon(true);
|
thread.setDaemon(true);
|
||||||
@@ -150,7 +164,7 @@ public final class TileFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Long notBefore = retryNotBeforeNanos.get(key);
|
Long notBefore = retryNotBeforeNanos.get(key);
|
||||||
if (notBefore != null && System.nanoTime() < notBefore) {
|
if (notBefore != null && nanoClock.getAsLong() < notBefore) {
|
||||||
/* Still inside the pause after a failure; do not ask again yet. */
|
/* Still inside the pause after a failure; do not ask again yet. */
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -236,7 +250,7 @@ public final class TileFetcher {
|
|||||||
|
|
||||||
private void recordFailure(String key) {
|
private void recordFailure(String key) {
|
||||||
failedRequests.incrementAndGet();
|
failedRequests.incrementAndGet();
|
||||||
retryNotBeforeNanos.put(key, System.nanoTime() + retryPause.toNanos());
|
retryNotBeforeNanos.put(key, nanoClock.getAsLong() + retryPause.toNanos());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void recordArrival() {
|
private void recordArrival() {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package kst4contest.controller;
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static kst4contest.testing.Await.awaitUntil;
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -25,6 +27,7 @@ import org.junit.jupiter.api.Timeout;
|
|||||||
import kst4contest.model.ChatCategory;
|
import kst4contest.model.ChatCategory;
|
||||||
import kst4contest.model.ChatMessage;
|
import kst4contest.model.ChatMessage;
|
||||||
import kst4contest.model.ChatPreferences;
|
import kst4contest.model.ChatPreferences;
|
||||||
|
import kst4contest.testing.ManualScheduler;
|
||||||
|
|
||||||
class On4KstConnectionProbeTest {
|
class On4KstConnectionProbeTest {
|
||||||
|
|
||||||
@@ -143,7 +146,8 @@ class On4KstConnectionProbeTest {
|
|||||||
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
|
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
|
||||||
out.write("OK|\r\n");
|
out.write("OK|\r\n");
|
||||||
out.flush();
|
out.flush();
|
||||||
releaseChatFrame.await(2, TimeUnit.SECONDS);
|
assertTrue(releaseChatFrame.await(2, TimeUnit.SECONDS),
|
||||||
|
"the test never released the chat frame");
|
||||||
out.write(chatFrame + "\r\n");
|
out.write(chatFrame + "\r\n");
|
||||||
out.flush();
|
out.flush();
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
@@ -191,7 +195,8 @@ class On4KstConnectionProbeTest {
|
|||||||
try (Socket accepted = server.accept();
|
try (Socket accepted = server.accept();
|
||||||
OutputStreamWriter out = new OutputStreamWriter(
|
OutputStreamWriter out = new OutputStreamWriter(
|
||||||
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
|
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
|
||||||
releaseOldResponse.await(2, TimeUnit.SECONDS);
|
assertTrue(releaseOldResponse.await(2, TimeUnit.SECONDS),
|
||||||
|
"the test never released the old session's response");
|
||||||
out.write("OK\r\n");
|
out.write("OK\r\n");
|
||||||
out.flush();
|
out.flush();
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
@@ -223,6 +228,16 @@ class On4KstConnectionProbeTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole idle flow against a server that never answers: the CK probe goes out
|
||||||
|
* after 90 s of silence, the 210 s stale timeout fails the session while the probe
|
||||||
|
* is still outstanding, and the reconnect backoff opens a second connection.
|
||||||
|
*
|
||||||
|
* <p>Time and the supervisor's schedule are the test's: the clock is advanced and the
|
||||||
|
* five-second monitor tick and the backoff are run on demand, so the test takes
|
||||||
|
* milliseconds and does not depend on the machine's speed. The session state itself
|
||||||
|
* is reached the way production reaches it, not set up by reflection.</p>
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
@Timeout(15)
|
@Timeout(15)
|
||||||
void unansweredProbeUsesExistingReconnectFlow() throws Exception {
|
void unansweredProbeUsesExistingReconnectFlow() throws Exception {
|
||||||
@@ -236,39 +251,93 @@ class On4KstConnectionProbeTest {
|
|||||||
org.mockito.Mockito.when(controller.getChatCategorySecondChat())
|
org.mockito.Mockito.when(controller.getChatCategorySecondChat())
|
||||||
.thenReturn(preferences.getLoginChatCategorySecond());
|
.thenReturn(preferences.getLoginChatCategorySecond());
|
||||||
|
|
||||||
|
AtomicLong clock = new AtomicLong(1_700_000_000_000L);
|
||||||
|
ManualScheduler scheduler = new ManualScheduler();
|
||||||
|
|
||||||
try (ServerSocket server = new ServerSocket(0)) {
|
try (ServerSocket server = new ServerSocket(0)) {
|
||||||
preferences.setStn_on4kstServersPort(server.getLocalPort());
|
preferences.setStn_on4kstServersPort(server.getLocalPort());
|
||||||
CompletableFuture<Integer> acceptedConnections =
|
CompletableFuture<Socket> firstConnection =
|
||||||
CompletableFuture.supplyAsync(() -> {
|
CompletableFuture.supplyAsync(() -> accept(server));
|
||||||
try (Socket first = server.accept();
|
|
||||||
Socket second = server.accept()) {
|
|
||||||
return 2;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
throw new RuntimeException(exception);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
On4KstConnectionManager manager =
|
On4KstConnectionManager manager = new On4KstConnectionManager(
|
||||||
new On4KstConnectionManager(controller);
|
controller, scheduler, clock::get);
|
||||||
try {
|
try {
|
||||||
manager.start();
|
manager.start();
|
||||||
awaitState(manager, On4KstConnectionState.AUTHENTICATING);
|
try (Socket first = firstConnection.get(2, TimeUnit.SECONDS)) {
|
||||||
manager.onLogstat(1L, new String[] {"LOGSTAT", "100"});
|
first.setSoTimeout(2_000);
|
||||||
awaitState(manager, On4KstConnectionState.SYNCING_MAIN_CHAT);
|
awaitState(manager,
|
||||||
manager.onInitialUserListCompleted(1L,
|
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT);
|
||||||
preferences.getLoginChatCategoryMain());
|
|
||||||
awaitState(manager, On4KstConnectionState.ONLINE);
|
|
||||||
|
|
||||||
setTimedOutProbe(manager, System.currentTimeMillis());
|
// The server stays silent, so the login goes out on the fallback delay.
|
||||||
|
awaitUntil(Duration.ofSeconds(2),
|
||||||
|
"the login fallback was never scheduled",
|
||||||
|
scheduler::hasDelayedTasks);
|
||||||
|
scheduler.runDelayedTasks();
|
||||||
|
assertEquals(On4KstConnectionState.AUTHENTICATING,
|
||||||
|
manager.getState());
|
||||||
|
|
||||||
awaitState(manager, On4KstConnectionState.RECONNECT_WAIT);
|
manager.onLogstat(1L, new String[] {"LOGSTAT", "100"});
|
||||||
assertEquals(2, acceptedConnections.get(7, TimeUnit.SECONDS));
|
awaitState(manager, On4KstConnectionState.SYNCING_MAIN_CHAT);
|
||||||
|
manager.onInitialUserListCompleted(1L,
|
||||||
|
preferences.getLoginChatCategoryMain());
|
||||||
|
awaitState(manager, On4KstConnectionState.ONLINE);
|
||||||
|
|
||||||
|
// 90 s of silence: the monitor sends the client liveness probe.
|
||||||
|
clock.addAndGet(
|
||||||
|
On4KstConnectionManager.CLIENT_LIVENESS_PROBE_AFTER_MILLIS + 1L);
|
||||||
|
scheduler.runPeriodicTask();
|
||||||
|
assertTrue(readUntil(first, "CK|\r\n").contains("CK|\r\n"),
|
||||||
|
"the probe must have been written to the socket");
|
||||||
|
assertEquals(On4KstConnectionState.ONLINE, manager.getState(),
|
||||||
|
"an outstanding probe alone is not a failure");
|
||||||
|
|
||||||
|
// 210 s of silence with the probe unanswered: the session is failed.
|
||||||
|
CompletableFuture<Socket> secondConnection =
|
||||||
|
CompletableFuture.supplyAsync(() -> accept(server));
|
||||||
|
clock.addAndGet(
|
||||||
|
On4KstConnectionManager.INBOUND_STALE_AFTER_MILLIS
|
||||||
|
- On4KstConnectionManager.CLIENT_LIVENESS_PROBE_AFTER_MILLIS);
|
||||||
|
scheduler.runPeriodicTask();
|
||||||
|
assertEquals(On4KstConnectionState.RECONNECT_WAIT, manager.getState());
|
||||||
|
assertTrue(scheduler.hasDelayedTasks(), "a reconnect must be scheduled");
|
||||||
|
|
||||||
|
// The backoff elapses: the existing reconnect flow opens a new socket.
|
||||||
|
scheduler.runDelayedTasks();
|
||||||
|
try (Socket second = secondConnection.get(2, TimeUnit.SECONDS)) {
|
||||||
|
assertTrue(second.isConnected());
|
||||||
|
}
|
||||||
|
awaitState(manager, On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
manager.stopByUser();
|
manager.stopByUser();
|
||||||
|
manager.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Socket accept(ServerSocket server) {
|
||||||
|
try {
|
||||||
|
return server.accept();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new RuntimeException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads from the socket until {@code marker} has arrived, or its 2 s read timeout fires. */
|
||||||
|
private static String readUntil(Socket socket, String marker) throws IOException {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
InputStream in = socket.getInputStream();
|
||||||
|
byte[] buffer = new byte[256];
|
||||||
|
while (!text.toString().contains(marker)) {
|
||||||
|
int read = in.read(buffer);
|
||||||
|
if (read < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
text.append(new String(buffer, 0, read, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private On4KstConnectionManager.IdleAction idleAction(
|
private On4KstConnectionManager.IdleAction idleAction(
|
||||||
long inboundIdleMillis,
|
long inboundIdleMillis,
|
||||||
boolean online,
|
boolean online,
|
||||||
@@ -303,38 +372,9 @@ class On4KstConnectionProbeTest {
|
|||||||
private void awaitState(
|
private void awaitState(
|
||||||
On4KstConnectionManager manager,
|
On4KstConnectionManager manager,
|
||||||
On4KstConnectionState expected
|
On4KstConnectionState expected
|
||||||
) throws InterruptedException {
|
) {
|
||||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(7L);
|
awaitUntil(Duration.ofSeconds(5),
|
||||||
while (System.nanoTime() < deadline) {
|
() -> "expected state " + expected + " but was " + manager.getState(),
|
||||||
if (manager.getState() == expected) {
|
() -> manager.getState() == expected);
|
||||||
return;
|
|
||||||
}
|
|
||||||
TimeUnit.MILLISECONDS.sleep(25L);
|
|
||||||
}
|
|
||||||
assertEquals(expected, manager.getState());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setTimedOutProbe(
|
|
||||||
On4KstConnectionManager manager,
|
|
||||||
long now
|
|
||||||
) throws ReflectiveOperationException {
|
|
||||||
Field activeSession = On4KstConnectionManager.class
|
|
||||||
.getDeclaredField("activeSession");
|
|
||||||
activeSession.setAccessible(true);
|
|
||||||
Object session = activeSession.get(manager);
|
|
||||||
|
|
||||||
Field lastInboundMillis = session.getClass()
|
|
||||||
.getDeclaredField("lastInboundMillis");
|
|
||||||
lastInboundMillis.setAccessible(true);
|
|
||||||
((AtomicLong) lastInboundMillis.get(session)).set(
|
|
||||||
now - On4KstConnectionManager.INBOUND_STALE_AFTER_MILLIS - 1L);
|
|
||||||
|
|
||||||
Field clientLivenessProbe = session.getClass()
|
|
||||||
.getDeclaredField("clientLivenessProbe");
|
|
||||||
clientLivenessProbe.setAccessible(true);
|
|
||||||
((On4KstConnectionManager.ClientLivenessProbeState)
|
|
||||||
clientLivenessProbe.get(session)).tryStart(
|
|
||||||
now - On4KstConnectionManager
|
|
||||||
.CLIENT_LIVENESS_PROBE_AFTER_MILLIS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package kst4contest.testing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded polling for tests that wait on another thread.
|
||||||
|
*
|
||||||
|
* <p>Replaces fixed {@code Thread.sleep} pauses: a condition that is already true returns
|
||||||
|
* at once, and one that never becomes true fails with a message instead of letting the
|
||||||
|
* test pass or fail on the speed of the machine.</p>
|
||||||
|
*/
|
||||||
|
public final class Await {
|
||||||
|
|
||||||
|
private static final long POLL_INTERVAL_MS = 10L;
|
||||||
|
|
||||||
|
private Await() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, BooleanSupplier condition) {
|
||||||
|
awaitUntil(timeout, "condition not met within " + timeout, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param message what the failure should say when the timeout elapses
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, String message, BooleanSupplier condition) {
|
||||||
|
awaitUntil(timeout, () -> message, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls {@code condition} every 10 ms until it is true.
|
||||||
|
*
|
||||||
|
* @param timeout how long to keep polling before failing
|
||||||
|
* @param message builds the failure message at the moment of failure, so it can
|
||||||
|
* describe the state that was actually seen
|
||||||
|
* @param condition what must become true
|
||||||
|
* @throws AssertionError when the timeout elapses first
|
||||||
|
*/
|
||||||
|
public static void awaitUntil(Duration timeout, Supplier<String> message, BooleanSupplier condition) {
|
||||||
|
long deadline = System.nanoTime() + timeout.toNanos();
|
||||||
|
while (true) {
|
||||||
|
if (condition.getAsBoolean()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
throw new AssertionError(message.get());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(POLL_INTERVAL_MS);
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError("interrupted while waiting: " + message.get(), interrupted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package kst4contest.testing;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.AbstractExecutorService;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.Delayed;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.FutureTask;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ScheduledExecutorService} whose delays the test releases by hand.
|
||||||
|
*
|
||||||
|
* <p>Immediate work ({@code execute}, {@code submit}) runs on a real single thread, as
|
||||||
|
* it does in production, so the class under test keeps its threading. Delayed work is
|
||||||
|
* recorded instead of timed: {@link #runDelayedTasks()} runs everything scheduled so far
|
||||||
|
* and {@link #runPeriodicTask()} runs the one fixed-rate task, each on that same thread
|
||||||
|
* and each waited for. A test therefore triggers "five seconds later" itself rather than
|
||||||
|
* sleeping through it.</p>
|
||||||
|
*/
|
||||||
|
public final class ManualScheduler extends AbstractExecutorService
|
||||||
|
implements ScheduledExecutorService {
|
||||||
|
|
||||||
|
private final ExecutorService worker = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "manual-scheduler");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
|
private final List<Runnable> delayed = new CopyOnWriteArrayList<>();
|
||||||
|
private final AtomicReference<Runnable> periodic = new AtomicReference<>();
|
||||||
|
|
||||||
|
/** Whether at least one delayed task is waiting to be released. */
|
||||||
|
public boolean hasDelayedTasks() {
|
||||||
|
return !delayed.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs every delayed task recorded so far, in scheduling order, on the worker thread,
|
||||||
|
* and returns once they have all finished. Tasks they schedule stay pending.
|
||||||
|
*/
|
||||||
|
public void runDelayedTasks() throws Exception {
|
||||||
|
List<Runnable> due = new ArrayList<>(delayed);
|
||||||
|
delayed.removeAll(due);
|
||||||
|
for (Runnable task : due) {
|
||||||
|
runOnWorker(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the fixed-rate task once, on the worker thread, and waits for it. */
|
||||||
|
public void runPeriodicTask() throws Exception {
|
||||||
|
Runnable task = periodic.get();
|
||||||
|
if (task == null) {
|
||||||
|
throw new AssertionError("no fixed-rate task has been scheduled");
|
||||||
|
}
|
||||||
|
runOnWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runOnWorker(Runnable task) throws Exception {
|
||||||
|
try {
|
||||||
|
worker.submit(task).get(10, TimeUnit.SECONDS);
|
||||||
|
} catch (ExecutionException failed) {
|
||||||
|
throw new AssertionError("scheduled task failed", failed.getCause());
|
||||||
|
} catch (TimeoutException tooSlow) {
|
||||||
|
throw new AssertionError("scheduled task did not finish within 10 s", tooSlow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(Runnable command) {
|
||||||
|
worker.execute(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
|
||||||
|
RecordedTask<Void> task = new RecordedTask<>(command, delay, unit);
|
||||||
|
delayed.add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
|
||||||
|
throw new UnsupportedOperationException("not used by the classes under test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledFuture<?> scheduleAtFixedRate(
|
||||||
|
Runnable command, long initialDelay, long period, TimeUnit unit) {
|
||||||
|
if (!periodic.compareAndSet(null, command)) {
|
||||||
|
throw new UnsupportedOperationException("only one fixed-rate task is supported");
|
||||||
|
}
|
||||||
|
return new RecordedTask<>(command, initialDelay, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScheduledFuture<?> scheduleWithFixedDelay(
|
||||||
|
Runnable command, long initialDelay, long delay, TimeUnit unit) {
|
||||||
|
throw new UnsupportedOperationException("not used by the classes under test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
worker.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Runnable> shutdownNow() {
|
||||||
|
delayed.clear();
|
||||||
|
return worker.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isShutdown() {
|
||||||
|
return worker.isShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isTerminated() {
|
||||||
|
return worker.isTerminated();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
|
||||||
|
return worker.awaitTermination(timeout, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recorded delay: a plain future whose "remaining delay" is what was asked for. */
|
||||||
|
private static final class RecordedTask<V> extends FutureTask<V> implements ScheduledFuture<V> {
|
||||||
|
|
||||||
|
private final long delayNanos;
|
||||||
|
|
||||||
|
RecordedTask(Runnable command, long delay, TimeUnit unit) {
|
||||||
|
super(command, null);
|
||||||
|
this.delayNanos = unit.toNanos(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getDelay(TimeUnit unit) {
|
||||||
|
return unit.convert(delayNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(Delayed other) {
|
||||||
|
return Long.compare(delayNanos, other.getDelay(TimeUnit.NANOSECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -53,6 +54,22 @@ class TileFetcherTest {
|
|||||||
return new TileFetcher(cacheDirectory, source);
|
return new TileFetcher(cacheDirectory, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clock the retry pause is measured against, advanced by the test instead of
|
||||||
|
* waited for. The pause itself is 1 ms; what matters is only "before" and "after".
|
||||||
|
*/
|
||||||
|
private static final Duration SHORT_PAUSE = Duration.ofMillis(1);
|
||||||
|
|
||||||
|
private final AtomicLong nanoClock = new AtomicLong();
|
||||||
|
|
||||||
|
private TileFetcher fetcherWithPause(Path cacheDirectory, RecordingSource source) {
|
||||||
|
return new TileFetcher(cacheDirectory, source, SHORT_PAUSE, nanoClock::get);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void letThePauseElapse() {
|
||||||
|
nanoClock.addAndGet(SHORT_PAUSE.toNanos() * 20);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void the_url_is_the_one_openstreetmap_still_serves() {
|
void the_url_is_the_one_openstreetmap_still_serves() {
|
||||||
String url = TileFetcher.tileUrl(6, 33, 21);
|
String url = TileFetcher.tileUrl(6, 33, 21);
|
||||||
@@ -120,10 +137,10 @@ class TileFetcherTest {
|
|||||||
@Test
|
@Test
|
||||||
void a_failure_is_not_remembered_as_an_answer(@TempDir Path cache) throws Exception {
|
void a_failure_is_not_remembered_as_an_answer(@TempDir Path cache) throws Exception {
|
||||||
RecordingSource source = new RecordingSource(null, true);
|
RecordingSource source = new RecordingSource(null, true);
|
||||||
TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1));
|
TileFetcher fetcher = fetcherWithPause(cache, source);
|
||||||
|
|
||||||
fetcher.fetchTileAsync(6, 33, 21).get();
|
fetcher.fetchTileAsync(6, 33, 21).get();
|
||||||
Thread.sleep(20);
|
letThePauseElapse();
|
||||||
fetcher.fetchTileAsync(6, 33, 21).get();
|
fetcher.fetchTileAsync(6, 33, 21).get();
|
||||||
|
|
||||||
assertEquals(2, source.requestedUrls.size());
|
assertEquals(2, source.requestedUrls.size());
|
||||||
@@ -255,10 +272,10 @@ class TileFetcherTest {
|
|||||||
@Test
|
@Test
|
||||||
void a_tile_is_asked_for_again_once_the_pause_is_over(@TempDir Path cache) throws Exception {
|
void a_tile_is_asked_for_again_once_the_pause_is_over(@TempDir Path cache) throws Exception {
|
||||||
RecordingSource source = new RecordingSource(null, true);
|
RecordingSource source = new RecordingSource(null, true);
|
||||||
TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1));
|
TileFetcher fetcher = fetcherWithPause(cache, source);
|
||||||
|
|
||||||
fetcher.fetchTileAsync(6, 33, 21).get();
|
fetcher.fetchTileAsync(6, 33, 21).get();
|
||||||
Thread.sleep(20);
|
letThePauseElapse();
|
||||||
fetcher.fetchTileAsync(6, 33, 21).get();
|
fetcher.fetchTileAsync(6, 33, 21).get();
|
||||||
|
|
||||||
assertEquals(2, source.requestedUrls.size());
|
assertEquals(2, source.requestedUrls.size());
|
||||||
@@ -267,12 +284,12 @@ class TileFetcherTest {
|
|||||||
/** A tile that arrives after a failure is not held back by the old failure. */
|
/** A tile that arrives after a failure is not held back by the old failure. */
|
||||||
@Test
|
@Test
|
||||||
void a_pause_does_not_outlive_a_successful_fetch(@TempDir Path cache) throws Exception {
|
void a_pause_does_not_outlive_a_successful_fetch(@TempDir Path cache) throws Exception {
|
||||||
TileFetcher failing = new TileFetcher(cache, new RecordingSource(null, true), Duration.ofMillis(1));
|
TileFetcher failing = fetcherWithPause(cache, new RecordingSource(null, true));
|
||||||
failing.fetchTileAsync(6, 33, 21).get();
|
failing.fetchTileAsync(6, 33, 21).get();
|
||||||
Thread.sleep(20);
|
letThePauseElapse();
|
||||||
|
|
||||||
RecordingSource working = new RecordingSource(new byte[]{3}, false);
|
RecordingSource working = new RecordingSource(new byte[]{3}, false);
|
||||||
TileFetcher recovered = new TileFetcher(cache, working, Duration.ofMillis(1));
|
TileFetcher recovered = fetcherWithPause(cache, working);
|
||||||
|
|
||||||
assertArrayEquals(new byte[]{3}, recovered.fetchTileAsync(6, 33, 21).get());
|
assertArrayEquals(new byte[]{3}, recovered.fetchTileAsync(6, 33, 21).get());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user