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
@@ -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.
|
||||
*
|
||||
* <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.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<ChatMessage> receiveQueue;
|
||||
private final LinkedBlockingQueue<ChatMessage> 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<Integer, Map<String, ChatMember>> 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<ChatMessage> receiveQueue,
|
||||
LinkedBlockingQueue<ChatMessage> transmitQueue
|
||||
LinkedBlockingQueue<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Long> 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() {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
@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<Integer> acceptedConnections =
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
try (Socket first = server.accept();
|
||||
Socket second = server.accept()) {
|
||||
return 2;
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
});
|
||||
CompletableFuture<Socket> 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<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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user