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, Supplier 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); + } + } + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java index c5e22606..c7ccee19 100644 --- a/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java +++ b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java @@ -3,12 +3,13 @@ package kst4contest.view; 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,85 +18,89 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *

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 * 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 LinkedBlockingQueue receiveQueue; private final LinkedBlockingQueue transmitQueue; - private final long connectedMillis = System.currentTimeMillis(); - private final AtomicLong lastInboundMillis = - new AtomicLong(connectedMillis); - private final AtomicLong lastProgressMillis = - new AtomicLong(connectedMillis); + private final AtomicLong lastInboundMillis; + private final AtomicLong lastProgressMillis; private final ClientLivenessProbeState clientLivenessProbe = new ClientLivenessProbeState(); private final Map> initialMembers = @@ -910,16 +933,23 @@ final class On4KstConnectionManager { private volatile boolean online; private volatile boolean closed; + /** + * @param connectedMillis the manager's clock at connect time; both idle clocks + * start from it + */ private Session( long id, Socket socket, LinkedBlockingQueue receiveQueue, - LinkedBlockingQueue transmitQueue + LinkedBlockingQueue transmitQueue, + long connectedMillis ) { this.id = id; this.socket = socket; this.receiveQueue = receiveQueue; this.transmitQueue = transmitQueue; + this.lastInboundMillis = new AtomicLong(connectedMillis); + this.lastProgressMillis = new AtomicLong(connectedMillis); } } diff --git a/core/src/main/java/kst4contest/view/map/TileFetcher.java b/core/src/main/java/kst4contest/view/map/TileFetcher.java index 58ddf2b3..2a346e20 100644 --- a/core/src/main/java/kst4contest/view/map/TileFetcher.java +++ b/core/src/main/java/kst4contest/view/map/TileFetcher.java @@ -19,6 +19,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.LongSupplier; /** * 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; + /** Monotonic clock the retry pause is measured against. */ + private final LongSupplier nanoClock; + /** When each recently failed tile may be asked for again. */ private final Map retryNotBeforeNanos = new java.util.concurrent.ConcurrentHashMap<>(); @@ -97,9 +101,19 @@ public final class TileFetcher { } 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.source = source; this.retryPause = retryPause; + this.nanoClock = nanoClock; this.executor = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS, runnable -> { Thread thread = new Thread(runnable, "map-tile-fetch"); thread.setDaemon(true); @@ -150,7 +164,7 @@ public final class TileFetcher { } 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. */ return null; } @@ -236,7 +250,7 @@ public final class TileFetcher { private void recordFailure(String key) { failedRequests.incrementAndGet(); - retryNotBeforeNanos.put(key, System.nanoTime() + retryPause.toNanos()); + retryNotBeforeNanos.put(key, nanoClock.getAsLong() + retryPause.toNanos()); } private void recordArrival() { diff --git a/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java b/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java index 1d94ea4e..28946a69 100644 --- a/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java +++ b/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java @@ -1,13 +1,15 @@ package kst4contest.controller; +import static kst4contest.testing.Await.awaitUntil; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.io.InputStream; import java.io.OutputStreamWriter; -import java.lang.reflect.Field; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; @@ -25,6 +27,7 @@ import org.junit.jupiter.api.Timeout; import kst4contest.model.ChatCategory; import kst4contest.model.ChatMessage; import kst4contest.model.ChatPreferences; +import kst4contest.testing.ManualScheduler; class On4KstConnectionProbeTest { @@ -143,7 +146,8 @@ class On4KstConnectionProbeTest { accepted.getOutputStream(), StandardCharsets.UTF_8)) { out.write("OK|\r\n"); 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.flush(); } catch (Exception exception) { @@ -191,7 +195,8 @@ class On4KstConnectionProbeTest { try (Socket accepted = server.accept(); OutputStreamWriter out = new OutputStreamWriter( 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.flush(); } 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. + * + *

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.

+ */ @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()); - CompletableFuture acceptedConnections = - CompletableFuture.supplyAsync(() -> { - try (Socket first = server.accept(); - Socket second = server.accept()) { - return 2; - } catch (Exception exception) { - throw new RuntimeException(exception); - } - }); + CompletableFuture firstConnection = + CompletableFuture.supplyAsync(() -> accept(server)); - On4KstConnectionManager manager = - new On4KstConnectionManager(controller); + On4KstConnectionManager manager = new On4KstConnectionManager( + controller, scheduler, clock::get); try { manager.start(); - awaitState(manager, On4KstConnectionState.AUTHENTICATING); - manager.onLogstat(1L, new String[] {"LOGSTAT", "100"}); - awaitState(manager, On4KstConnectionState.SYNCING_MAIN_CHAT); - manager.onInitialUserListCompleted(1L, - preferences.getLoginChatCategoryMain()); - awaitState(manager, On4KstConnectionState.ONLINE); + try (Socket first = firstConnection.get(2, TimeUnit.SECONDS)) { + first.setSoTimeout(2_000); + awaitState(manager, + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT); - 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); - assertEquals(2, acceptedConnections.get(7, TimeUnit.SECONDS)); + manager.onLogstat(1L, new String[] {"LOGSTAT", "100"}); + 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 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 { 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( long inboundIdleMillis, boolean online, @@ -303,38 +372,9 @@ class On4KstConnectionProbeTest { private void awaitState( On4KstConnectionManager manager, On4KstConnectionState expected - ) throws InterruptedException { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(7L); - while (System.nanoTime() < deadline) { - if (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); + ) { + awaitUntil(Duration.ofSeconds(5), + () -> "expected state " + expected + " but was " + manager.getState(), + () -> manager.getState() == expected); } } diff --git a/core/src/test/java/kst4contest/testing/Await.java b/core/src/test/java/kst4contest/testing/Await.java new file mode 100644 index 00000000..1a65a0b9 --- /dev/null +++ b/core/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, Supplier 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); + } + } + } +} diff --git a/core/src/test/java/kst4contest/testing/ManualScheduler.java b/core/src/test/java/kst4contest/testing/ManualScheduler.java new file mode 100644 index 00000000..426d3560 --- /dev/null +++ b/core/src/test/java/kst4contest/testing/ManualScheduler.java @@ -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. + * + *

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.

+ */ +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 delayed = new CopyOnWriteArrayList<>(); + private final AtomicReference 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 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 task = new RecordedTask<>(command, delay, unit); + delayed.add(task); + return task; + } + + @Override + public ScheduledFuture schedule(Callable 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 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 extends FutureTask implements ScheduledFuture { + + 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)); + } + } +} diff --git a/core/src/test/java/kst4contest/view/map/TileFetcherTest.java b/core/src/test/java/kst4contest/view/map/TileFetcherTest.java index f1c81b57..48900830 100644 --- a/core/src/test/java/kst4contest/view/map/TileFetcherTest.java +++ b/core/src/test/java/kst4contest/view/map/TileFetcherTest.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.List; 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.assertEquals; @@ -53,6 +54,22 @@ class TileFetcherTest { 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 void the_url_is_the_one_openstreetmap_still_serves() { String url = TileFetcher.tileUrl(6, 33, 21); @@ -120,10 +137,10 @@ class TileFetcherTest { @Test void a_failure_is_not_remembered_as_an_answer(@TempDir Path cache) throws Exception { 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(); - Thread.sleep(20); + letThePauseElapse(); fetcher.fetchTileAsync(6, 33, 21).get(); assertEquals(2, source.requestedUrls.size()); @@ -255,10 +272,10 @@ class TileFetcherTest { @Test void a_tile_is_asked_for_again_once_the_pause_is_over(@TempDir Path cache) throws Exception { 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(); - Thread.sleep(20); + letThePauseElapse(); fetcher.fetchTileAsync(6, 33, 21).get(); 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. */ @Test 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(); - Thread.sleep(20); + letThePauseElapse(); 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()); }