diff --git a/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java index 6730eaa4..53ef37a8 100644 --- a/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java +++ b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java @@ -1,6 +1,5 @@ package kst4contest.view; -import javax.swing.Timer; import java.util.Objects; /** @@ -14,16 +13,24 @@ import java.util.Objects; */ public final class CoalescingTrigger { - private final Timer timer; + private final DelayTimer timer; /** * @param delayMs how long to wait after the last request * @param action runs on the user-interface thread once the delay elapses */ 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"); - this.timer = new Timer(delayMs, event -> action.run()); - this.timer.setRepeats(false); + this.timer = timers.create(delayMs, action); } /** Requests a run, restarting the delay. */ diff --git a/app-desktop/src/main/java/kst4contest/view/DelayTimer.java b/app-desktop/src/main/java/kst4contest/view/DelayTimer.java new file mode 100644 index 00000000..64519d93 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/DelayTimer.java @@ -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. + * + *
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.
+ */ +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(); + } + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java index 35088929..5169dd1a 100644 --- a/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java +++ b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java @@ -2,7 +2,6 @@ package kst4contest.view; import kst4contest.observe.UiDispatcher; -import javax.swing.Timer; import java.util.Objects; /** @@ -22,7 +21,7 @@ public final class LayoutAutosave { private final Runnable writeLayout; private final UiDispatcher uiDispatcher; - private final Timer saveDelay; + private final DelayTimer saveDelay; private boolean pending; /** @@ -30,16 +29,28 @@ public final class LayoutAutosave { * @param uiDispatcher confines the pending state to one thread */ 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 * AWT event thread, which is where the user interface lives now, and restart() * has the same meaning playFromStart() had. A PauseTransition would need the * JavaFX toolkit to be running, which it no longer is. */ - this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending()); - this.saveDelay.setRepeats(false); + this(writeLayout, uiDispatcher, DelayTimer.SWING); + } + + /** + * 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. */ diff --git a/app-desktop/src/test/java/kst4contest/testing/Await.java b/app-desktop/src/test/java/kst4contest/testing/Await.java new file mode 100644 index 00000000..1a65a0b9 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/testing/Await.java @@ -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. + * + *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.
+ */ +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, SupplierUntested 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 * contest load, never and the table stops updating altogether.
+ * + *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.
*/ class CoalescingTriggerTest { @Test - void aBurstOfTriggersProducesOneRun() throws Exception { + void aBurstOfTriggersProducesOneRun() { AtomicInteger runs = new AtomicInteger(); - CountDownLatch ran = new CountDownLatch(1); - CoalescingTrigger trigger = new CoalescingTrigger(120, () -> { - runs.incrementAndGet(); - ran.countDown(); - }); + FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory(); + CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers); // What a busy chat looks like: many updates, one intended redraw. for (int i = 0; i < 25; i++) { 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"); - Thread.sleep(300); + timers.timer().fire(); 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 - void theActionRunsOnTheEventThread() throws Exception { + void theActionRunsOnTheEventThread() { AtomicBoolean onEventThread = new AtomicBoolean(); - CountDownLatch ran = new CountDownLatch(1); + AtomicBoolean ran = new AtomicBoolean(); CoalescingTrigger trigger = new CoalescingTrigger(60, () -> { onEventThread.set(EventQueue.isDispatchThread()); - ran.countDown(); + ran.set(true); }); 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"); } @Test - void aLaterTriggerPostponesTheRun() throws Exception { + void aLaterTriggerPostponesTheRun() { 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(); - Thread.sleep(150); trigger.trigger(); - Thread.sleep(150); - // 300 ms have passed, but the second trigger restarted the wait at 150 ms. - assertEquals(0, runs.get(), + assertEquals(2, timers.timer().restarts(), "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 - void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception { + void cancellingBeforeTheDelayElapsesSuppressesTheRun() { 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.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"); } @Test - void triggeringAgainAfterACancelStillWorks() throws Exception { + void triggeringAgainAfterACancelStillWorks() { AtomicInteger runs = new AtomicInteger(); - CountDownLatch ran = new CountDownLatch(1); - CoalescingTrigger trigger = new CoalescingTrigger(80, () -> { - runs.incrementAndGet(); - ran.countDown(); - }); + FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory(); + CoalescingTrigger trigger = new CoalescingTrigger(80, runs::incrementAndGet, timers); trigger.trigger(); trigger.cancel(); trigger.trigger(); + assertTrue(timers.timer().isArmed(), "a cancel must not disable the trigger for good"); - assertTrue(ran.await(5, TimeUnit.SECONDS), - "a cancel must not disable the trigger for good"); + timers.timer().fire(); assertEquals(1, runs.get()); } } diff --git a/app-desktop/src/test/java/kst4contest/view/FakeDelayTimer.java b/app-desktop/src/test/java/kst4contest/view/FakeDelayTimer.java new file mode 100644 index 00000000..9b30ad9c --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/FakeDelayTimer.java @@ -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. + * + *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.
+ */ +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; + } + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java index 871fed11..73cff362 100644 --- a/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java +++ b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java @@ -4,11 +4,13 @@ import kst4contest.observe.UiDispatcher; import org.junit.jupiter.api.Test; import java.awt.EventQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; 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.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -17,59 +19,72 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *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 * saved, with nothing to see until the next start.
+ * + *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.
*/ class LayoutAutosaveTest { private final UiDispatcher dispatcher = new AwtUiDispatcher(); + /** The real Swing timer, end to end, from a thread that is not the event thread. */ @Test - void aRequestFromAForeignThreadEventuallyWrites() throws Exception { - CountDownLatch written = new CountDownLatch(1); - LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher); + void aRequestFromAForeignThreadEventuallyWrites() { + AtomicBoolean written = new AtomicBoolean(); + LayoutAutosave autosave = new LayoutAutosave(() -> written.set(true), dispatcher); autosave.requestSave(); - assertTrue(written.await(5, TimeUnit.SECONDS), - "a save requested off the user-interface thread must still happen"); + awaitUntil(Duration.ofSeconds(5), + "a save requested off the user-interface thread must still happen", + written::get); } @Test void burstsOfRequestsCollapseIntoOneWrite() throws Exception { AtomicInteger writes = new AtomicInteger(); - CountDownLatch written = new CountDownLatch(1); - LayoutAutosave autosave = new LayoutAutosave(() -> { - writes.incrementAndGet(); - written.countDown(); - }, dispatcher); + FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory(); + LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers); // What dragging a column divider looks like: many events, one intended save. - for (int i = 0; i < 20; i++) { - autosave.requestSave(); - } + EventQueue.invokeAndWait(() -> { + 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"); - Thread.sleep(300); + EventQueue.invokeAndWait(timers.timer()::fire); 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 void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception { 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(() -> { autosave.requestSave(); 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"); } @Test void flushingWritesAtOnceAndOnlyOnce() throws Exception { 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(() -> { autosave.requestSave(); @@ -79,5 +94,7 @@ class LayoutAutosaveTest { }); 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"); } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt index f42ee5d5..0786c056 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt @@ -1,6 +1,7 @@ package kst4contest.view.compose import kst4contest.model.OperatorProfile +import kst4contest.testing.Await.awaitUntil import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assumptions.assumeFalse @@ -8,9 +9,10 @@ import org.junit.jupiter.api.Test import java.awt.EventQueue import java.awt.GraphicsEnvironment import java.awt.Window +import java.time.Duration import java.util.concurrent.Executors +import java.util.concurrent.Future import java.util.concurrent.TimeUnit -import javax.swing.Timer /** * The picker must be openable from the user-interface thread. @@ -29,31 +31,45 @@ class OperatorProfilePickerWindowTest { OperatorProfile().apply { profileId = "second"; displayName = "Second" }, ) - /** Closes whatever window the picker opened, the way the operator's X would. */ - private fun disposeOpenWindowsAfter(delayMs: Int): Timer = - Timer(delayMs) { - Window.getWindows() - .filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") } - .forEach { it.dispose() } - }.apply { isRepeats = true } + /** The picker's dialog, once it is on screen, or null while it is not. */ + private fun showingPickerDialog(): Window? = + Window.getWindows().firstOrNull { it.isShowing && it.javaClass.name.contains("ComposeDialog") } + + /** + * Closes the picker the way the operator's X would, as soon as it is showing. + * + * 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 fun opensAndReturnsWhenCalledOnTheEventThread() { assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") - val closer = disposeOpenWindowsAfter(1_500) val worker = Executors.newSingleThreadExecutor() try { - closer.start() + val closing = disposePickerOnceShowing() // invokeAndWait so the call really happens ON the event thread, as the menu does. val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } } // The deadlock had no timeout of its own: without a fix this never completes. call.get(20, TimeUnit.SECONDS) + closing.get(1, TimeUnit.SECONDS) } finally { - closer.stop() worker.shutdownNow() + closer.shutdownNow() } } @@ -61,17 +77,16 @@ class OperatorProfilePickerWindowTest { fun opensAndReturnsWhenCalledOffTheEventThread() { assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") - val closer = disposeOpenWindowsAfter(1_500) - try { - closer.start() + val closing = disposePickerOnceShowing() // The startup path: main() resolves the profile before any window exists. val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root") // Dismissed rather than confirmed, so the caller is told to quit. assertTrue(chosen.isEmpty, "a dismissed picker must report no choice") + closing.get(1, TimeUnit.SECONDS) } finally { - closer.stop() + closer.shutdownNow() } } diff --git a/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java index 95841a81..cc8475cc 100644 --- a/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java +++ b/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java @@ -21,6 +21,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; import java.util.logging.Level; import java.util.logging.Logger; @@ -54,6 +55,7 @@ final class On4KstConnectionManager { private final ChatController controller; private final ScheduledExecutorService scheduler; + private final LongSupplier millisClock; private final AtomicLong generation = new AtomicLong(); private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong(); @@ -64,12 +66,35 @@ final class On4KstConnectionManager { private int reconnectAttempt; 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. + * + *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.
+ * + * @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.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable, "On4KstConnectionSupervisor"); - thread.setDaemon(true); - return thread; - }); + this.scheduler = scheduler; + this.millisClock = millisClock; this.scheduler.scheduleAtFixedRate( this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS); LOGGER.fine("ON4KST connection supervisor initialized"); @@ -188,7 +213,7 @@ final class On4KstConnectionManager { return; } - long now = System.currentTimeMillis(); + long now = millisClock.getAsLong(); session.lastInboundMillis.set(now); session.lastProgressMillis.set(now); @@ -236,7 +261,7 @@ final class On4KstConnectionManager { session.initialMembers .computeIfAbsent(category, ignored -> new ConcurrentHashMap<>()) .put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member); - session.lastProgressMillis.set(System.currentTimeMillis()); + session.lastProgressMillis.set(millisClock.getAsLong()); } void onInitialUserListCompleted(long sessionId, ChatCategory category) { @@ -277,7 +302,8 @@ final class On4KstConnectionManager { token, socket, receiveQueue, - transmitQueue); + transmitQueue, + millisClock.getAsLong()); MessageHistoryRecorder historyRecorder = controller.getMessageHistoryRecorder(); @@ -360,7 +386,7 @@ final class On4KstConnectionManager { historyFrom); session.loginSent = true; - session.lastProgressMillis.set(System.currentTimeMillis()); + session.lastProgressMillis.set(millisClock.getAsLong()); LOGGER.log(Level.INFO, "Sending ON4KST login for session {0}, main category {1}", new Object[] {sessionId, mainCategory}); @@ -392,7 +418,7 @@ final class On4KstConnectionManager { return; } session.authenticated = true; - session.lastProgressMillis.set(System.currentTimeMillis()); + session.lastProgressMillis.set(millisClock.getAsLong()); LOGGER.log(Level.INFO, "ON4KST login accepted for session {0}", sessionId); @@ -528,7 +554,7 @@ final class On4KstConnectionManager { } reconnectAttempt = 0; session.online = true; - session.lastProgressMillis.set(System.currentTimeMillis()); + session.lastProgressMillis.set(millisClock.getAsLong()); LOGGER.log(Level.INFO, "ON4KST session {0} is authenticated and synchronized", session.id); @@ -668,7 +694,7 @@ final class On4KstConnectionManager { return; } - long now = System.currentTimeMillis(); + long now = millisClock.getAsLong(); if (!session.online && now - session.lastProgressMillis.get() > HANDSHAKE_TIMEOUT_MILLIS) { @@ -890,11 +916,8 @@ final class On4KstConnectionManager { private final Socket socket; private final LinkedBlockingQueueTime 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.
+ */ @Test @Timeout(15) void unansweredProbeUsesExistingReconnectFlow() throws Exception { @@ -236,39 +251,93 @@ class On4KstConnectionProbeTest { org.mockito.Mockito.when(controller.getChatCategorySecondChat()) .thenReturn(preferences.getLoginChatCategorySecond()); + AtomicLong clock = new AtomicLong(1_700_000_000_000L); + ManualScheduler scheduler = new ManualScheduler(); + try (ServerSocket server = new ServerSocket(0)) { preferences.setStn_on4kstServersPort(server.getLocalPort()); - CompletableFutureReplaces 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.
+ */ +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, SupplierImmediate 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.
+ */ +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