Remove Thread.sleep timing coupling from the tests (#109) (#118)

* 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:
Rsclub2_2andClaude Fable 5.1 authored and GitHub committed 2026-10-11 18:39:21 +02:00
1 parent f2b0fdd5b4
commit 91863fee1f
14 files changed
+741 -159

No files matched your search

@@ -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()
}
}