Compare commits

...
2 Commits
Author SHA1 Message Date
Rsclub2_2andClaude Fable 5.1 7b44eb0c2e Reference cases for the path-analysis mathematics (#108) (#117)
* test: pin the Maidenhead and great-circle maths of Location against definitions (#108)

Subsquare centres from the Maidenhead definition, round trip through
toMaidenhead (lower-case subsquare pinned as is), distance and bearing
between locators against an independent spherical computation on
R = 6371.009 km, consistency with PathGeometryUtils, and the NaN bearing
for identical points pinned as current behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin PathGeometryUtils against ITU-R P.525/P.526 and textbook values (#108)

Great circle against the Veness worked example, Fresnel radius against
P.526-15 eq. (2) and (3), earth bulge against the 8500 km effective
radius, radio horizon against 4.12*sqrt(h), knife-edge loss J(v) against
eq. (31), the v parameter against eq. (26), free-space loss pinned at the
project's 32.44 constant with the P.525 and exact values documented,
plus wattsToDbm, the sample-count rule and the k-factor sanitiser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin the path-analysis pipeline on a flat profile (#108)

A flat 20 km profile with 10 m antennas at 144.3 MHz through an
in-memory terrain provider: bulge, line-of-sight clearance, first
Fresnel radius and intrusion, no knife edge and 0 dB diffraction. Pins
that a partial Fresnel intrusion without a blocked line of sight
contributes 0 dB to the link budget; P.526 would give 0 to 6 dB there,
which is tracked separately.

Fixes #108

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:39:48 +02:00
Rsclub2_2andClaude Fable 5.1 91863fee1f 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>
2026-10-11 18:39:21 +02:00
17 changed files with 1359 additions and 159 deletions

No files matched your search

@@ -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. */
@@ -0,0 +1,58 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
* A one-shot delay that can be restarted and stopped.
*
* <p>The thin seam between the two coalescing classes in this package and the
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
* implementation; tests substitute one whose firing they control, so "nothing ran" can
* be asserted without waiting for a delay that might not have elapsed yet.</p>
*/
interface DelayTimer {
/** Starts the delay, or starts it over if it is already running. */
void restart();
/** Drops a pending run. Idempotent. */
void stop();
/** Creates one-shot timers for a fixed delay and action. */
interface Factory {
/**
* @param delayMs how long the timer waits after a {@link #restart()}
* @param onFire what runs once the delay elapses
*/
DelayTimer create(int delayMs, Runnable onFire);
}
/**
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
* thread where the user interface lives.
*/
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
/** {@code javax.swing.Timer} behind the interface. */
final class Swing implements DelayTimer {
private final Timer timer;
Swing(final int delayMs, final Runnable onFire) {
Objects.requireNonNull(onFire, "onFire");
this.timer = new Timer(delayMs, event -> onFire.run());
this.timer.setRepeats(false);
}
@Override
public void restart() {
timer.restart();
}
@Override
public void stop() {
timer.stop();
}
}
}
@@ -2,7 +2,6 @@ package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import 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. */
@@ -0,0 +1,70 @@
package kst4contest.testing;
import java.time.Duration;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
/**
* Bounded polling for tests that wait on another thread.
*
* <p>Replaces fixed {@code Thread.sleep} pauses: a condition that is already true returns
* at once, and one that never becomes true fails with a message instead of letting the
* test pass or fail on the speed of the machine.</p>
*/
public final class Await {
private static final long POLL_INTERVAL_MS = 10L;
private Await() {
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, BooleanSupplier condition) {
awaitUntil(timeout, "condition not met within " + timeout, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message what the failure should say when the timeout elapses
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, String message, BooleanSupplier condition) {
awaitUntil(timeout, () -> message, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message builds the failure message at the moment of failure, so it can
* describe the state that was actually seen
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, Supplier<String> message, BooleanSupplier condition) {
long deadline = System.nanoTime() + timeout.toNanos();
while (true) {
if (condition.getAsBoolean()) {
return;
}
if (System.nanoTime() >= deadline) {
throw new AssertionError(message.get());
}
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("interrupted while waiting: " + message.get(), interrupted);
}
}
}
}
@@ -3,12 +3,13 @@ package kst4contest.view;
import org.junit.jupiter.api.Test;
import 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;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
* wrong are invisible in a build: too eager and the table redraws on every message under
* contest load, never and the table stops updating altogether.</p>
*
* <p>All but one test run on a {@link FakeDelayTimer} the test fires itself, so "it did
* not run" is a fact and not a 400 ms guess. The one exception goes through the real
* Swing timer so the default wiring stays covered.</p>
*/
class CoalescingTriggerTest {
@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());
}
}
@@ -0,0 +1,73 @@
package kst4contest.view;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* A {@link DelayTimer} that fires only when the test says so.
*
* <p>Models exactly what the Swing timer promises and nothing about wall time: a
* {@code restart()} arms it, a {@code stop()} disarms it, and {@link #fire()} runs the
* action once if it is armed. That turns "the run did not happen" from a wait-and-hope
* into an assertion.</p>
*/
final class FakeDelayTimer implements DelayTimer {
private final Runnable onFire;
private boolean armed;
private int restarts;
FakeDelayTimer(final Runnable onFire) {
this.onFire = onFire;
}
@Override
public void restart() {
armed = true;
restarts++;
}
@Override
public void stop() {
armed = false;
}
/** What the Swing timer does when the delay elapses: run once, then disarm. */
void fire() {
if (!armed) {
return;
}
armed = false;
onFire.run();
}
boolean isArmed() {
return armed;
}
int restarts() {
return restarts;
}
/** Hands out one fake timer and keeps it for the test to fire. */
static final class Factory implements DelayTimer.Factory {
private FakeDelayTimer created;
private int createdDelayMs;
@Override
public DelayTimer create(final int delayMs, final Runnable onFire) {
created = new FakeDelayTimer(onFire);
createdDelayMs = delayMs;
return created;
}
FakeDelayTimer timer() {
assertNotNull(created, "the class under test did not create its timer");
return created;
}
int delayMs() {
return createdDelayMs;
}
}
}
@@ -4,11 +4,13 @@ import kst4contest.observe.UiDispatcher;
import org.junit.jupiter.api.Test;
import 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;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
* silent: a timer that never fires means the operator's column widths are simply not
* saved, with nothing to see until the next start.</p>
*
* <p>The negative checks run on a {@link FakeDelayTimer} the test fires itself, on the
* event thread as the Swing timer would, so "nothing was written" is a fact rather than a
* 1.2 s guess. The first test goes through the real Swing timer so the default wiring
* stays covered.</p>
*/
class LayoutAutosaveTest {
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");
}
}
@@ -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()
}
}
@@ -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,168 @@
package kst4contest.locatorUtils;
import kst4contest.view.map.PathGeometryUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Reference cases for the Maidenhead and great-circle maths in {@link Location}.
*
* <p>None of the expected values below was derived from the code under test. The
* Maidenhead centres follow from the definition of the locator system, the distances
* and bearings from an independent spherical computation (haversine and atan2, see
* {@link #expectedDistanceAndBearingOfLocatorPairs}), both on the IUGG mean radius
* R = 6371.009 km that {@code Location} declares. Behaviour that is merely pinned
* (not endorsed) is marked as such in the test method's Javadoc.</p>
*
* <p>Context: issue #108.</p>
*/
class LocationMaidenheadReferenceTest {
/** Tolerance for a subsquare centre: well below the 1e-7 degree print precision. */
private static final double CENTRE_TOLERANCE_DEG = 1e-6;
/** Tolerance for values that the code rounds to 0.01 km / 0.01 degrees. */
private static final double ROUNDED_TOLERANCE = 0.005;
/**
* Locator to subsquare centre, by definition.
*
* <p>Source: Wikipedia, "Maidenhead Locator System". A field spans 20 degrees of
* longitude and 10 degrees of latitude, a square 2 by 1 degrees, a subsquare 5 by
* 2.5 arc minutes. The centre of a subsquare is therefore</p>
* <pre>
* lon = -180 + 20 * F1 + 2 * S1 + (5/60) * X1 + (5/60)/2
* lat = -90 + 10 * F2 + S2 + (2.5/60) * X2 + (2.5/60)/2
* </pre>
* <p>with F = field letter index (A = 0), S = square digit, X = subsquare letter
* index. Tolerance: 1e-6 degrees.</p>
*/
@Test
void locatorIsResolvedToTheCentreOfItsSubsquare() {
assertCentre("JO62QM", 52.5208333, 13.3750000);
assertCentre("JN49FK", 49.4375000, 8.4583333);
assertCentre("JO50JP", 50.6458333, 10.7916667);
assertCentre("AA00AA", -89.9791667, -179.9583333);
assertCentre("RR99XX", 89.9791667, 179.9583333);
}
/**
* Round trip from the subsquare centre back to the locator.
*
* <p>The coordinates are the JO62QM centre of the previous test. The expected string
* uses a lower-case subsquare: that is what {@link Location#toMaidenhead(double, double)}
* produces today and is pinned here, not endorsed. The locator system itself is
* case-insensitive; the ON4KST convention is upper case.</p>
*/
@Test
void centreCoordinatesRoundTripToTheLocatorWithLowerCaseSubsquare() {
assertEquals("JO62qm", Location.toMaidenhead(52.5208333, 13.375));
}
/**
* Distance and bearing between locator centres against an independent spherical
* computation.
*
* <p>Expected values were computed with Python 3 ({@code python3 -I}) on the subsquare
* centres of the previous test, R = 6371.009 km, using</p>
* <pre>
* a = sin^2(dphi/2) + cos(phi1) * cos(phi2) * sin^2(dlambda/2)
* d = 2 * R * atan2(sqrt(a), sqrt(1 - a)) (haversine)
* theta = atan2(sin(dlambda) * cos(phi2),
* cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dlambda))
* bearing = (deg(theta) + 360) mod 360
* </pre>
* <p>giving JO50JP to JO31AA 336.1207 km / 278.963 deg, JO50JP to JN49FK
* 214.0343 km / 232.018 deg, JO62QM to JN58TD 502.0447 km / 195.002 deg.
* {@link Location#getDistanceKmByTwoLocatorStrings} and {@link Location#getBearing}
* round to two decimals, so the rounded values are pinned with a tolerance of
* 0.005 km and 0.005 degrees.</p>
*/
@Test
void expectedDistanceAndBearingOfLocatorPairs() {
assertDistanceAndBearing("JO50JP", "JO31AA", 336.12, 278.96);
assertDistanceAndBearing("JO50JP", "JN49FK", 214.03, 232.02);
assertDistanceAndBearing("JO62QM", "JN58TD", 502.04, 195.00);
}
/**
* The two distance implementations in the project agree.
*
* <p>{@link Location} uses the spherical law of cosines, {@link PathGeometryUtils}
* the haversine formula; both declare R = 6371.009 km. On the same pair of subsquare
* centres the unrounded results must agree within 0.005 km (the law of cosines loses
* precision only for distances far below one kilometre).</p>
*/
@Test
void lawOfCosinesAndHaversineAgreeOnTheSamePair() {
Location from = new Location("JO50JP");
Location to = new Location("JN58TD");
double lawOfCosinesKm = from.getDistanceKm(to);
double haversineKm = PathGeometryUtils.calculateGreatCircleDistanceKm(
from.getLatitude().toDegrees(),
from.getLongitude().toDegrees(),
to.getLatitude().toDegrees(),
to.getLongitude().toDegrees()
);
assertEquals(haversineKm, lawOfCosinesKm, ROUNDED_TOLERANCE,
"law of cosines vs haversine on JO50JP -> JN58TD");
}
/**
* Bearing between identical points in {@link Location}: NaN.
*
* <p>Pinned, not endorsed. The bearing between a point and itself is undefined;
* {@code Location.getBearing} answers {@code Double.NaN}.</p>
*/
@Test
void locationBearingOfIdenticalPointsIsNaN() {
Location point = new Location("JO62QM");
assertTrue(Double.isNaN(Location.getBearing(point, new Location("JO62QM"))),
"Location.getBearing for identical points");
}
/**
* Bearing between identical points in {@link PathGeometryUtils}: 0 degrees.
*
* <p>Pinned, not endorsed. For identical coordinates both atan2 arguments are zero
* and {@code atan2(0, 0)} is 0, so {@code calculateInitialBearingDeg} answers 0 where
* {@code Location.getBearing} answers NaN. The two implementations disagree on the
* undefined case; this test records the current answer.</p>
*/
@Test
void pathGeometryBearingOfIdenticalPointsIsZero() {
double bearingDeg = PathGeometryUtils.calculateInitialBearingDeg(
52.5208333, 13.375, 52.5208333, 13.375);
assertEquals(0.0, bearingDeg, 1e-12, "PathGeometryUtils bearing for identical points");
}
private static void assertCentre(String locator, double expectedLatDeg, double expectedLonDeg) {
Location location = new Location(locator);
assertEquals(expectedLatDeg, location.getLatitude().toDegrees(), CENTRE_TOLERANCE_DEG,
locator + " latitude");
assertEquals(expectedLonDeg, location.getLongitude().toDegrees(), CENTRE_TOLERANCE_DEG,
locator + " longitude");
}
private static void assertDistanceAndBearing(String fromLocator,
String toLocator,
double expectedRoundedKm,
double expectedRoundedBearingDeg) {
Location helper = new Location();
assertEquals(expectedRoundedKm,
helper.getDistanceKmByTwoLocatorStrings(fromLocator, toLocator),
ROUNDED_TOLERANCE,
fromLocator + " -> " + toLocator + " distance");
assertEquals(expectedRoundedBearingDeg,
helper.getBearingOfTwoLocatorStrings(fromLocator, toLocator),
ROUNDED_TOLERANCE,
fromLocator + " -> " + toLocator + " bearing");
}
}
@@ -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));
}
}
}
@@ -0,0 +1,197 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The path-analysis pipeline of {@link GeometryOnlyPathAnalysisService} on a flat
* terrain profile, with every expected value computed from the underlying formulas
* rather than from the code.
*
* <p>Scenario: 20.000 km due north from 50.0 N 10.0 E, both antennas 10 m above a
* terrain of 0 m everywhere, 144.3 MHz, k = 4/3, default link-budget settings. A path
* along a meridian makes the haversine distance exactly R times the latitude difference,
* so the 20 km are hit to floating-point precision. The in-memory terrain provider hands
* back the requested number of samples (121 for 20 km, so the midpoint is sample 60 at
* exactly 10.000 km) with elevation 0.</p>
*
* <p>Expected midpoint values (R = 6 371 009 m, c = 299 792 458 m/s):</p>
* <pre>
* bulge = d1 d2 / (2 k R) = 1e4 * 1e4 / (2 * 4/3 * 6371009) = 5.886 m
* LOS height = 10 m (both endpoints have bulge 0 and antenna 10 m)
* clearance = 10 - 5.886 = 4.114 m
* lambda = c / 144.3e6 = 2.0776 m
* R1 = sqrt(lambda d1 d2 / (d1 + d2)) = sqrt(2.0776 * 5000) = 101.921 m
* intrusion = bulge - (LOS - R1) = 5.886 - (10 - 101.921) = 97.807 m
* </pre>
* <p>Tolerances: 0.05 m for bulge and clearance, 0.1 m for Fresnel radius and intrusion,
* 0.01 dB for losses.</p>
*
* <p>Context: issue #108.</p>
*/
class GeometryOnlyPathAnalysisServiceProfileTest {
private static final double EARTH_RADIUS_KM = 6371.009;
private static final double TOTAL_DISTANCE_KM = 20.0;
private static final double ANTENNA_HEIGHT_M = 10.0;
private static final double FREQUENCY_MHZ = 144.3;
private static final int MIDPOINT_INDEX = 60;
private static final double EXPECTED_MIDPOINT_BULGE_M = 5.886;
private static final double EXPECTED_MIDPOINT_LOS_CLEARANCE_M = 4.114;
private static final double EXPECTED_MIDPOINT_FRESNEL_RADIUS_M = 101.921;
private static final double EXPECTED_MIDPOINT_INTRUSION_M = 97.807;
/**
* Terrain provider that answers a flat profile of the requested sample count.
* Points are spaced uniformly along the path; latitude is interpolated linearly,
* which is exact for a meridian.
*/
private static final class FlatTerrainProfileProvider implements TerrainProfileProvider {
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
int sampleCount = request.requestedSampleCount();
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
for (int i = 0; i < sampleCount; i++) {
double fraction = (double) i / (sampleCount - 1);
double distanceKm = request.totalDistanceKm() * fraction;
double latitudeDeg = request.fromLatitudeDeg()
+ (request.toLatitudeDeg() - request.fromLatitudeDeg()) * fraction;
points.add(new PathProfilePoint(distanceKm, latitudeDeg, request.fromLongitudeDeg(), 0.0));
}
return new TerrainProfileData(points, "Flat test terrain", false);
}
}
private static PathAnalysisResult analyzeFlatPath() {
double fromLatitudeDeg = 50.0;
double longitudeDeg = 10.0;
double toLatitudeDeg = fromLatitudeDeg + Math.toDegrees(TOTAL_DISTANCE_KM / EARTH_RADIUS_KM);
PathAnalysisRequest request = new PathAnalysisRequest(
"JO50AA",
fromLatitudeDeg,
longitudeDeg,
"DL0TEST",
"JO50AE",
toLatitudeDeg,
longitudeDeg,
FREQUENCY_MHZ,
ANTENNA_HEIGHT_M,
ANTENNA_HEIGHT_M,
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
PathLinkBudgetSettings.defaults()
);
return new GeometryOnlyPathAnalysisService(new FlatTerrainProfileProvider()).analyze(request);
}
/**
* The scenario is what the Javadoc says: 20.000 km, 121 samples, midpoint at 10 km.
*
* <p>Sample count: ceil(20 / 0.5) + 1 = 41, clamped to the minimum of 121.
* Tolerance: 1e-6 km for the distance.</p>
*/
@Test
void scenarioIsTwentyKilometresWithTheMidpointAtSampleSixty() {
PathAnalysisResult result = analyzeFlatPath();
assertTrue(result.hasUsableProfile(), "profile usable");
assertEquals(TOTAL_DISTANCE_KM, result.distanceKm(), 1e-6, "path distance");
assertEquals(121, result.profilePoints().size(), "sample count");
assertEquals(10.0, result.profilePoints().get(MIDPOINT_INDEX).distanceKm(), 1e-9, "midpoint distance");
}
/**
* Earth bulge at the midpoint: d1 d2 / (2 k R) = 5.886 m at k = 4/3.
*
* <p>On a flat profile the curvature-adjusted elevation is the bulge itself.
* Tolerance: 0.05 m.</p>
*/
@Test
void midpointBulgeIsFivePointNineMetres() {
PathProfilePoint midpoint = analyzeFlatPath().profilePoints().get(MIDPOINT_INDEX);
assertEquals(EXPECTED_MIDPOINT_BULGE_M, midpoint.curvatureAdjustedElevationMeters(), 0.05,
"curvature-adjusted elevation at 10 km");
}
/**
* Line of sight: 10 m antenna height minus 5.886 m bulge leaves 4.114 m, so the
* direct path is free and the minimum clearance of the whole profile is the midpoint
* value. Tolerance: 0.05 m.
*/
@Test
void lineOfSightIsFreeWithFourMetresClearanceAtTheMidpoint() {
PathAnalysisResult result = analyzeFlatPath();
PathProfilePoint midpoint = result.profilePoints().get(MIDPOINT_INDEX);
assertEquals(ANTENNA_HEIGHT_M, midpoint.lineOfSightHeightMeters(), 1e-9, "LOS height");
assertEquals(EXPECTED_MIDPOINT_LOS_CLEARANCE_M, midpoint.lineOfSightClearanceMeters(), 0.05,
"LOS clearance at 10 km");
assertTrue(result.lineOfSightClear(), "line of sight clear");
assertEquals(EXPECTED_MIDPOINT_LOS_CLEARANCE_M, result.minimumLineOfSightClearanceMeters(), 0.05,
"minimum LOS clearance");
}
/**
* First Fresnel zone: R1 = sqrt(lambda d1 d2 / (d1 + d2)) = 101.921 m at the midpoint,
* far more than the 4.114 m of clearance, so the zone is intruded by
* 5.886 - (10 - 101.921) = 97.807 m at 10 km. Tolerance: 0.1 m.
*/
@Test
void fresnelZoneIsIntrudedByNinetyEightMetresAtTheMidpoint() {
PathAnalysisResult result = analyzeFlatPath();
PathProfilePoint midpoint = result.profilePoints().get(MIDPOINT_INDEX);
double localFresnelRadiusM = midpoint.lineOfSightHeightMeters() - midpoint.fresnelLowerHeightMeters();
assertEquals(EXPECTED_MIDPOINT_FRESNEL_RADIUS_M, localFresnelRadiusM, 0.1, "R1 at 10 km");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, midpoint.fresnelIntrusionMeters(), 0.1, "intrusion at 10 km");
assertFalse(result.fresnelClear(), "Fresnel zone intruded");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, result.worstFresnelIntrusionMeters(), 0.1, "worst intrusion");
assertEquals(10.0, result.worstFresnelDistanceKm(), 1e-9, "worst intrusion distance");
assertEquals(MIDPOINT_INDEX, result.worstFresnelSampleIndex(), "worst intrusion sample");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M / EXPECTED_MIDPOINT_FRESNEL_RADIUS_M,
result.worstFresnelIntrusionRatio(), 1e-3, "worst intrusion ratio");
}
/**
* No knife edge: the obstruction search only considers samples that rise above the
* line of sight (h greater than 0). On a flat profile with free LOS there is none, so
* there is no dominant obstruction and no knife-edge loss estimate.
*/
@Test
void noKnifeEdgeCandidateOnAFreeLineOfSight() {
PathObstructionSummary obstruction = analyzeFlatPath().obstructionSummary();
assertFalse(obstruction.hasDominantLosObstruction(), "dominant obstruction");
assertEquals(-1, obstruction.dominantObstructionSampleIndex(), "obstruction sample index");
assertTrue(Double.isNaN(obstruction.estimatedKnifeEdgeLossDb()), "knife-edge loss is NaN");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, obstruction.worstFresnelIntrusionMeters(), 0.1,
"obstruction summary carries the worst Fresnel intrusion");
}
/**
* Pinned, not endorsed: a partial Fresnel intrusion without LOS blockage adds 0 dB to
* the link budget.
*
* <p>The link budget takes its diffraction term only from a dominant LOS obstruction.
* Here the first Fresnel zone is intruded by 96 percent of its radius yet the direct
* path is open, so the code books 0 dB. ITU-R P.526 eq. (31) would still give a loss
* for negative nu down to -0.78 (here nu = -0.057, about 5.5 dB; in general 0 .. 6 dB
* for an open but Fresnel-obstructed path). Tracked as a separate issue; this test
* records the current behaviour. Tolerance: 0.01 dB.</p>
*/
@Test
void partialFresnelIntrusionWithoutLosBlockageContributesZeroDiffractionLoss() {
PathLinkBudgetSummary budget = analyzeFlatPath().linkBudgetSummary();
assertEquals(0.0, budget.diffractionLossDb(), 0.01, "diffraction loss in the link budget");
}
}
@@ -0,0 +1,253 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Reference cases for {@link PathGeometryUtils} against ITU-R P.525 / P.526 and
* textbook values.
*
* <p>Every expected value is stated in the test method's Javadoc together with its
* source, the equation and the tolerance. Where the project deliberately deviates from
* the standard (the 32.44 constant of the free-space loss) the code's value is pinned
* and the standard's value is documented next to it. Nothing here was derived by
* running the code under test.</p>
*
* <p>Context: issue #108.</p>
*/
class PathGeometryUtilsReferenceTest {
private static final double SPEED_OF_LIGHT_M_PER_S = 299_792_458.0;
/**
* Great-circle distance and initial bearing, Veness worked example.
*
* <p>Source: Chris Veness, "Calculate distance, bearing and more between
* Latitude/Longitude points" (movable-type.co.uk/scripts/latlong.html): from
* 50 03 59 N, 005 42 53 W to 58 38 38 N, 003 04 12 W the haversine distance is
* 968.9 km on R = 6371 km and the initial bearing is 9.12 degrees. Recomputed on the
* project's R = 6371.009 km the distance is 968.855 km. Tolerance: 0.1 km, 0.01 deg.</p>
*/
@Test
void greatCircleDistanceAndBearingMatchTheVenessWorkedExample() {
double fromLat = 50.0 + 3.0 / 60.0 + 59.0 / 3600.0;
double fromLon = -(5.0 + 42.0 / 60.0 + 53.0 / 3600.0);
double toLat = 58.0 + 38.0 / 60.0 + 38.0 / 3600.0;
double toLon = -(3.0 + 4.0 / 60.0 + 12.0 / 3600.0);
assertEquals(968.855,
PathGeometryUtils.calculateGreatCircleDistanceKm(fromLat, fromLon, toLat, toLon),
0.1, "Veness distance on R = 6371.009 km");
assertEquals(9.1198,
PathGeometryUtils.calculateInitialBearingDeg(fromLat, fromLon, toLat, toLon),
0.01, "Veness initial bearing");
}
/**
* First Fresnel zone radius, ITU-R P.526-15 section 2.1 eq. (2).
*
* <p>R_n = sqrt(n * lambda * d1 * d2 / (d1 + d2)) with n = 1 and lambda = c / f,
* c = 299 792 458 m/s. For d1 = d2 = 50 km at 144.3 MHz this is 227.9015 m, for
* d1 = 10 km, d2 = 40 km at 432.2 MHz it is 74.4926 m. Tolerance: 1e-3 m against the
* literal, relative 1e-6 against the equation evaluated in the test.</p>
*/
@Test
void firstFresnelRadiusMatchesP526Equation2() {
double symmetric = PathGeometryUtils.calculateFirstFresnelRadiusMeters(50.0, 100.0, 144.3);
double asymmetric = PathGeometryUtils.calculateFirstFresnelRadiusMeters(10.0, 50.0, 432.2);
assertEquals(227.9015, symmetric, 1e-3, "R1 for d1 = d2 = 50 km at 144.3 MHz");
assertEquals(74.4926, asymmetric, 1e-3, "R1 for d1 = 10 km, d2 = 40 km at 432.2 MHz");
double expectedSymmetric = fresnelRadiusEq2(50_000.0, 50_000.0, 144.3);
double expectedAsymmetric = fresnelRadiusEq2(10_000.0, 40_000.0, 432.2);
assertEquals(expectedSymmetric, symmetric, expectedSymmetric * 1e-6, "R1 vs eq. (2), symmetric");
assertEquals(expectedAsymmetric, asymmetric, expectedAsymmetric * 1e-6, "R1 vs eq. (2), asymmetric");
}
/**
* First Fresnel zone radius against the practical form, ITU-R P.526-15 eq. (3).
*
* <p>R_1 = 550 * sqrt(d1 * d2 / ((d1 + d2) * f)) with d in km and f in MHz. The
* constant 550 carries the rounding of sqrt(c) in these units, so the two forms
* differ by about 0.45 percent (228.928 m vs 227.902 m, 74.828 m vs 74.493 m).
* Tolerance: 0.5 percent.</p>
*/
@Test
void firstFresnelRadiusAgreesWithP526Equation3WithinHalfAPercent() {
assertRelative(fresnelRadiusEq3(50.0, 50.0, 144.3),
PathGeometryUtils.calculateFirstFresnelRadiusMeters(50.0, 100.0, 144.3),
0.005, "R1 vs eq. (3), symmetric");
assertRelative(fresnelRadiusEq3(10.0, 40.0, 432.2),
PathGeometryUtils.calculateFirstFresnelRadiusMeters(10.0, 50.0, 432.2),
0.005, "R1 vs eq. (3), asymmetric");
}
/**
* Recommended clearance is 60 percent of the first Fresnel radius.
*
* <p>Source: the usual planning rule (ITU-R P.530, "0.6 F1 clearance"). Expected
* 0.6 * 227.9015 m = 136.7409 m. Tolerance: 1e-3 m.</p>
*/
@Test
void recommendedClearanceIsSixtyPercentOfTheFirstFresnelRadius() {
assertEquals(136.7409,
PathGeometryUtils.calculateRecommendedFresnelClearanceMeters(50.0, 100.0, 144.3),
1e-3, "0.6 * R1 for d1 = d2 = 50 km at 144.3 MHz");
}
/**
* Earth bulge below the straight line between the endpoints.
*
* <p>h = d1 * d2 / (2 * k * R) with R = 6 371 009 m. For d1 = d2 = 50 km: 147.1509 m
* at k = 4/3 and 196.2013 m at k = 1. Tolerance: 1e-3 m. The k = 4/3 value is also
* checked against the effective Earth radius of 8500 km that ITU-R P.834 and the IEV
* quote for the standard atmosphere (147.0588 m), within 0.1 percent.</p>
*/
@Test
void earthBulgeMatchesTheGeometricFormulaAndThe8500KmEffectiveRadius() {
double bulgeK43 = PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0, 4.0 / 3.0);
double bulgeK1 = PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0, 1.0);
assertEquals(147.1509, bulgeK43, 1e-3, "bulge at k = 4/3");
assertEquals(196.2013, bulgeK1, 1e-3, "bulge at k = 1");
assertEquals(bulgeK43, PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0), 1e-9,
"two-argument overload uses k = 4/3");
double bulge8500Km = 50_000.0 * 50_000.0 / (2.0 * 8_500_000.0);
assertRelative(bulge8500Km, bulgeK43, 0.001, "bulge vs 8500 km effective radius");
}
/**
* Radio horizon distance for an antenna height of 100 m.
*
* <p>d = sqrt(2 * k * R * h) with R = 6 371 009 m: 41.2181 km at k = 4/3 and
* 35.6960 km at k = 1. Tolerance: 1e-3 km. The textbook approximations
* d = 4.12 * sqrt(h) (radio, k = 4/3) and d = 3.57 * sqrt(h) (optical, k = 1), d in
* km and h in m, are met within 0.1 percent.</p>
*/
@Test
void radioHorizonMatchesSqrtTwoKRHAndTheTextbookApproximations() {
double horizonK43 = PathGeometryUtils.calculateRadioHorizonDistanceKm(100.0, 4.0 / 3.0);
double horizonK1 = PathGeometryUtils.calculateRadioHorizonDistanceKm(100.0, 1.0);
assertEquals(41.2181, horizonK43, 1e-3, "horizon at k = 4/3");
assertEquals(35.6960, horizonK1, 1e-3, "horizon at k = 1");
assertRelative(4.12 * Math.sqrt(100.0), horizonK43, 0.001, "horizon vs 4.12 sqrt(h)");
assertRelative(3.57 * Math.sqrt(100.0), horizonK1, 0.001, "horizon vs 3.57 sqrt(h)");
}
/**
* Single knife-edge diffraction loss, ITU-R P.526-15 eq. (31).
*
* <p>J(nu) = 6.9 + 20 log10(sqrt((nu - 0.1)^2 + 1) + nu - 0.1) for nu greater than
* -0.78, otherwise 0. Expected: J(0) = 6.033, J(1) = 13.926, J(2.4) = 20.539,
* J(-0.5) = 1.959, J(-1) = 0. Tolerance: 0.01 dB.</p>
*
* <p>At exactly nu = -0.78 the formula gives 0.004 dB; the code compares with
* {@code v <= -0.78} and answers 0 there. That is pinned here, not endorsed: the step
* is 0.004 dB and of no practical consequence, but the boundary belongs to the
* zero branch in this implementation.</p>
*/
@Test
void knifeEdgeLossMatchesP526Equation31() {
assertEquals(6.033, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(0.0), 0.01, "J(0)");
assertEquals(13.926, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(1.0), 0.01, "J(1)");
assertEquals(20.539, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(2.4), 0.01, "J(2.4)");
assertEquals(1.959, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-0.5), 0.01, "J(-0.5)");
assertEquals(0.0, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-1.0), 0.0, "J(-1)");
// Pinned: the boundary nu = -0.78 falls into the zero branch (v <= -0.78).
assertEquals(0.0, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-0.78), 0.0, "J(-0.78)");
}
/**
* Fresnel-Kirchhoff diffraction parameter, ITU-R P.526-15 eq. (26) in SI units.
*
* <p>nu = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)). For h = 50 m, d1 = d2 = 25 km
* at 144.3 MHz (lambda = 2.0776 m): nu = 0.4388. Tolerance: 1e-4.</p>
*/
@Test
void knifeEdgeVParameterMatchesP526Equation26() {
assertEquals(0.4388,
PathGeometryUtils.calculateKnifeEdgeVParameter(50.0, 25.0, 25.0, 144.3),
1e-4, "nu for h = 50 m, d1 = d2 = 25 km, 144.3 MHz");
}
/**
* Free-space basic transmission loss for 100 km at 144.3 MHz.
*
* <p>The code uses L = 32.44 + 20 log10(d/km) + 20 log10(f/MHz), which gives
* 115.625 dB; that value is pinned (tolerance 0.001 dB) as decided in #108.</p>
*
* <p>For the record: ITU-R P.525-5 eq. (6) writes the constant as 32.4 (115.585 dB
* here), and the exact constant 20 log10(4 pi * 1e9 / c) = 32.4478 gives 115.633 dB.
* The project keeps 32.44; the spread of 0.05 dB is far below the uncertainty of
* the rest of the link budget.</p>
*/
@Test
void freeSpacePathLossUsesTheProjectConstant32Point44() {
double lossDb = PathGeometryUtils.calculateFreeSpacePathLossDb(100.0, 144.3);
assertEquals(115.625, lossDb, 0.001, "FSPL 100 km / 144.3 MHz with 32.44");
// Documented, not asserted as the project's answer: the standard's variants.
double p525Variant = 32.4 + 20.0 * Math.log10(100.0) + 20.0 * Math.log10(144.3);
double exactConstant = 20.0 * Math.log10(4.0 * Math.PI * 1e9 / SPEED_OF_LIGHT_M_PER_S);
double exactVariant = exactConstant + 20.0 * Math.log10(100.0) + 20.0 * Math.log10(144.3);
assertEquals(115.585, p525Variant, 0.001, "P.525-5 eq. (6) with 32.4");
assertEquals(115.633, exactVariant, 0.001, "exact constant 32.4478");
}
/**
* Watts to dBm: P_dBm = 10 log10(P_W * 1000).
*
* <p>750 W = 58.751 dBm, 100 W = 50.000 dBm. Tolerance: 1e-3 dB.</p>
*/
@Test
void wattsToDbmMatchesTheDefinition() {
assertEquals(58.751, PathGeometryUtils.wattsToDbm(750.0), 1e-3, "750 W");
assertEquals(50.0, PathGeometryUtils.wattsToDbm(100.0), 1e-3, "100 W");
}
/**
* Adaptive sample count: ceil(d / 0.5 km) + 1, clamped to 121 .. 1201.
*
* <p>0 km (non-positive) yields the minimum 121, 50 km computes 101 and is clamped
* to 121, 100 km gives 201, 1000 km computes 2001 and is clamped to 1201.</p>
*/
@Test
void adaptiveSampleCountFollowsTheHalfKilometreRuleWithClamping() {
assertEquals(121, PathGeometryUtils.resolveAdaptiveSampleCount(0.0), "0 km");
assertEquals(121, PathGeometryUtils.resolveAdaptiveSampleCount(50.0), "50 km");
assertEquals(201, PathGeometryUtils.resolveAdaptiveSampleCount(100.0), "100 km");
assertEquals(1201, PathGeometryUtils.resolveAdaptiveSampleCount(1000.0), "1000 km");
}
/**
* Effective Earth radius factor sanitising: values outside 0.5 .. 10 fall back to 4/3.
*
* <p>0.4 and 11 are replaced by 4/3, 1.0 is kept. Tolerance: 1e-12.</p>
*/
@Test
void effectiveEarthRadiusFactorOutsideTheAcceptedRangeFallsBackToFourThirds() {
assertEquals(4.0 / 3.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(0.4), 1e-12, "0.4");
assertEquals(4.0 / 3.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(11.0), 1e-12, "11");
assertEquals(1.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(1.0), 1e-12, "1.0");
}
/** ITU-R P.526-15 eq. (2) with n = 1, distances in metres. */
private static double fresnelRadiusEq2(double d1Meters, double d2Meters, double frequencyMHz) {
double wavelengthMeters = SPEED_OF_LIGHT_M_PER_S / (frequencyMHz * 1e6);
return Math.sqrt(wavelengthMeters * d1Meters * d2Meters / (d1Meters + d2Meters));
}
/** ITU-R P.526-15 eq. (3): distances in km, frequency in MHz, result in metres. */
private static double fresnelRadiusEq3(double d1Km, double d2Km, double frequencyMHz) {
return 550.0 * Math.sqrt(d1Km * d2Km / ((d1Km + d2Km) * frequencyMHz));
}
private static void assertRelative(double expected, double actual, double relativeTolerance, String message) {
assertEquals(expected, actual, Math.abs(expected) * relativeTolerance, message);
}
}
@@ -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());
}