Next major release/version1.50 (#89)

* Make DBController work on one database file per instance

The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Allow preferences and chat controller to be bound to a file set

ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Introduce the operator profile model and its registry

Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Select the operator profile at startup

Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Manage operator profiles from the settings window

Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Document operator profiles

Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Make the terrain profile cache multi operator safe

The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Fail cleanly when the registry path has no directory

SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Code Version 1.50

* Add the in-app update concept for 1.50

Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf

* Use one base font size and a per-profile startup design

JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the macOS system menu bar in all windows

On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List direct commits on main in the release notes

GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.

* Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.

* Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.

* Draw the menu bar in Compose, and give the drawing layer a net

What the removal left open (4 commits).

Compose's MenuBar is a javax.swing.JMenuBar set on the window, so Swing draws
it and no theme reaches it; with no look-and-feel configured that meant the
default Metal menu above a themed window. The bar is drawn in the window now,
except on macOS, where the system bar is where that platform expects it. Two
renderers read one description -- mainMenuModel is a plain function of the
state, which also gives the menus their first tests. Closing the main window
quits the application again, and the window writes its size back to the
profile; both had quietly stopped working when the last JavaFX stage went.

Then Etappe 7. Four faults shipped in the settings-window branch alongside a
green suite of 382 tests, because not one of those tests drew anything: a
control that never redrew, a tab strip that clipped mid-word, a form taller
than its window with no way down, and colours that came from Material rather
than the stylesheet. One regression test per class, and the spec's acceptance
criterion is met rather than assumed -- each fault was put back deliberately
and the matching test watched to fail.

The CI had never run the suite at all; it does now, headless. What that took
was not Xvfb: skiko's native library links against libGL and only has to be
loadable, since the drawing tests render in software. Without the package the
whole Compose half of the suite dies with a message that reads like a missing
display. Found by running the job under act rather than on GitHub.

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.

* Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.

* Document the adjustable colours, and record the design and the plan

Both manuals get the Colours tab in full: the six roles, that a change applies at
once but survives a restart only after Save settings, the three ways back with their
keyboard shortcuts, what each provenance label means, and the readability notice that
warns without refusing.

Two things an operator would otherwise have to discover. The reset button is always
drawn in the shipped colours and works when nothing else can be read -- the risk
section of the design says plainly that a keyboard path only helps whoever knows it,
so the manual is where this has to be written down. And the root profile already has
both stylesheet files, because KST4Contest has been writing them at startup for
years, so most of its roles report "from file" rather than "shipped" with identical
values.

PROJECT_CONTEXT.md records the decisions that outlive this stage: the three sources
and their precedence, that a role is a selector and a property, that the theme must
read the store's state rather than remember, where the contrast pairs come from, why
the root profile's file layer always exists, and why the lifeline refuses the active
palette. Also corrects its claim that CSS stays global -- a profile now owns its
stylesheets.

The design document and the implementation plan are kept as they were written,
including the plan's own corrections where measuring the code contradicted it.

* Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.

* Document the two languages, and how to contribute a third

Both manuals get the language picker in full: where it sits, that it applies per profile and
takes effect at once in every open window, and -- at more length -- what is deliberately left
alone and why. An operator who sees an English beacon text in a German client should be able
to read that it is meant that way.

The contribution route is written down in both, because plain text files are only useful if
somebody knows they are there: copy, translate, submit; a partial file is welcome; the build
reports mistakes by file name and writes a worksheet of what is still missing.

PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and
the rule that the addressee decides, that core produces no display text, that incompleteness
is allowed and incorrectness is not, that anything built outside a composition needs its text
read inside one, and that a column id is never translated.

CLAUDE.md said two modules; there are three, and the third never ships. It also states the
German orthography now, which this session got wrong across a spec and two plans: ss after a
short vowel, so muss and dass.

* Remove the implementation plans now that every stage is executed

Thirteen plans, all of them step-by-step instructions for work that is committed. A plan left
behind reads to the next person like an open intention, and these describe a build that no
longer exists -- the first still explains how to introduce Gradle.

The specifications stay. They say why the architecture is what it is, which the code cannot,
and three of them are still argued from. The live-findings note stays too: CLAUDE.md sends
people to it, and its hardest lesson -- that a blocking wait on the AWT event thread deadlocks
the interface while the build stays green -- is written down nowhere else.

Its sweep script stays as well. The note argues explicitly that the script belongs beside the
finding so the next reader can measure rather than believe, and removing it would contradict
the reasoning it illustrates.

Two references in the note and one in the JavaFX-removal spec pointed at plans and now say
where the reasoning went instead.

* Point the JavaFX-removal spec at where its plan's reasoning went

* Remove the stray release-metadata patch from the working tree

A 416-line patch file that was applied long ago and has sat in the repository root since. It
predates this branch and stays in the shared history, where rewriting it would mean rewriting
commits that are already on main and on origin.

* Check the translation files in CI and say what they hold

classes already runs the generator, so an orphan key, a dropped placeholder
or a duplicate fails the compile job with the generator's own message naming
the file -- verified by breaking strings_de.properties on purpose.

A step summary counts each language against the base, so the author of a
translation PR sees what they covered without reading the build log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a translation key that would not form a Kotlin name

The generator escaped every translation value but emitted the key verbatim as
the member identifier, and identifierFor strips only . _ - -- so a key carrying
a space, a brace, a quote or a newline from a \uXXXX escape broke out of
val <name>: String get() = lookup(...) into an init block or property
initializer that runs when Strings is instantiated. Translation files are the
contribution surface that arrives by pull request, so this was a path from a
merged .properties change to code in every client.

The generated identifier is now held to letters-then-letters-and-digits before
any source is written, alongside the existing orphan and collision refusals.
Proven by replaying a file-writing payload against the built generator: it now
throws and writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Port the rebased diagnostics and session logging onto the modules

origin/main landed replayable session logging (MessageHistoryRecorder), file
logging (ApplicationFileLogging) and a user-list refresh split on the old flat
layout. The rebase carried the new files, but three things needed the new
architecture:

- ChatController.fireUserListUpdate() used javafx Platform; core is toolkit-free
  now, so it dispatches through uiDispatcher like every other fire method.
- Two new tests were left in the old src/test/java; moved into core.
- ScoreServiceRefreshTest mocked javafx Platform statically; it now injects the
  DirectUiDispatcher the no-arg constructor installs.

The Leaflet map performance work was dropped with the HTML map it optimised.

classes testClasses and test are green: 1069 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the debug-to-file recording a home in the Compose settings

origin/main added a checkbox to the old JavaFX settings that recorded the whole
ON4KST session to file; the Compose redraw dropped the old settings UI, so the
toggle had no control left even though the preference and its wiring survived
the rebase.

It returns to the message-handling tab: the state writes the preference and asks
the application, through a new SettingsHost callback, to open or close the
error-log handler and the history recorder in the order the old checkbox kept.
The explanatory line and its German translation say that passwords are redacted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix Codex review findings on PR 89: station row key, map selection, sort after filter

- Station row key now includes the chat category, so the same callsign in two
  categories no longer collides.
- Map marker click and Reset view go through the central station selection.
- Sorting now operates on the filtered rows, so filters stay effective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the map drawing test off the network and the user's tile cache

The test assumed no tile ever arrives, but it used the default TileFetcher,
which reads the real disk cache and the OSM tile server. On CI a tile now and
then landed before the frame was rendered and covered the background the
'unreachable tile source' test asserts on. An empty temporary cache and a
source that always fails make the assumption hold by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-08 23:22:11 +02:00
1 parent 3e8457eede
commit 2a5c89b943
600 files changed
+46019 -23704

No files matched your search

@@ -0,0 +1,24 @@
package kst4contest.view
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class BuildInfoTest {
@Test
fun `an unstamped build reports a version that cannot be mistaken for a release`() {
// Tests run from class directories, where no jar manifest exists.
assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version)
assertFalse(BuildInfo.version.first().isDigit(),
"an unstamped version must not look like a release number")
}
@Test
fun `the window title carries the version`() {
val title = BuildInfo.windowTitle("KST4Contest")
assertTrue(title.startsWith("KST4Contest "))
assertTrue(title.endsWith(BuildInfo.version))
}
}
@@ -0,0 +1,56 @@
package kst4contest.view
import kst4contest.controller.OperatorProfilePaths
import kst4contest.view.compose.JavaFxStylesheet
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The file the startup copy writes is the file the root profile's palette layer reads.
*
* These were two independent literals of the same string in two classes, and they had to stay
* equal for the root profile's own stylesheet to be read at all -- the copy is written under
* one name and resolved under the other. A rename on either side would silently restore the
* defect this stage was commissioned to fix: a file written at every startup that nothing
* ever reads. The build would stay green and no other test would notice.
*
* Both now derive from `ApplicationConstants`, so there is nothing left to drift. This pins
* that it stays that way.
*/
class StartupStylesheetCopyTest {
private val rootProfile = OperatorProfilePaths.buildRootProfile("Default")
@Test
fun theCopyTargetIsWhatTheRootProfilesPaletteLayerReads() {
assertEquals(
OperatorProfilePaths.paletteRelativeFileName(rootProfile, false),
Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE,
)
assertEquals(
OperatorProfilePaths.paletteRelativeFileName(rootProfile, true),
Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_FILE,
)
}
@Test
fun theCopySourceIsTheSheetTheThemeReads() {
// Written from one resource and read from another would be the same defect, inverted.
assertEquals(
JavaFxStylesheet.DAYLIGHT_RESOURCE,
Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE,
)
assertEquals(
JavaFxStylesheet.EVENING_RESOURCE,
Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_RESOURCE,
)
}
@Test
fun theResourceIsTheFileNameWithALeadingSlash() {
assertEquals(
"/" + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE,
Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE,
)
}
}
@@ -0,0 +1,107 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The aircraft-reflection cell, shared by the station list and the directed messages.
*
* Both JavaFX tables built this text by hand and identically: the minutes until the
* aeroplane arrives and how promising the path is, for at most the first two. The
* Compose station list was reading AirPlaneReflectionInfo.toString() instead, which
* yields "DL0ABC > OK1XYZ at <date> 3 planes: [...]" — a defect from Etappe 5a that this
* test closes.
*/
class AirplaneCellTest {
@Test
fun `one aeroplane gives its minutes and its potential`() {
val info = reflectionInfo(plane(minutes = 7, potential = 85))
assertEquals("7 (85%)", AirplaneCell.format(info))
}
@Test
fun `two aeroplanes are separated by a slash`() {
val info = reflectionInfo(plane(7, 85), plane(12, 60))
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
}
/** Only the first two fit in the column; the rest are in the tooltip's business. */
@Test
fun `a third aeroplane is not shown`() {
val info = reflectionInfo(plane(7, 85), plane(12, 60), plane(20, 30))
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
}
/**
* "nil" and not an empty cell: the operator has to tell "no aeroplane is coming"
* apart from "nothing has been computed for this station yet".
*/
@Test
fun `no aeroplanes reads nil`() {
assertEquals("nil", AirplaneCell.format(reflectionInfo()))
}
@Test
fun `an unset aeroplane list reads nil too`() {
val info = AirPlaneReflectionInfo()
info.risingAirplanes = null
assertEquals("nil", AirplaneCell.format(info))
}
/** Nothing computed yet leaves the cell blank, as both JavaFX tables did. */
@Test
fun `no reflection info at all leaves the cell empty`() {
assertEquals("", AirplaneCell.format(null))
}
// ---- the colour ---------------------------------------------------------
/**
* The colour is what an aircraft-scatter chance is spotted by; the numbers are read
* afterwards. Both stylesheets use the same three: purple for a certain path, red for a
* good one, orange for a fair one.
*/
@Test
fun `a certain path is purple, a good one red, a fair one orange`() {
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (100%)")?.foreground)
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (75%)")?.foreground)
assertEquals(AirplaneCell.ORANGE_50, AirplaneCell.accentFor("7 (50%)")?.foreground)
}
/**
* Pinned as found: JavaFX matched the rendered string, so only exactly 100, 75 and 50
* are coloured — 85% gets nothing. Colouring by threshold instead would light up cells
* the operator has never seen lit, which changes what the column means at a glance.
*/
@Test
fun `a potential between the three steps is not coloured`() {
assertNull(AirplaneCell.accentFor("7 (85%)"))
assertNull(AirplaneCell.accentFor("7 (60%)"))
assertNull(AirplaneCell.accentFor("7 (49%)"))
}
@Test
fun `nothing coming is not coloured`() {
assertNull(AirplaneCell.accentFor("nil"))
assertNull(AirplaneCell.accentFor(""))
}
/** With two aeroplanes the better one decides, as the original's if-chain did. */
@Test
fun `the best of two potentials decides the colour`() {
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (75%) / 12 (100%)")?.foreground)
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
}
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
private fun plane(minutes: Int, potential: Int): AirPlane = AirPlane().apply {
arrivingDurationMinutes = minutes
this.potential = potential
}
}
@@ -0,0 +1,435 @@
package kst4contest.view.compose
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The line the operator actually types into. Everything here is the JavaFX behaviour;
* two places where that behaviour is inconsistent are pinned as they are rather than
* quietly repaired, because both decide what gets transmitted.
*/
class ChatInputStateTest {
private val mainCategory = ChatCategory(2)
private val memberCategory = ChatCategory(3)
// ---- sending -----------------------------------------------------------
@Test
fun `sending queues the resolved text and empties the field`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("test de dn9apw")
state.send()
assertEquals(1, actions.queued.size)
assertEquals("test de dn9apw", actions.queued.single().messageText)
assertEquals("", state.value.text)
}
@Test
fun `the enter key sends the same way the button does`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("cq cq")
state.send()
assertEquals(1, actions.queued.size)
}
/** A variable may legitimately resolve to nothing; then there is no message. */
@Test
fun `a text that resolves to nothing is dropped and the field cleared`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
state.value = TextFieldValue("%qrg%")
state.send()
assertTrue(actions.queued.isEmpty())
assertEquals("", state.value.text)
}
@Test
fun `nothing is queued for an empty field`() {
val actions = FakeActions(resolved = "")
val state = ChatInputState(actions)
state.send()
assertTrue(actions.queued.isEmpty())
}
/** Sending a private message to yourself is a mistake, not a feature. */
@Test
fun `a message addressed to the own callsign is refused`() {
val actions = FakeActions(addressedToSelf = true)
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DN9APW hi")
state.send()
assertTrue(actions.queued.isEmpty())
assertEquals("", state.value.text)
}
@Test
fun `the category comes from the resolver`() {
val actions = FakeActions(category = memberCategory)
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC hi")
state.send()
assertSame(memberCategory, actions.queued.single().chatCategory)
}
/** If the category cannot be resolved at all, the main chat is where it goes. */
@Test
fun `an unresolvable category falls back to the main chat`() {
val actions = FakeActions(category = null)
val state = ChatInputState(actions)
state.value = TextFieldValue("hello")
state.send()
assertSame(mainCategory, actions.queued.single().chatCategory)
}
@Test
fun `an outgoing message is never addressed to the server`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("hello")
state.send()
assertEquals(false, actions.queued.single().isMessageDirectedToServer)
}
/** A /cq starts the reply clock, which is what the no-reply tracking runs on. */
@Test
fun `sending records the outbound cq and asks for a recompute`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC pse 23cm")
state.send()
assertEquals(listOf("/cq DL0ABC pse 23cm"), actions.recordedCqs)
assertEquals(listOf("outbound-tx"), actions.recomputeReasons)
}
// ---- which station the text is resolved against ------------------------
@Test
fun `the panel selection wins over the table`() {
val panel = member("DL0ABC")
val table = member("OK1XYZ")
val actions = FakeActions(panelSelection = panel, tableSelection = table)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertSame(panel, actions.resolvedAgainst.first())
}
@Test
fun `the table selection is used when the panel has none`() {
val table = member("OK1XYZ")
val actions = FakeActions(tableSelection = table)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertSame(table, actions.resolvedAgainst.first())
}
/**
* Pinned as found, not repaired. On the send path the score service's selection is
* consulted only AFTER the variables have been resolved, so a station that only it
* knows about does not reach the resolver — while the shortcut buttons, which use
* the three-step lookup, do see it. Repairing this would change which station a
* variable resolves against, and therefore what gets transmitted.
*/
@Test
fun `on the send path the score service selection reaches the category but not the variables`() {
val scored = member("S50ZZZ")
val actions = FakeActions(scoreSelection = scored)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertNull(actions.resolvedAgainst.first(), "the resolver saw a station it did not see in JavaFX")
assertSame(scored, actions.categoryResolvedAgainst.first())
}
// ---- the shortcut buttons ---------------------------------------------
/** A shortcut button APPENDS, so several can be strung together into one message. */
@Test
fun `a shortcut button appends to what is already typed`() {
val actions = FakeActions(resolved = "pse sked")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ")
state.appendShortcut("pse sked")
assertEquals("/cq DL0ABC pse sked", state.value.text)
}
@Test
fun `a shortcut button resolves its variables before appending`() {
val actions = FakeActions(resolved = "144.300")
val state = ChatInputState(actions)
state.appendShortcut("MYQRGVAR")
assertEquals("144.300", state.value.text)
assertEquals("MYQRGVAR ", actions.resolvedTemplates.single())
}
@Test
fun `a shortcut that resolves to nothing changes nothing`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.appendShortcut("%unset%")
assertEquals("keep me", state.value.text)
}
/** The two QRG buttons append the field contents rather than a resolved template. */
@Test
fun `the MYQRG button appends the own frequency`() {
val actions = FakeActions(ownQrgMain = "144.370")
val state = ChatInputState(actions)
state.value = TextFieldValue("qrg ")
state.appendOwnQrg(mainCategory = true)
assertEquals("qrg 144.370 ", state.value.text)
}
@Test
fun `the SECONDQRG button appends the second frequency`() {
val actions = FakeActions(ownQrgSecond = "432.200")
val state = ChatInputState(actions)
state.appendOwnQrg(mainCategory = false)
assertEquals("432.200 ", state.value.text)
}
// ---- the snippets ------------------------------------------------------
/**
* A snippet REPLACES the field, unlike a shortcut button: it builds a whole
* directed message, so whatever was half typed would only corrupt it.
*/
@Test
fun `a snippet replaces the field with a directed message`() {
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "/cq DL0ABC pse 23cm")
val state = ChatInputState(actions)
state.value = TextFieldValue("half typed")
state.insertSnippet(listOf("pse 23cm", "qrz?"), 0)
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
assertEquals("/cq DL0ABC pse 23cm", actions.resolvedTemplates.single())
}
/** Without a station there is nobody to direct it at, so nothing happens. */
@Test
fun `a snippet without a selected station does nothing`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.insertSnippet(listOf("pse 23cm"), 0)
assertEquals("keep me", state.value.text)
assertTrue(actions.resolvedTemplates.isEmpty())
}
@Test
fun `a snippet index nobody configured does nothing`() {
val actions = FakeActions(panelSelection = member("DL0ABC"))
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.insertSnippet(listOf("only one"), 5)
state.insertSnippet(listOf("only one"), -1)
assertEquals("keep me", state.value.text)
}
@Test
fun `the snippet uses the three step station lookup`() {
val scored = member("S50ZZZ")
val actions = FakeActions(scoreSelection = scored, resolved = "/cq S50ZZZ hi")
val state = ChatInputState(actions)
state.insertSnippet(listOf("hi"), 0)
assertSame(scored, actions.resolvedAgainst.single())
}
// ---- the context menu -------------------------------------------------
/**
* The same snippet roster reaches the field three ways, and all three differ. Ctrl+N
* replaces the field with a directed message; a right-click APPENDS the snippet with
* no trailing space and no /cq prefix; a shortcut button appends its own roster's
* entry followed by a space. Pinned because collapsing any two of them would change
* what goes out.
*/
@Test
fun `a context menu entry appends the resolved snippet without a trailing space`() {
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "pse 23cm")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ")
state.appendSnippet("pse 23cm")
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
assertEquals("pse 23cm", actions.resolvedTemplates.single(), "a space was added to the template")
}
@Test
fun `a context menu entry needs no selected station`() {
val actions = FakeActions(resolved = "qrz?")
val state = ChatInputState(actions)
state.appendSnippet("qrz?")
assertEquals("qrz?", state.value.text)
}
@Test
fun `a context menu entry that resolves to nothing changes nothing`() {
val actions = FakeActions(resolved = "")
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.appendSnippet("%unset%")
assertEquals("keep me", state.value.text)
}
// ---- the caret and the focus ------------------------------------------
/**
* After every insertion the caret sits at the end and the field takes the focus
* back — the operator is mid sentence, and a caret left at position 0 turns the
* next keystroke into a corrupted message.
*/
@Test
fun `an insertion puts the caret at the end`() {
val actions = FakeActions(resolved = "pse sked")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ", selection = TextRange(0))
state.appendShortcut("pse sked")
assertEquals(state.value.text.length, state.value.selection.start)
assertTrue(state.value.selection.collapsed)
}
@Test
fun `an insertion asks for the focus back`() {
val actions = FakeActions(resolved = "x")
val state = ChatInputState(actions)
val before = state.focusRequests
state.appendShortcut("x")
assertTrue(state.focusRequests > before)
}
@Test
fun `a refused insertion does not steal the focus`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
val before = state.focusRequests
state.appendShortcut("%unset%")
assertEquals(before, state.focusRequests)
}
// ---- clearing ----------------------------------------------------------
@Test
fun `clear empties the field without sending`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("unsent")
state.clear()
assertEquals("", state.value.text)
assertTrue(actions.queued.isEmpty())
}
private fun member(call: String): ChatMember = ChatMember().apply {
setCallSign(call)
setChatCategory(memberCategory)
}
/** Real behaviour, recorded; no mocking framework needed for a boundary this small. */
private inner class FakeActions(
private val panelSelection: ChatMember? = null,
private val tableSelection: ChatMember? = null,
private val scoreSelection: ChatMember? = null,
private val resolved: String? = null,
private val addressedToSelf: Boolean = false,
private val category: ChatCategory? = null,
private val ownQrgMain: String = "",
private val ownQrgSecond: String = "",
) : ChatInputActions {
val queued = mutableListOf<ChatMessage>()
val resolvedAgainst = mutableListOf<ChatMember?>()
val categoryResolvedAgainst = mutableListOf<ChatMember?>()
val resolvedTemplates = mutableListOf<String>()
val recordedCqs = mutableListOf<String>()
val recomputeReasons = mutableListOf<String>()
override fun panelSelection(): ChatMember? = panelSelection
override fun tableSelection(): ChatMember? = tableSelection
override fun scoreSelection(): ChatMember? = scoreSelection
override fun resolveVariables(template: String, member: ChatMember?): String? {
resolvedAgainst += member
resolvedTemplates += template
return resolved ?: template
}
override fun isAddressedToOwnCallsign(text: String): Boolean = addressedToSelf
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? {
categoryResolvedAgainst += member
return category
}
override fun mainCategory(): ChatCategory = this@ChatInputStateTest.mainCategory
override fun recordOutboundCq(text: String) { recordedCqs += text }
override fun requestRecompute(reason: String) { recomputeReasons += reason }
override fun queue(message: ChatMessage) { queued += message }
override fun ownQrgMain(): String = ownQrgMain
override fun ownQrgSecond(): String = ownQrgSecond
}
}
@@ -0,0 +1,152 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* The rules of the alert dialog that hold without a screen.
*
* The drawing is covered by the GUI acceptance run, the way `raiseSteps` and
* `deiconifiedState` of ComposeWindowHost split the same way: the rule is the part that
* can be wrong silently.
*
* Button order is in here because getting it wrong is not cosmetic. The operator reaches
* for a position, not a word, and both confirmations this class shows give something up —
* a running contest session, or the whole client.
*/
class ComposeAlertTest {
@Test
fun anAcknowledgementHasOneButtonAndItConfirms() {
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals(listOf(AlertButton("OK", confirming = true)), buttons)
}
@Test
fun closingTheWindowCountsAsNotConfirming() {
// The JavaFX Alert returned Optional.empty when dismissed, and every caller
// treated that as "do not proceed". The window close button must do the same.
assertFalse(dismissalConfirms())
}
/*
* The four cases below are the JavaFX ButtonBar orders, read off JavaFX 21.0.5 on this
* machine rather than from memory:
* LINUX = L_HE+UNYACBXIO_R -> YES < CANCEL_CLOSE < OK_DONE
* MAC_OS = L_HE+U+FBIX_NCYOA_R -> CANCEL_CLOSE < YES < OK_DONE
* WINDOWS = L_E+U+FBXI_YNOCAH_R -> YES < OK_DONE < CANCEL_CLOSE
* The quit dialog used ButtonType.YES, the profile switch used OK_DONE, so the two
* disagree on Linux — which is why a single "confirming button first" rule cannot be
* right for both.
*/
@Test
fun theQuitDialogKeepsYesFirstWhereJavaFxPutItFirst() {
val linux = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_LINUX)
val windows = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_WINDOWS)
assertEquals(listOf("Yes", "Cancel"), linux.map { it.text })
assertEquals(listOf("Yes", "Cancel"), windows.map { it.text })
}
@Test
fun theQuitDialogPutsCancelFirstOnMacOsWhereJavaFxDid() {
val mac = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_MAC_OS)
assertEquals(listOf("Cancel", "Yes"), mac.map { it.text })
}
@Test
fun theProfileSwitchPutsCancelFirstOnLinuxAndMacOs() {
val linux = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
// The regression this pins: a plain "confirming first" rule put the button that
// gives up a live contest session under the cursor's resting place.
assertEquals(listOf("Cancel", "Switch profile"), linux.map { it.text })
assertEquals(listOf("Cancel", "Switch profile"), mac.map { it.text })
}
@Test
fun theProfileSwitchKeepsItsConfirmFirstOnWindows() {
val windows = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_WINDOWS)
assertEquals(listOf("Switch profile", "Cancel"), windows.map { it.text })
}
@Test
fun confirmingStaysMarkedAsConfirmingWhateverTheOrder() {
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
// Reordering must never move the meaning onto the other button.
assertEquals(
mapOf("Cancel" to false, "Switch profile" to true),
mac.associate { it.text to it.confirming },
)
}
@Test
fun theOrderStringIsChosenByOperatingSystem() {
assertEquals(BUTTON_ORDER_MAC_OS, buttonOrderFor("Mac OS X"))
assertEquals(BUTTON_ORDER_WINDOWS, buttonOrderFor("Windows 11"))
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("Linux"))
// Anything else follows Linux, the way JavaFX treated unknown platforms.
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("FreeBSD"))
}
@Test
fun enterConfirmsAndEscapeCancels() {
val buttons = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals("Switch profile", buttonForEnter(buttons)?.text)
assertEquals("Cancel", buttonForEscape(buttons)?.text)
}
/*
* The two below pin the rule that cost three failed application starts: a ComposeDialog
* is a Swing component wrapping a Skia layer, so building it anywhere but the AWT event
* thread makes skiko fail to create its GL context ("Can't wrap nullptr") and the dialog
* never paints. Since a modal wait on an unpaintable dialog cannot end, the client hung
* at startup. No unit test saw it; only running the application did.
*/
@Test
fun aDialogAskedForFromAWorkerThreadIsBuiltOnTheEventThread() {
val onEventThread = AtomicBoolean(false)
// The startup path: main() asks for the profile picker and the startup warning.
openOnEventThread { onEventThread.set(EventQueue.isDispatchThread()) }
assertTrue(
onEventThread.get(),
"a dialog built off the event thread cannot create its graphics context",
)
}
@Test
fun aDialogAskedForOnTheEventThreadIsBuiltInline() {
val ranInline = AtomicBoolean(false)
// The menu path: already on the event thread, where invokeAndWait would throw.
EventQueue.invokeAndWait {
openOnEventThread { ranInline.set(EventQueue.isDispatchThread()) }
}
assertTrue(ranInline.get(), "the dialog must still be built on the event thread")
}
@Test
fun escapeOnAnAcknowledgementPressesItsOnlyButton() {
// No cancel to fall back on: dismissing an acknowledgement is acknowledging it,
// which is what the JavaFX Alert's ESC did with a lone OK button.
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals("OK", buttonForEnter(buttons)?.text)
assertEquals("OK", buttonForEscape(buttons)?.text)
}
}
@@ -0,0 +1,146 @@
package kst4contest.view.compose
import androidx.compose.material3.Text
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* Closing a window from the user-interface thread has to work.
*
* Since the dispatcher moved to the AWT event thread, every window toggle in the Windows
* menu — and the whole teardown in `shutdownRuntime` — calls [ComposeWindowHost.close]
* from that thread. The window's own `application { }` can only advance as continuations
* posted to the same thread, so a plain `Thread.join` there can never be satisfied: the
* interface froze for the timeout and `close()` returned with the window still registered
* as open. The documented consequence is the worst one: a profile switch then found the
* stale flag, raised the dead runtime's window, and the rebuilt profile got no settings
* window at all — the window that carries the Connect button.
*/
class ComposeWindowHostCloseTest {
/**
* Waits until the window with this title is actually showing.
*
* `ComposeWindowHost.isOpen` turns true the moment the window thread enters
* `application { }`, which is well before the window is realised — so a fixed sleep
* after it is a guess, and this test failed on that guess once the machine was busy.
* Frames are read on the event thread, which owns them.
*/
private fun awaitShowingFrame(title: String): java.awt.Frame {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20)
while (System.nanoTime() < deadline) {
var found: java.awt.Frame? = null
EventQueue.invokeAndWait {
found = java.awt.Window.getWindows()
.filterIsInstance<java.awt.Frame>()
.firstOrNull { it.isShowing && it.title == title }
}
found?.let { return it }
Thread.sleep(50)
}
throw AssertionError("the window titled '$title' never became visible")
}
private fun openHost(name: String): ComposeWindowHost {
val host = ComposeWindowHost(name)
host.show(
title = { "close test" },
darkMode = false,
baseFontSizeSp = 12f,
widthDp = 200f,
heightDp = 120f,
) { Text("close test") }
// Wait for the window to really be on screen, so the close has something to close.
awaitShowingFrame("close test")
return host
}
@Test
fun closingFromTheEventThreadCompletesAndClearsTheOpenFlag() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val host = openHost("close-from-edt")
val worker = Executors.newSingleThreadExecutor()
try {
// invokeAndWait so close() really runs ON the event thread, as the menu does.
worker.submit { EventQueue.invokeAndWait { host.close() } }
.get(20, TimeUnit.SECONDS)
assertFalse(
host.isOpen,
"close() must not return while the window still counts as open: the next "
+ "show() would raise the dead window instead of building a new one",
)
} finally {
worker.shutdownNow()
host.close()
}
}
@Test
fun theWindowManagerCloseRunsTheHostsOwnCloseRequestWhenOneIsGiven() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
/*
* The main window's close has to run the application's quit flow, not just close
* the window. Compose's default `onCloseRequest = ::exitApplication` ends that one
* window's `application { }` and leaves the process running with the chat still
* connected and no main window — which is what a tiling window manager's close
* produced: the window vanished and gradle kept reporting the task as executing.
*/
val asked = java.util.concurrent.CountDownLatch(1)
val host = ComposeWindowHost("close-request-override")
host.show(
title = { "close request test" },
darkMode = false,
baseFontSizeSp = 12f,
widthDp = 200f,
heightDp = 120f,
onCloseRequest = { asked.countDown() },
) { Text("close request test") }
/*
* Matched by title rather than by type: the suite runs in one JVM and other tests'
* Compose windows can still be showing, so a type filter sent the event to the
* wrong window and this latch waited for ever.
*/
val mine = awaitShowingFrame("close request test")
try {
// What the window manager sends: a close request on the window itself.
EventQueue.invokeAndWait {
mine.dispatchEvent(
java.awt.event.WindowEvent(mine, java.awt.event.WindowEvent.WINDOW_CLOSING)
)
}
assertTrue(
asked.await(10, TimeUnit.SECONDS),
"the host must route a close request to the handler it was given",
)
} finally {
host.close()
}
}
@Test
fun closingFromAForeignThreadStillWorks() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val host = openHost("close-from-worker")
host.close()
assertFalse(host.isOpen, "a close off the event thread must still wait for the window")
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.awt.Frame
/**
* What a second press on an opener does to the window that is already open.
*
* `ComposeWindowHost.show` refused silently when the window was open, which is correct
* about not building a second one and wrong about everything else: the JavaFX originals
* built a fresh Stage per press and came forward as a side effect, so the operator who
* clicks back to the main window and presses "more" again used to see the window and now
* saw nothing. Six windows share this host, so all six had it.
*
* Only the rules are tested here, but there turned out to be two of them: what to do,
* and what to write when de-iconifying. The first version of this file called the second
* one untestable AWT and got it wrong in the one line it had excused itself from
* covering. `toFront` really is not drivable — its outcome belongs to the window manager
* and is not observable from the JVM — and that is the whole of what is uncovered.
*/
class ComposeWindowHostRaiseTest {
@Test
fun `an ordinary open window is brought forward`() {
assertEquals(
listOf(RaiseStep.TO_FRONT),
raiseSteps(displayable = true, iconified = false),
)
}
/**
* The order is the point, not the content. `toFront` on an iconified frame leaves it
* iconified, so restoring afterwards would raise a window the operator still cannot
* see — the same dead button, one step further along.
*/
@Test
fun `a minimised window is restored before it is raised`() {
assertEquals(
listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT),
raiseSteps(displayable = true, iconified = true),
)
}
/**
* A window on its way out is left alone. `close()` drops visibility and waits for the
* thread; a press landing in that gap must not fight the close and must not raise a
* frame that is about to be disposed.
*/
@Test
fun `a window that is no longer displayable is left alone`() {
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = false))
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = true))
}
/**
* De-iconifying clears one bit; it does not assign a state.
*
* `extendedState` is a bit set — `NORMAL` is 0, `ICONIFIED` is 1, `MAXIMIZED_BOTH` is
* 6 — so a window the operator maximised and then minimised holds 7. Writing
* `Frame.NORMAL` there hands it back restored-down and drops a maximisation nobody
* asked to drop. That is what the first version of `raise()` did, one line after
* taking care to *detect* the mixed state with a mask rather than an equality.
*/
@Test
fun `restoring a maximised window keeps it maximised`() {
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH or Frame.ICONIFIED))
}
@Test
fun `restoring a plain minimised window leaves it normal`() {
assertEquals(Frame.NORMAL, deiconifiedState(Frame.ICONIFIED))
}
@Test
fun `a state without the iconified bit is returned untouched`() {
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH))
assertEquals(Frame.NORMAL, deiconifiedState(Frame.NORMAL))
}
}
@@ -0,0 +1,172 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kotlin.math.abs
/**
* The readability measure, which only warns.
*
* A pure function, and therefore the part that can be wrong without anyone noticing: a
* warning that never fires is indistinguishable from a palette that is fine, and a warning
* that always fires teaches the operator to ignore warnings. The reference numbers below are
* the ones WCAG itself states for black and white.
*
* The pair is an enum rather than its name, deliberately: a translated name here would make
* every one of these filters depend on whichever language ran last in the shared JVM.
*
* The comparison is against the shipped sheet rather than against the bare floor, because
* the shipped sheets do not meet that floor themselves -- measured: the evening accent sits
* at 2.26 on its own surface. What is reported is therefore "this change made a pair harder
* to read than it shipped", which is the question the operator is actually asking.
*/
class ContrastRuleTest {
private val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
private val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
private fun assertClose(expected: Double, actual: Double, what: String) {
assertTrue(abs(expected - actual) < 0.05, "$what: expected ~$expected but was $actual")
}
@Test
fun blackOnWhiteIsTheMaximumOf21() {
assertClose(21.0, contrastRatio(Color.Black, Color.White), "black on white")
assertClose(21.0, contrastRatio(Color.White, Color.Black), "white on black")
}
@Test
fun aColourAgainstItselfIsTheMinimumOf1() {
assertClose(1.0, contrastRatio(Color.Black, Color.Black), "black on black")
assertClose(1.0, contrastRatio(Color(0xFF8899AA), Color(0xFF8899AA)), "grey on grey")
}
@Test
fun theRatioDoesNotDependOnWhichIsTheBackground() {
assertEquals(
contrastRatio(Color(0xFF123456), Color(0xFFEEDDCC)),
contrastRatio(Color(0xFFEEDDCC), Color(0xFF123456)),
)
}
@Test
fun midGreyOnWhiteIsAroundFourAndAHalf() {
// #767676 on white is the canonical WCAG AA boundary example: 4.54.
assertClose(4.54, contrastRatio(Color(0xFF767676), Color.White), "#767676 on white")
}
@Test
fun aShippedSheetNeverWarnsAboutItself() {
/*
* The property the whole relative rule exists for. Both sheets have pairs under the
* floor -- the day accent at 2.86, the evening accent at 2.26 -- so an absolute rule
* would warn on a client nobody has touched.
*/
assertTrue(contrastWarnings(day, day).isEmpty(), "the daylight sheet warns about itself")
assertTrue(
contrastWarnings(evening, evening).isEmpty(),
"the evening sheet warns about itself",
)
}
@Test
fun theShippedSheetsReallyDoHavePairsUnderTheFloor() {
/*
* Measured here rather than asserted in prose, because it is the premise of the rule
* above. If a later change to the sheets makes every pair pass, the relative rule
* becomes equivalent to the absolute one and the deviation can be dropped.
*/
val accentOnSurface = contrastRatio(day.accent, day.base)
assertTrue(
accentOnSurface < CONTRAST_FLOOR,
"the daylight accent now passes at $accentOnSurface; the relative rule may no "
+ "longer be needed",
)
}
@Test
fun blackTextOnABlackSurfaceWarnsAboutEveryPairItAppearsIn() {
val unreadable = day.copy(
base = Color.Black,
windowBackground = Color.Black,
controlInnerBackground = Color.Black,
labelTextFill = Color.Black,
accent = Color.Black,
textAccent = Color.Black,
)
val warnings = contrastWarnings(unreadable, day)
assertEquals(4, warnings.size, "every pair must warn: ${warnings.map { it.what }}")
warnings.forEach {
assertTrue(it.ratio < CONTRAST_FLOOR, "${it.what}")
assertTrue(it.ratio < it.shippedRatio, "${it.what} is not actually worse than shipped")
}
}
@Test
fun blackeningOnlyTheWindowSurfaceWarnsAboutTheTextOnIt() {
/*
* The pair that actually carries almost all the text, and the one the guard missed.
* Theme.kt maps background and surface onto windowBackground and onBackground and
* onSurface onto labelTextFill, so every label in every window sits on this pair --
* while `base` reaches the screen only through the menu strip and the selection tint.
* Checking text against `base` alone let an operator black out every window and be
* told nothing.
*/
val blackWindows = day.copy(windowBackground = Color.Black)
val warnings = contrastWarnings(blackWindows, day)
assertTrue(
warnings.any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE },
"no warning when every label in every window went black on black: "
+ "${warnings.map { it.what }}",
)
}
@Test
fun theAccentIsMeasuredWhereItIsActuallyDrawn() {
/*
* Material's primary is textAccent, not accent, and it is drawn as text and as tab
* underlines on the window surface -- not as `accent` on `base`. Measuring the wrong
* colour on the wrong ground answers a question nobody is asking.
*/
val invisibleAccent = day.copy(textAccent = day.windowBackground)
val warnings = contrastWarnings(invisibleAccent, day)
assertTrue(
warnings.any { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE },
"an accent identical to the surface it is drawn on did not warn: "
+ "${warnings.map { it.what }}",
)
}
@Test
fun anImprovementDoesNotWarnEvenWhileStillUnderTheFloor() {
/*
* The evening accent ships at 2.26. An operator who moves it to something lighter has
* made things better and must not be told off for it -- that is how a guard loses its
* authority.
*/
val better = evening.copy(accent = Color.White)
val accentWarnings = contrastWarnings(better, evening).filter { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE }
assertTrue(accentWarnings.isEmpty(), "an improvement warned: $accentWarnings")
}
@Test
fun aPairThatStaysComfortablyReadableDoesNotWarn() {
// Worse than shipped but still well over the floor: nothing to say.
val slightlyWorse = day.copy(labelTextFill = Color(0xFF444444))
val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.name.startsWith("TEXT") }
assertTrue(textWarnings.isEmpty(), "a readable pair warned: $textWarnings")
}
}
@@ -0,0 +1,559 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The rules of the multi-column table.
*
* Row identity, selection across a refresh and stable sorting are the three places
* where Etappe 2 lost time, and all three were found by watching the window rather
* than by a test. They are pinned here.
*/
class DataTableStateTest {
private class Station(val callSign: String, var qrb: Int)
private class Spot(val text: String)
private fun stationTable() = DataTableState(
columns = listOf(
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
DataColumn(
id = "qrb",
title = "QRB",
value = { s: Station -> s.qrb.toString() },
comparator = compareBy { s: Station -> s.qrb },
),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
@Test
fun `the selection follows the row, not its position`() {
val table = stationTable()
val dn9apw = Station("DN9APW", 100)
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
table.select(dn9apw)
// Something arrives above it, as happens constantly in a live list.
table.replaceRows(listOf(Station("DL1ABC", 50), Station("DN9APW", 100), Station("DO5AMF", 200)))
assertEquals("DN9APW", table.selectedRow?.callSign,
"the station the operator picked is still that station after it moved down")
}
@Test
fun `a selected row that disappears clears the selection`() {
val table = stationTable()
val gone = Station("DN9APW", 100)
table.replaceRows(listOf(gone, Station("DO5AMF", 200)))
table.select(gone)
table.replaceRows(listOf(Station("DO5AMF", 200)))
assertNull(table.selectedRow,
"sliding the selection onto the neighbour would point the operator at "
+ "someone they did not choose")
}
@Test
fun `equal rows keep the order they arrived in`() {
val table = stationTable()
table.replaceRows(
listOf(Station("A", 100), Station("B", 100), Station("C", 100)),
)
table.toggleSort("qrb")
assertEquals(
listOf("A", "B", "C"),
table.visibleRows.map { it.callSign },
"the sort is stable; Etappe 2 lost this and rows jumped",
)
}
@Test
fun `a column with a comparator sorts by it, not by its text`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 9), Station("B", 100)))
table.toggleSort("qrb")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign },
"9 before 100 numerically; by text it would be the other way round")
}
@Test
fun `a column without a comparator sorts by the shown text`() {
val table = stationTable()
table.replaceRows(listOf(Station("DO5AMF", 1), Station("DL1ABC", 2)))
table.toggleSort("callsign")
assertEquals(listOf("DL1ABC", "DO5AMF"), table.visibleRows.map { it.callSign })
}
@Test
fun `clicking the sorted column again reverses it`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 1), Station("B", 2)))
table.toggleSort("qrb")
assertTrue(table.sortAscending)
table.toggleSort("qrb")
assertFalse(table.sortAscending)
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
}
/**
* The third click clears the sorting and puts the rows back in arrival order. JavaFX
* TableView cycles ascending, descending, unsorted for free; losing that meant an
* operator who had sorted the message list once could never get back to "newest at
* the bottom" without restarting.
*/
@Test
fun `a third click clears the sorting and restores arrival order`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
table.toggleSort("qrb")
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
table.toggleSort("qrb")
assertNull(table.sortedColumnId)
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign }, "arrival order not restored")
}
/** After clearing, the next click on the same column starts ascending again. */
@Test
fun `sorting the same column after clearing starts over ascending`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("qrb")
assertEquals("qrb", table.sortedColumnId)
assertTrue(table.sortAscending)
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
}
/** Switching to another column is a fresh ascending sort, not a continuation. */
@Test
fun `switching columns does not inherit the cycle`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("callsign")
assertEquals("callsign", table.sortedColumnId)
assertTrue(table.sortAscending)
}
@Test
fun `an unknown column is ignored rather than clearing the sorting`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("a-column-this-release-no-longer-has")
assertEquals("qrb", table.sortedColumnId,
"a stored layout naming a dropped column must not undo the sorting")
}
@Test
fun `the sorted order is computed once, not on every read`() {
/*
* visibleRows is read on every recomposition. Sorting there means sorting up
* to thirty thousand rows per inbound message, on the thread that draws, and
* a column without a comparator would call its value function once per
* comparison on top of that.
*/
var valueCalls = 0
val table = DataTableState(
columns = listOf(
DataColumn(
id = "callsign",
title = "Callsign",
value = { s: Station -> valueCalls++; s.callSign },
),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
table.replaceRows(listOf(Station("C", 1), Station("A", 2), Station("B", 3)))
table.toggleSort("callsign")
table.visibleRows
val afterFirstRead = valueCalls
repeat(20) { table.visibleRows }
assertEquals(afterFirstRead, valueCalls,
"reading the sorted rows again must not sort them again")
}
@Test
fun `rows read once and then replaced are the new rows`() {
/*
* The regression this pins: caching the sorted order in a plain field meant
* that once the cache was warm the getter never read `rows` again, so nothing
* subscribed to it and the table went on showing what it had. On screen that
* looked like a monitor window that stayed empty while the JavaFX tab beside it
* filled up, and only redrew when an unrelated click forced it to.
*/
val table = stationTable()
table.replaceRows(listOf(Station("DN9APW", 1)))
table.toggleSort("callsign")
assertEquals(listOf("DN9APW"), table.visibleRows.map { it.callSign })
table.replaceRows(listOf(Station("DN9APW", 1), Station("DO5AMF", 2)))
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign },
"a row that arrived after the first read has to be there")
}
@Test
fun `reading the rows subscribes to them even when the order is already computed`() {
/*
* This is the defect a value-checking test cannot see. Compose repaints a
* composable when state it *read* changes. A cache that returns early stops
* reading `rows`, so the next recomposition — triggered by anything else —
* leaves the composable subscribed to nothing, and later rows never arrive on
* screen. That is what a monitor window sitting empty beside a filling JavaFX
* tab looks like, redrawing only when an unrelated click forces it.
*/
val table = stationTable()
table.replaceRows(listOf(Station("DN9APW", 1)))
table.toggleSort("callsign")
table.visibleRows
var reads = 0
Snapshot.observe(readObserver = { reads++ }) { table.visibleRows }
assertTrue(reads > 0,
"a read that touches no state means the table will not repaint when the "
+ "rows change")
}
@Test
fun `changing the sorting after a read re-sorts`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 1), Station("A", 2)))
table.toggleSort("callsign")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
table.toggleSort("callsign")
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
}
@Test
fun `unsorted rows stay in the order they were given`() {
val table = stationTable()
table.replaceRows(listOf(Station("C", 3), Station("A", 1), Station("B", 2)))
assertEquals(listOf("C", "A", "B"), table.visibleRows.map { it.callSign })
}
@Test
fun `two identical messages are two rows, not one`() {
/*
* The reason event lists need reference identity: a key built from their
* fields merges them, and repeating a macro is ordinary operator behaviour.
*/
val table = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = RowKeys.byReference(),
)
val first = Spot("tnx om, 73")
val second = Spot("tnx om, 73")
table.replaceRows(listOf(first, second))
table.select(second)
assertEquals(2, table.visibleRows.size)
assertSame(second, table.selectedRow, "the second one, not the first")
assertFalse(table.isSelected(first))
}
@Test
fun `the key a row is drawn under is its identity, not its position`() {
/*
* The list draws rows keyed by this. A positional key would reshuffle every
* row whenever something arrives at the top, which in a live list is constant.
*/
val table = stationTable()
val dn9apw = Station("DN9APW", 100)
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
val keyAtTop = table.keyOf(dn9apw)
table.replaceRows(listOf(Station("DL1ABC", 50), dn9apw))
assertEquals(keyAtTop, table.keyOf(dn9apw), "same row, same key, new position")
}
@Test
fun `a reference key stays with its row across a refresh`() {
val table = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = RowKeys.byReference(),
)
val kept = Spot("first")
table.replaceRows(listOf(kept))
table.select(kept)
// The roster hands back the same instances plus a new one.
table.replaceRows(listOf(Spot("newer"), kept))
assertSame(kept, table.selectedRow)
}
}
/**
* Column widths survive a restart, and the table is what remembers them.
*
* Storage is ChatPreferences.getTableColumnWidth/setTableColumnWidth, which is
* toolkit-free; only the JavaFX glue around it stays behind. The ids are part of the
* stored layout, so this is also where a renamed column loses an operator's sizing.
*/
class DataTableColumnWidthTest {
private class Station(val callSign: String)
private class RecordingStore : ColumnWidthStore {
val stored = mutableMapOf<String, Double>()
var saveRequests = 0
override fun width(tableId: String, columnId: String): Double? = stored["$tableId/$columnId"]
override fun setWidth(tableId: String, columnId: String, width: Double) {
stored["$tableId/$columnId"] = width
}
override fun requestSave() {
saveRequests++
}
}
private fun table(store: ColumnWidthStore) = DataTableState(
columns = listOf(
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
DataColumn(id = "qra", title = "QRA", value = { _: Station -> "" }),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
tableId = "dx-cluster-monitor",
widths = store,
)
@Test
fun `a stored width is used`() {
val store = RecordingStore()
store.stored["dx-cluster-monitor/callsign"] = 140.0
assertEquals(140.0, table(store).widthOf("callsign"))
}
@Test
fun `a column never sized yet has no stored width`() {
assertNull(table(RecordingStore()).widthOf("qra"),
"the caller falls back to the column's own weight, rather than to a "
+ "number invented here")
}
@Test
fun `resizing stores the width and asks for a save`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("callsign", 180.0)
assertEquals(180.0, table.widthOf("callsign"))
assertEquals(1, store.saveRequests,
"saving is debounced by the layout autosave, not written per pixel")
}
@Test
fun `resizing a column this table does not have is ignored`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("a-column-from-another-table", 180.0)
assertTrue(store.stored.isEmpty())
assertEquals(0, store.saveRequests)
}
@Test
fun `a width at or below zero is refused`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("callsign", 120.0)
table.resizeColumn("callsign", 0.0)
assertEquals(120.0, table.widthOf("callsign"),
"a drag past the left edge must not store a column the operator cannot "
+ "grab again")
}
@Test
fun `a table without a width store simply has no stored widths`() {
val table = DataTableState(
columns = listOf(DataColumn(id = "c", title = "C", value = { _: Station -> "" })),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
table.resizeColumn("c", 100.0)
assertNull(table.widthOf("c"), "the two monitor tables may be built before "
+ "preferences are reachable; that must not throw")
}
}
/**
* The reference keys must not turn into a memory leak, and they are handed out from
* two threads at once.
*
* Both stores behind these tables are capped on purpose — ten thousand cluster
* messages, thirty thousand chat messages — so that memory cannot grow without bound.
* A key map holding a strong reference to every row it ever saw would undo that cap
* from the outside.
*/
class ReferenceRowKeysTest {
private class Spot(val text: String)
private fun table(keys: RowKeySource<Spot>) = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = keys,
)
@Test
fun `keys of rows that have gone are released`() {
val keys = RowKeys.byReference<Spot>()
val table = table(keys)
val first = (1..100).map { Spot("message $it") }
table.replaceRows(first)
// Keys are handed out when the list draws, which is what this stands in for.
first.forEach { table.keyOf(it) }
assertEquals(100, keys.size(), "one key per row on screen")
// The store trims, as ChatController does to stay inside its cap.
val remaining = first.takeLast(11)
table.replaceRows(remaining)
remaining.forEach { table.keyOf(it) }
assertEquals(11, keys.size(),
"a key map that keeps every row it ever saw undoes the cap the store keeps")
}
@Test
fun `a row that stays keeps its key across a refresh`() {
val keys = RowKeys.byReference<Spot>()
val table = table(keys)
val kept = Spot("kept")
table.replaceRows(listOf(kept, Spot("other")))
val before = keys.keyOf(kept)
table.replaceRows(listOf(Spot("newer"), kept))
assertEquals(before, keys.keyOf(kept), "same row, same key")
}
@Test
fun `keys handed out from several threads at once are unique`() {
// The rows are replaced from the JavaFX thread while Compose draws on its own.
val keys = RowKeys.byReference<Spot>()
val rows = (1..500).map { Spot("message $it") }
val handedOut = java.util.concurrent.ConcurrentHashMap.newKeySet<Any>()
val threads = (1..4).map {
Thread { rows.forEach { row -> handedOut.add(keys.keyOf(row)) } }
}
threads.forEach { it.start() }
threads.forEach { it.join() }
assertEquals(500, handedOut.size,
"two rows sharing a key make the list throw; one row with two keys makes "
+ "it jump — both were seen in Etappe 2")
}
}
/**
* Columns can be dragged into another order, as the JavaFX TableView allowed.
*
* Within the session only: JavaFX did not persist the order either — TableLayoutManager
* stores widths and ChatPreferences has no column order at all — and 1.50 is a
* functionally equal port, not an improvement on it.
*/
class ColumnOrderTest {
private class Station(val callSign: String)
private fun table() = DataTableState(
columns = listOf("a", "b", "c").map { id ->
DataColumn(id = id, title = id.uppercase(), value = { _: Station -> id })
},
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
@Test
fun `columns start in the order they were declared`() {
assertEquals(listOf("a", "b", "c"), table().orderedColumns.map { it.id })
}
@Test
fun `a column dragged to the front lands there`() {
val table = table()
table.moveColumn("c", 0)
assertEquals(listOf("c", "a", "b"), table.orderedColumns.map { it.id })
}
@Test
fun `a column dragged to the end lands there`() {
val table = table()
table.moveColumn("a", 2)
assertEquals(listOf("b", "c", "a"), table.orderedColumns.map { it.id })
}
@Test
fun `a target outside the table is clamped rather than dropping the column`() {
val table = table()
table.moveColumn("b", 99)
assertEquals(listOf("a", "c", "b"), table.orderedColumns.map { it.id })
table.moveColumn("b", -5)
assertEquals(listOf("b", "a", "c"), table.orderedColumns.map { it.id })
}
@Test
fun `moving a column this table does not have changes nothing`() {
val table = table()
table.moveColumn("a-column-from-another-table", 0)
assertEquals(listOf("a", "b", "c"), table.orderedColumns.map { it.id })
}
}
@@ -0,0 +1,123 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The messages addressed to this station. Its own pane above the send line, not one of
* the tabs — the operator must be able to see a private call without switching away from
* whatever tab they are reading.
*/
class DirectedMessageColumnsTest {
private val columns = DirectedMessageColumns.all { "formatted: ${it.messageText}" }
/** The stored column widths are keyed by these ids; renaming one loses a setting. */
@Test
fun `the column ids are the ones the widths are stored under`() {
assertEquals(
listOf("time", "callsign", "name", "qra", "qrb", "message", "last-qrg", "airscout", "category"),
columns.map { it.id },
)
}
/** The order operators read left to right, character for character from JavaFX. */
@Test
fun `the titles are the ones on screen today`() {
assertEquals(
listOf("Time", "Callsign", "Name", "QRA", "QRB", "Message", "Last known QRG", "AP [minutes / pot%]", "Category"),
columns.map { it.title() },
)
}
@Test
fun `a message from a known station fills the sender columns`() {
val message = message(sender = member("DL0ABC", name = "Hans", qra = "JO40AA"))
assertEquals("DL0ABC", cell("callsign", message))
assertEquals("Hans", cell("name", message))
assertEquals("JO40AA", cell("qra", message))
}
/**
* A message whose sender is not in the user list any more still has to render. The
* JavaFX cells all guarded for it, and a station leaving the chat mid contest is
* normal, not exceptional.
*/
@Test
fun `a message without a sender still renders every column`() {
val message = message(sender = null)
columns.forEach { column ->
val value = column.value(message)
assertTrue(value.isNotEmpty() || value.isEmpty(), "${column.id} threw")
}
assertEquals("", cell("callsign", message))
assertEquals("", cell("qrb", message))
}
@Test
fun `the message text comes from the formatter the controller provides`() {
val message = message(sender = member("DL0ABC"), text = "pse 23cm")
assertEquals("formatted: pse 23cm", cell("message", message))
}
/** The odd degree sign outside the bracket is JavaFX's; operators read it that way. */
@Test
fun `the qrb cell carries the distance and the bearing`() {
val sender = member("DL0ABC").apply {
qrb = 342.7
setQTFdirection(88.4)
}
assertEquals("342 km (88)°", cell("qrb", message(sender = sender)))
}
/** Own messages get no distance to themselves. */
@Test
fun `a message from the own station has no qrb`() {
val sender = member("DN9APW").apply { qrb = 0.0 }
val cols = DirectedMessageColumns.all(ownCallSign = { "DN9APW" }) { "x" }
val cell = cols.single { it.id == "qrb" }.value(message(sender = sender))
assertEquals("", cell)
}
@Test
fun `the airscout cell is the shared one`() {
val sender = member("DL0ABC").apply {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(
AirPlane().apply { arrivingDurationMinutes = 7; potential = 85 },
)
}
}
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
}
@Test
fun `the category names the chat the message arrived in`() {
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
assertTrue(cell("category", message).isNotBlank())
}
private fun cell(id: String, message: ChatMessage): String =
columns.single { it.id == id }.value(message)
private fun message(sender: ChatMember?, text: String = "hello"): ChatMessage =
ChatMessage().apply {
this.sender = sender
messageText = text
chatCategory = ChatCategory(2)
}
private fun member(call: String, name: String = "", qra: String = ""): ChatMember =
ChatMember().apply {
setCallSign(call)
this.name = name
this.qra = qra
}
}
@@ -0,0 +1,75 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* A new private message is highlighted and the highlight fades over five minutes, so a call
* that arrived while the operator was looking elsewhere is still findable — noticing one is
* what that pane is for. Own messages get their own colour instead.
*
* The steps and the colours are PrivateMessageRowStyleResolver's and the stylesheets', which
* both designs share.
*/
class DirectedMessageRowStyleTest {
@Test
fun `a message just arrived is brightest`() {
val accent = DirectedMessageRowStyle.accentFor(ownMessage = false, ageSeconds = 0)
assertEquals(DirectedMessageRowStyle.FRESH, accent?.background)
}
/** Six steps, each dimmer than the last, so age is readable without reading the clock. */
@Test
fun `the highlight fades step by step`() {
val ages = listOf(30L, 60L, 90L, 120L, 180L, 300L)
val colours = ages.map { DirectedMessageRowStyle.accentFor(false, it)?.background }
assertEquals(ages.size, colours.distinct().size, "two steps share a colour")
colours.forEach { assertNotEquals(null, it) }
}
/** The step boundaries are inclusive, as the resolver's `<=` is. */
@Test
fun `a boundary age belongs to the brighter step`() {
assertEquals(
DirectedMessageRowStyle.accentFor(false, 30),
DirectedMessageRowStyle.accentFor(false, 29),
)
assertNotEquals(
DirectedMessageRowStyle.accentFor(false, 30),
DirectedMessageRowStyle.accentFor(false, 31),
)
}
/** After five minutes it is no longer news and the row goes back to normal. */
@Test
fun `an old message is not highlighted at all`() {
assertNull(DirectedMessageRowStyle.accentFor(false, 301))
assertNull(DirectedMessageRowStyle.accentFor(false, 60 * 60))
}
/** Own messages are blue at any age: they are context, not news. */
@Test
fun `an own message keeps its own colour however old`() {
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 0)?.background)
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 9999)?.background)
}
/** The resolver in the JavaFX view decides the steps; this must not drift from it. */
@Test
fun `the steps are the resolver's own`() {
listOf(0L, 30L, 60L, 90L, 120L, 180L, 300L).forEach { age ->
val styleClass = kst4contest.view.PrivateMessageRowStyleResolver
.resolveStyleClass(false, age)
val accent = DirectedMessageRowStyle.accentFor(false, age)
assertEquals(
styleClass != null,
accent != null,
"age $age: the resolver says $styleClass but the accent says $accent",
)
}
}
}
@@ -0,0 +1,207 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.tabs.MessageHandlingTab
import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The four ways the drawing layer went wrong while the build was green.
*
* Every one of these shipped in the branch at the same time as a passing suite of 382
* tests, because not one of those tests drew anything. The state-level tests and the two
* settings coverage nets answer "was the setter called"; these answer "did it reach the
* screen", which is a different question and the one the operator asks.
*
* | Class | How it showed up |
* |---|---|
* | A control reads a non-observable value and never redraws | every keystroke in the settings was written through and not displayed |
* | A layout clips instead of wrapping | the tab strip cut "Workedstn database" mid-word |
* | Content is taller than the window and unreachable | a tab's form ended mid-way with nothing to say more was below |
* | Colours do not come from the stylesheet | the window surface took `-fx-base` instead of `derive(-fx-base, 26.4%)`; the accent was Material violet |
*
* All four run without a display: `runComposeUiTest` reaches the semantics tree headless,
* and the colour check reads the palette the theme hands out. That was measured before
* these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about.
*/
class DrawingLayerRegressionTest {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
}
// ---------------------------------------------------------------- class 1
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences()
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
// The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) })
// MessageHandlingTab has exactly one text field, so this finder is unambiguous.
onNode(hasSetTextAction()).performTextInput("qrv 144.300")
assertEquals(
"qrv 144.300",
prefs.getMessageHandling_autoAnswerTextMainCat(),
"the value did not reach the preferences",
)
onNodeWithText("qrv 144.300").assertExists()
}
// ---------------------------------------------------------------- class 2
private val tabTitles = listOf(
"Station", "Log synch", "TRX synch", "Airscout", "Notification",
"Message handling", "Beacon", "Shortcuts", "GUI", "Profiles",
"Workedstn database",
)
/**
* The strip inside a box of a given width.
*
* The width is forced with a modifier rather than by sizing the test window, which is
* what makes this a measurement instead of a does-it-crash check: the same content is
* laid out twice and the two results are compared.
*/
private fun stripAt(widthDp: Int): @Composable () -> Unit = themed {
Box(Modifier.width(widthDp.dp)) {
SettingsTabStrip(
tabs = tabTitles.map { title -> SettingsTab(title) { Text(title) } },
selectedIndex = 0,
onSelect = {},
)
}
}
@OptIn(ExperimentalTestApi::class)
@Test
fun theTabStripWrapsRatherThanSqueezingItsTitles() = runComposeUiTest {
setContent(stripAt(WIDE_ENOUGH_FOR_EVERY_TAB))
val wide = onNodeWithText(LONGEST_TAB).fetchSemanticsNode()
val wideWidth = wide.size.width
val wideTop = wide.positionInRoot.y
assertTrue(wideWidth > 0, "the longest tab title was not laid out at all")
setContent(stripAt(TOO_NARROW_FOR_ONE_ROW))
val narrow = onNodeWithText(LONGEST_TAB).fetchSemanticsNode()
assertEquals(
wideWidth,
narrow.size.width,
"the tab got narrower when the row did, which means it is being squeezed "
+ "instead of wrapped — that is what cut '$LONGEST_TAB' mid-word",
)
assertTrue(
narrow.positionInRoot.y > wideTop,
"eleven tabs fitted on one line at $TOO_NARROW_FOR_ONE_ROW dp, so nothing "
+ "was measured; pick a narrower width",
)
}
// ---------------------------------------------------------------- class 3
@OptIn(ExperimentalTestApi::class)
@Test
fun aFormTallerThanTheWindowCanStillBeReached() = runComposeUiTest {
val fieldCount = 40
val lastLabel = "field $fieldCount"
setContent(
themed {
// The window's own scroll region, with content that cannot possibly fit.
SettingsContent(
tabs = listOf(
SettingsTab("Tall") {
Column {
(1..fieldCount).forEach { Text("field $it", Modifier.height(40.dp)) }
}
},
),
notices = SettingsNotices(),
buttons = {},
close = {},
)
}
)
/*
* performScrollTo fails if nothing in the chain scrolls. That is exactly the fault:
* each tab used to scroll for itself, so content simply ended mid-form with no
* scrollbar and no way down.
*/
onNodeWithText(lastLabel).performScrollTo().assertExists()
}
// ---------------------------------------------------------------- class 4
@Test
fun theWindowSurfaceIsDerivedFromTheBaseAndNotTheBaseItself() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
/*
* The fault was taking -fx-base for the window surface. The sheets derive it, and
* the two are visibly different greys — which is why the first Compose windows
* looked flat against the JavaFX ones beside them.
*/
assertTrue(
evening.windowBackground != evening.base,
"the window surface is the raw base colour again",
)
}
@Test
fun theAccentComesFromTheStylesheetAndNotFromMaterial() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
/*
* Material's default primary is a violet (#6650a4 family). The sheets name their
* own accent, and the two sheets name different ones — so a palette that agreed
* with Material, or a palette that was the same in both sheets, would mean the
* stylesheet is not being read.
*/
val materialViolet = androidx.compose.ui.graphics.Color(0xFF6650A4)
assertTrue(evening.accent != materialViolet, "the accent is Material's, not the sheet's")
assertTrue(day.accent != materialViolet, "the accent is Material's, not the sheet's")
assertTrue(
evening.accent != day.accent || evening.base != day.base,
"both sheets produced the same palette, so neither was really read",
)
}
private companion object {
const val LONGEST_TAB = "Workedstn database"
/** Comfortably more than the eleven tabs need, so none of them wraps. */
const val WIDE_ENOUGH_FOR_EVERY_TAB = 2000
/** Narrow enough that the row must break; the wrap is the point of the test. */
const val TOO_NARROW_FOR_ONE_ROW = 300
}
}
@@ -0,0 +1,220 @@
package kst4contest.view.compose
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
class EditableListStateTest {
@Test
fun `a new entry lands at the top and becomes the selection`() {
val state = EditableListState(listOf("alt"))
state.addAtTop("neu")
assertEquals(listOf("neu", "alt"), state.texts)
assertEquals(0, state.selectedIndex,
"the JavaFX table selected row 0 and opened it for editing")
}
@Test
fun `an entry edited to blank disappears`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, " ")
assertEquals(listOf("a", "c"), state.texts,
"clearing the text was how the operator removed an entry")
}
@Test
fun `an edit keeps the other entries and their order`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, "B")
assertEquals(listOf("a", "B", "c"), state.texts)
}
@Test
fun `removing the last entry clears the selection`() {
val state = EditableListState(listOf("a"))
state.select(0)
state.removeAt(0)
assertEquals(emptyList<String>(), state.texts)
assertNull(state.selectedIndex)
}
@Test
fun `an index outside the list is ignored`() {
val state = EditableListState(listOf("a"))
state.updateAt(5, "x")
state.removeAt(-1)
assertEquals(listOf("a"), state.texts,
"a stale index from a pending edit must not corrupt the list")
}
@Test
fun `every entry keeps a stable key across an insertion at the top`() {
val state = EditableListState(listOf("a", "b"))
val keyOfA = state.entries[0].key
state.addAtTop("neu")
assertEquals(keyOfA, state.entries[1].key,
"a row must keep its key when another is inserted above it, or the "
+ "selection jumps in a LazyColumn")
}
@Test
fun `duplicate texts get distinct keys`() {
val state = EditableListState(listOf("gleich", "gleich"))
assertNotEquals(state.entries[0].key, state.entries[1].key,
"keying by text would collapse duplicates into one row")
}
@Test
fun `committing fills the existing roster instead of replacing it`() {
val roster = SimpleRoster<String>()
roster.add("alt")
val state = EditableListState(listOf("neu"))
state.commitTo(roster)
assertEquals(listOf("neu"), roster.snapshot())
assertSame(roster, roster,
"replacing the roster would orphan every listener registered on it")
}
@Test
fun `a new entry can also be appended`() {
val state = EditableListState(listOf("a"))
state.addAtBottom("b")
assertEquals(listOf("a", "b"), state.texts,
"the monitoring list appended: chatcontroller.getLstNotify_...().add(call)")
assertEquals(1, state.selectedIndex)
}
@Test
fun `moving the selection down swaps it with the entry below`() {
val state = EditableListState(listOf("a", "b", "c"))
state.select(0)
assertTrue(state.moveSelected(1))
assertEquals(listOf("b", "a", "c"), state.texts)
assertEquals(1, state.selectedIndex, "the moved row stays selected")
}
@Test
fun `moving the selection up swaps it with the entry above`() {
val state = EditableListState(listOf("a", "b", "c"))
state.select(2)
assertTrue(state.moveSelected(-1))
assertEquals(listOf("a", "c", "b"), state.texts)
assertEquals(1, state.selectedIndex)
}
@Test
fun `a move past either end changes nothing`() {
val state = EditableListState(listOf("a", "b"))
state.select(0)
assertFalse(state.moveSelected(-1))
state.select(1)
assertFalse(state.moveSelected(1))
assertEquals(listOf("a", "b"), state.texts)
}
@Test
fun `a move without a selection changes nothing`() {
val state = EditableListState(listOf("a", "b"))
assertFalse(state.moveSelected(1))
assertEquals(listOf("a", "b"), state.texts)
}
@Test
fun `committing an edit stores the text when no rule objects`() {
val state = EditableListState(listOf("a", "b"))
val outcome = state.commitAt(1, "B")
assertEquals(CommitOutcome.Stored("B"), outcome)
assertEquals(listOf("a", "B"), state.texts)
}
@Test
fun `committing a blank text removes the entry`() {
val state = EditableListState(listOf("a", "b"))
val outcome = state.commitAt(1, " ")
assertEquals(CommitOutcome.Removed, outcome)
assertEquals(listOf("a"), state.texts)
}
@Test
fun `a rule may rewrite the committed text`() {
val upperCase = EntryRule { candidate, _, _ ->
if (candidate.isBlank()) CommitOutcome.Removed
else CommitOutcome.Stored(candidate.uppercase())
}
val state = EditableListState(listOf("dn9apw"), upperCase)
state.commitAt(0, "do5amf")
assertEquals(listOf("DO5AMF"), state.texts)
}
@Test
fun `a refused edit leaves the stored text alone`() {
val noB = EntryRule { candidate, _, _ ->
if (candidate == "b") CommitOutcome.Refused("no b here") else CommitOutcome.Stored(candidate)
}
val state = EditableListState(listOf("a"), noB)
val outcome = state.commitAt(0, "b")
assertEquals(CommitOutcome.Refused("no b here"), outcome)
assertEquals(listOf("a"), state.texts,
"the JavaFX table refreshed the cell back to the stored value")
}
@Test
fun `a rule sees the other entries but not the row being edited`() {
var seen: List<String>? = null
val rule = EntryRule { candidate, _, others -> seen = others; CommitOutcome.Stored(candidate) }
val state = EditableListState(listOf("a", "b", "c"), rule)
state.commitAt(1, "b")
assertEquals(listOf("a", "c"), seen,
"the duplicate check skipped the edited row, or renaming it to itself would fail")
}
@Test
fun `committing on a vanished row is ignored`() {
val state = EditableListState(listOf("a"))
assertEquals(CommitOutcome.Ignored, state.commitAt(7, "x"))
assertEquals(listOf("a"), state.texts)
}
}
@@ -0,0 +1,340 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The colours the Compose windows show come from here, and nothing that renders is
* covered by a test. This class is where that gap is closed: the reader takes plain
* text and gives plain values.
*/
class JavaFxStylesheetTest {
private fun hex(color: Color): String = String.format(
"#%02X%02X%02X",
Math.round(color.red * 255),
Math.round(color.green * 255),
Math.round(color.blue * 255),
)
@Test
fun `derive gives the values JavaFX gives`() {
/*
* A handful of values spelled out, so the most-used derivations are readable
* here rather than only in a data file. The full comparison against JavaFX
* lives in `derive matches the frozen JavaFX reference`.
*/
val expected = mapOf(
("#373E43" to 26.4) to "#525B61",
("#373E43" to 35.0) to "#5B646A",
("#373E43" to -20.0) to "#2C3236",
("#ECECEC" to 26.4) to "#F4F4F4",
("#ECECEC" to -20.0) to "#BDBDBD",
("#FFFFFF" to -30.0) to "#B2B2B2",
)
expected.forEach { (input, want) ->
val (rgb, percent) = input
/*
* Compared with a tolerance of one step per channel. derive(white, -30%)
* lands on 0.7 * 255 = 178.5 exactly, and the two paths round that half
* step differently; a test that flips on a half step pins nothing useful.
*/
assertChannelsClose(want, JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)")
/*
* No live JavaFX comparison here. It lives in the frozen reference data
* instead (javafx-derive-reference.txt), because a reflection call into
* com.sun.javafx stops working when JavaFX is removed and would take the
* only real check with it.
*/
}
}
@Test
fun `the window background is not the base colour`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals("#373E43", hex(palette.base))
assertEquals("#525B61", hex(palette.windowBackground),
"Modena paints a window with derive(-fx-base, 26.4%), not with -fx-base; "
+ "taking the base raw made the Compose window visibly darker than the "
+ "JavaFX one beside it",
)
}
@Test
fun `the evening sheet gives its declared colours`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals("#373E43", hex(palette.base), "-fx-base")
assertEquals("#1E74C6", hex(palette.accent), "-fx-accent")
assertEquals("#D3D3D3", hex(palette.labelTextFill), ".label -fx-text-fill is lightgray")
assertEquals("#3C3C3C", hex(palette.separatorLine), ".separator *.line")
assertEquals("#FF0000", hex(palette.buttonPressedBorder), ".button:pressed")
assertEquals(12f, palette.fontSizePx)
}
@Test
fun `the control interior resolves its reference before deriving`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals(
hex(JavaFxStylesheet.derive(palette.base, 35.0)),
hex(palette.controlInnerBackground),
"-fx-control-inner-background is derive(-fx-base, 35%): the reference to "
+ "-fx-base has to be looked up in the same sheet first",
)
}
@Test
fun `the accent is the green of the field text, not the declared accent`() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
assertEquals("#1E74C6", hex(evening.accent),
"-fx-accent stays what the sheet says; it drives the JavaFX selection bar")
// green (#008000) and lightgreen (#90EE90), averaged.
assertEquals("#48B748", hex(evening.textAccent),
".text-field .text is what the operator reads as the accent")
assertEquals("#48B748", hex(day.textAccent),
"both sheets declare the same rule, so the accent does not change with "
+ "the design")
}
@Test
fun `the hover gradient keeps its stops in order`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertTrue(palette.buttonHoverGradient.size >= 2,
"the sheet declares linear-gradient(#f0ff35, #a9ff00)")
assertEquals("#F0FF35", hex(palette.buttonHoverGradient[0]))
assertEquals("#A9FF00", hex(palette.buttonHoverGradient[1]))
}
@Test
fun `the daylight sheet falls back to Modena`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
// It declares only a font size and a few button rules; everything else is
// JavaFX's own default theme.
assertEquals("#ECECEC", hex(palette.base))
assertEquals("#0096C9", hex(palette.accent))
assertEquals("#FFFFFF", hex(palette.controlInnerBackground))
assertEquals("#000000", hex(palette.labelTextFill))
assertEquals(12f, palette.fontSizePx)
}
@Test
fun `the daylight sheet still carries its own button rules`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
assertEquals("#FF0000", hex(palette.buttonPressedBorder),
"the daylight sheet declares .button:pressed too")
}
@Test
fun `an absent sheet yields the defaults instead of failing`() {
val palette = JavaFxStylesheet.read("/there-is-no-such-sheet.css")
assertEquals("#ECECEC", hex(palette.base),
"a missing stylesheet must not stop a window from opening")
}
/** Each channel within one step of the expected colour. */
private fun assertChannelsClose(expected: String, actual: Color, message: String) {
val want = parse(expected)
listOf(
Triple("red", want.red, actual.red),
Triple("green", want.green, actual.green),
Triple("blue", want.blue, actual.blue),
).forEach { (channel, wanted, got) ->
assertEquals(
Math.round(wanted * 255).toDouble(),
Math.round(got * 255).toDouble(),
1.0,
"$message: $channel",
)
}
}
/** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */
private fun referencePairs(): List<Triple<String, Double, String>> {
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
?: error("javafx-derive-reference.txt is missing from the test resources")
/*
* Comment lines begin with "# "; a data line begins with "#" followed straight
* by hex. The header's format line contains semicolons too, so filtering on
* those would read it as data.
*/
return resource.bufferedReader().readLines()
.filterNot { it.isBlank() || it.startsWith("# ") }
.map { line ->
val (rgb, percent, expected) = line.split(";")
Triple(rgb, percent.toDouble(), expected)
}
}
/**
* The one colour this reimplementation does not reproduce exactly.
*
* Found by widening the reference data past the greys the old pin covered. Every
* grey and both stylesheet bases match JavaFX to within the rounding tolerance;
* this saturated blue drifts by up to three steps per channel.
*
* Why that is tolerable, checked rather than assumed. derive() has four callers:
* `Theme.kt:86` and `JavaFxStylesheet` lines 96 and 106 all pass the window
* background or `-fx-base`, which are near grey. The fourth is the CSS parser at
* `JavaFxStylesheet:249`, which passes whatever colour a sheet names inside a
* `derive(...)` expression — and **every** such expression in both shipped sheets
* derives from `-fx-base` and nothing else. So no saturated colour reaches derive()
* today.
*
* That is a fact about the sheets as they are, not a guarantee. Etappe 8 of the
* migration makes the colours operator-editable; a sheet with `derive(#4da6ff, 20%)`
* in it would walk straight into this. Revisit then.
*
* Named rather than removed from the data: the data records what JavaFX does, and
* hiding the pairs that disagree would turn the record into a flattering one.
*/
private val knownDivergentColour = "#4da6ff"
/**
* derive() against the values JavaFX itself produced.
*
* Against checked-in data rather than a live reflection call, because the live call
* disappears with JavaFX and would take the only real check with it — leaving a
* test that compares derive() to derive().
*/
@Test
fun `derive matches the frozen JavaFX reference`() {
val pairs = referencePairs()
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
pairs.filterNot { (rgb, _, _) -> rgb == knownDivergentColour }
.forEach { (rgb, percent, expected) ->
assertChannelsClose(
expected,
JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)",
)
}
}
/**
* The known divergence, pinned so it cannot grow unnoticed.
*
* Three steps per channel is small enough to leave alone for a colour derive() is
* never called with. It is not small enough to stop measuring: a later change that
* widened it, or that started calling derive() with saturated colours, should fail
* here rather than go unseen.
*/
@Test
fun `the known divergence from JavaFX stays within three steps`() {
val divergent = referencePairs().filter { (rgb, _, _) -> rgb == knownDivergentColour }
assertTrue(divergent.isNotEmpty()) { "the reference data no longer covers $knownDivergentColour" }
divergent.forEach { (rgb, percent, expected) ->
val got = JavaFxStylesheet.derive(parse(rgb), percent)
val want = parse(expected)
listOf(
"red" to (want.red to got.red),
"green" to (want.green to got.green),
"blue" to (want.blue to got.blue),
).forEach { (channel, values) ->
val (wanted, actual) = values
val delta = Math.abs(Math.round(wanted * 255) - Math.round(actual * 255))
assertTrue(delta <= 3) {
"derive($rgb, $percent%): $channel is $delta steps from JavaFX, was at most 3"
}
}
}
}
/**
* Writes the reference data the frozen test compares against.
*
* A generator, not an assertion: it runs once, while JavaFX is still on the
* classpath, and its output is checked in. Kept rather than deleted so the
* provenance of the data stays readable — the values come from
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
* point of pinning them.
*
* To regenerate: remove @Disabled, run, restore @Disabled.
*/
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
@Test
fun `generate the JavaFX reference data`() {
/*
* The greys are chosen for where they land on the brightness staircase
* deriveColor switches on — 0.2, 0.3, 0.4, 0.5, 0.6 and 0.85 of
* 0.3R + 0.59G + 0.11B — and two of them are there because mutation testing
* showed the first set left holes:
*
* #e0e0e0 (0.878) sits above the 0.85 step but, unlike white and #f4f4f4,
* has room left to brighten, so changing that branch's 1.6 factor moves it
* further than the rounding tolerance. Without it, 1.6 could become 1.5 and
* every test stayed green.
*
* #606060 (0.376) and #5a5a5a (0.353) sit between the 0.3 and 0.4 steps,
* which nothing else covered: the 0.4 threshold could be moved to 0.35
* unnoticed.
*/
val colours = listOf(
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
"#e0e0e0", "#eeeeee", "#606060", "#5a5a5a", "#707070", "#454545",
)
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
val lines = buildList {
for (rgb in colours) {
for (percent in percents) {
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
}
}
}
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
target.parentFile.mkdirs()
target.writeText(
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
lines.joinToString("\n") + "\n"
)
println("wrote ${lines.size} pairs to ${target.absolutePath}")
}
private fun deriveWithJavaFx(rgb: String, percent: Double): String {
val fxColor = Class.forName("javafx.scene.paint.Color")
val utils = Class.forName("com.sun.javafx.util.Utils")
val source = fxColor.getMethod("web", String::class.java).invoke(null, rgb)
val result = utils
.getMethod("deriveColor", fxColor, Double::class.javaPrimitiveType)
.invoke(null, source, percent / 100.0)
return hex(
Color(
(fxColor.getMethod("getRed").invoke(result) as Double).toFloat(),
(fxColor.getMethod("getGreen").invoke(result) as Double).toFloat(),
(fxColor.getMethod("getBlue").invoke(result) as Double).toFloat(),
)
)
}
private fun parse(rgb: String): Color {
val value = rgb.removePrefix("#").toLong(16)
return Color(0xFF000000L or value)
}
}
@@ -0,0 +1,184 @@
package kst4contest.view.compose
import kst4contest.view.i18n.Strings
import kst4contest.view.i18n.Translations
import kst4contest.controller.On4KstConnectionState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The menus, their labels and which of them can be used.
*
* None of this was covered while the bar was declared straight into Compose's `MenuBar`:
* the structure only existed inside a composable, so it took a running window to look at.
* It is now a plain function of the state, and these are the parts that go wrong silently —
* a label that stops following the away state, an item that stays usable while the station
* is not in the chat, or a menu that loses an entry when a second renderer is added.
*/
class MainMenuModelTest {
private class RecordingActions : MainMenuActions {
val calls = mutableListOf<String>()
override fun connect() { calls += "connect" }
override fun disconnect() { calls += "disconnect" }
override fun switchOperatorProfile() { calls += "switchOperatorProfile" }
override fun exitApplication() { calls += "exitApplication" }
override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" }
override fun toggleAwayState() { calls += "toggleAwayState" }
override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" }
override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" }
override fun toggleStationMap() { calls += "toggleStationMap" }
override fun useDarkDesign() { calls += "useDarkDesign" }
override fun useDefaultDesign() { calls += "useDefaultDesign" }
override fun openDonationPage() { calls += "openDonationPage" }
override fun openHomepage() { calls += "openHomepage" }
override fun openNewsgroup() { calls += "openNewsgroup" }
override fun openChangelog() { calls += "openChangelog" }
override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" }
override fun contactAuthor() { calls += "contactAuthor" }
override fun showAbout() { calls += "showAbout" }
}
private fun model(
connectionState: On4KstConnectionState = On4KstConnectionState.DISCONNECTED,
away: Boolean = false,
settingsOpen: Boolean = false,
monitorOpen: Boolean = false,
actions: MainMenuActions = RecordingActions(),
): List<MenuSpec> {
val state = MainMenuState()
state.connectionState = connectionState
state.awayFromChat = away
return mainMenuModel(state, actions, settingsOpen, monitorOpen, baseStrings())
}
/*
* The base language's texts, built here rather than taken from a store: these tests
* assert over real titles ("File", "Options"), and a store would make them depend on
* whichever language ran last in the shared JVM.
*/
private fun baseStrings(): Strings {
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)
return Strings { key -> base[key] ?: key }
}
private fun items(menu: MenuSpec) = menu.entries.filterIsInstance<MenuEntry.Item>()
private fun labels(menu: MenuSpec) = items(menu).map { it.label }
private fun item(model: List<MenuSpec>, label: String): MenuEntry.Item =
model.flatMap { items(it) }.single { it.label == label }
@Test
fun theFourMenusAppearInTheOrderTheyAlwaysHad() {
assertEquals(listOf("File", "Options", "Windows", "Info"), model().map { it.title })
}
@Test
fun noMenuIsEmptyAndTheSeparatorsSurviveTheTranslation() {
val model = model()
model.forEach { menu ->
assertTrue(items(menu).isNotEmpty(), "menu '${menu.title}' has no items")
}
// Windows has two separators, Info one. A renderer that drops them makes the
// design switch read as just another window toggle.
assertEquals(2, model.single { it.title == "Windows" }.entries.count { it is MenuEntry.Separator })
assertEquals(1, model.single { it.title == "Info" }.entries.count { it is MenuEntry.Separator })
}
@Test
fun theConnectItemCarriesTheStatesOwnLabel() {
val state = MainMenuState()
state.connectLabel = "Connect to 144/432 MHz"
val model = mainMenuModel(state, RecordingActions(), false, false, baseStrings())
assertEquals(
"Connect to 144/432 MHz",
labels(model.single { it.title == "File" }).first(),
"the label names the chat the station lands in; it must not be hardcoded here",
)
}
@Test
fun connectAndDisconnectAreNeverBothUsable() {
for (connectionState in On4KstConnectionState.values()) {
val model = model(connectionState = connectionState)
val connect = model.flatMap { items(it) }.first()
val disconnect = item(model, "Disconnect")
assertFalse(
connect.enabled && disconnect.enabled,
"both were usable while $connectionState",
)
assertTrue(
connect.enabled || disconnect.enabled,
"neither was usable while $connectionState, which would strand the operator",
)
}
}
@Test
fun theChatActionsNeedASessionThatIsReallyOnline() {
val offline = model(connectionState = On4KstConnectionState.CONNECTING)
// Logging in is not being in the chat: a command sent then goes nowhere.
assertFalse(item(offline, "Set QRG as name in Chat (main category)").enabled)
assertFalse(item(offline, "Show me as AWAY FROM chat!").enabled)
}
@Test
fun theAwayLabelNamesTheNextActionAndFollowsTheState() {
assertEquals(
"Show me as AWAY FROM chat!",
item(model(away = false), "Show me as AWAY FROM chat!").label,
)
assertEquals(
"Show me as ACTIVE in chat!",
item(model(away = true), "Show me as ACTIVE in chat!").label,
)
}
@Test
fun theWindowTogglesSayWhatPressingThemWillDo() {
/*
* "Show options" with a capital S in both menus. The JavaFX wording had it capital in
* the Options menu and lower case in the Windows menu for the same action; pulling
* both onto one key settled that, and in German the two would have been identical
* anyway.
*/
val closed = model(settingsOpen = false, monitorOpen = false)
assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "Show options" })
assertTrue(
labels(closed.single { it.title == "Windows" })
.any { it == "Show cluster / stranger QSOs" },
)
val open = model(settingsOpen = true, monitorOpen = true)
assertTrue(labels(open.single { it.title == "Windows" }).any { it == "hide options" })
assertTrue(
labels(open.single { it.title == "Windows" })
.any { it == "Hide cluster / stranger QSOs" },
)
}
@Test
fun everyItemIsWiredToSomething() {
val actions = RecordingActions()
val model = model(connectionState = On4KstConnectionState.ONLINE, actions = actions)
model.flatMap { items(it) }.forEach { it.onClick() }
// One click each, and nothing silently wired to an empty lambda: the JavaFX bar had
// an item whose handler was commented out, and it looked exactly like the others.
assertEquals(
model.flatMap { items(it) }.size,
actions.calls.size,
"every item must reach an action; one of them did nothing",
)
}
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Which menu of the in-window row is open.
*
* Small, but it carries one thing that is not cosmetic: [MainMenuRowState.anyOpen] is what
* keeps the window-wide keys out of the way while a menu is down. `MainWindow` hangs
* `WindowShortcuts.handle` on the window's `onPreviewKeyEvent`, and a preview runs *before*
* its children — so without this, pressing Escape to dismiss a menu would first clear the
* operator's half-typed message.
*/
class MainMenuRowStateTest {
@Test
fun nothingIsOpenToStartWith() {
val state = MainMenuRowState()
assertNull(state.openMenu)
assertFalse(state.anyOpen)
}
@Test
fun aTitleOpensItsOwnMenu() {
val state = MainMenuRowState()
state.toggle("File")
assertEquals("File", state.openMenu)
assertTrue(state.anyOpen)
}
@Test
fun theSameTitleAgainClosesIt() {
val state = MainMenuRowState()
state.toggle("File")
state.toggle("File")
assertNull(state.openMenu, "pressing the open menu's own title must close it")
assertFalse(state.anyOpen)
}
@Test
fun anotherTitleSwitchesRatherThanStacking() {
val state = MainMenuRowState()
state.toggle("File")
state.toggle("Info")
// Two menus down at once is a thing no menu bar does.
assertEquals("Info", state.openMenu)
}
@Test
fun closeAlwaysEndsUpClosedAndIsIdempotent() {
val state = MainMenuRowState()
state.toggle("Windows")
state.close()
state.close()
assertNull(state.openMenu)
assertFalse(state.anyOpen)
}
@Test
fun anItemBeingPickedClosesTheMenu() {
val state = MainMenuRowState()
state.toggle("Options")
var ran = false
state.pick { ran = true }
assertTrue(ran, "the action must run")
assertNull(state.openMenu, "a picked item must close the menu it came from")
}
}
@@ -0,0 +1,113 @@
package kst4contest.view.compose
import kst4contest.view.i18n.Strings
import kst4contest.view.i18n.Translations
import kst4contest.controller.On4KstConnectionState
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
/**
* Which menu items can be used, and when.
*
* A menu item that merely looks usable is a worse place for a stale value than a form
* field: the operator presses it in the middle of a contest and nothing happens, or
* the wrong thing does. The JavaFX window drove these from onConnectionStateChanged.
*/
class MainMenuStateTest {
@Test
fun `disconnected - connect is offered, disconnect is not`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.DISCONNECTED
assertTrue(menu.canConnect)
assertFalse(menu.canDisconnect)
}
@Test
fun `while connecting, connect is no longer offered`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.AUTHENTICATING
assertFalse(menu.canConnect, "a second attempt while one is running helps nobody")
assertTrue(menu.canDisconnect, "the attempt can be given up")
}
@Test
fun `online - disconnect yes, connect no`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.ONLINE
assertFalse(menu.canConnect)
assertTrue(menu.canDisconnect)
}
@Test
fun `the chat actions need a session that is really online`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.AUTHENTICATING
assertFalse(menu.canUseChatActions,
"logging in is not being in the chat; /AWAY would go nowhere")
menu.connectionState = On4KstConnectionState.ONLINE
assertTrue(menu.canUseChatActions)
}
@Test
fun `switching profiles and leaving are always possible`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.DISCONNECTED
assertTrue(menu.canSwitchProfile)
assertTrue(menu.canExit, "an operator must always be able to leave")
}
@Test
fun `an unknown state is treated as disconnected`() {
val menu = MainMenuState()
assertTrue(menu.canConnect, "before the first callback there is no session")
assertFalse(menu.canDisconnect)
}
/**
* The away item's own label is the only place the AFK state is visible at all — there
* is no other indicator for it. It names the next action: away when active, active when
* away. JavaFX flipped setText() inside the handler; here it follows the state, so it
* is right even when the state changes from elsewhere.
*/
/*
* The base language's texts, built here rather than taken from a store: this test asserts
* over the real English wording, and a store would make it depend on whichever language
* ran last in the shared JVM.
*/
private fun baseStrings(): Strings {
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)
return Strings { key -> base[key] ?: key }
}
@Test
fun `the away item says what pressing it will do`() {
val state = MainMenuState()
val strings = baseStrings()
state.awayFromChat = false
assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel(strings))
state.awayFromChat = true
assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel(strings))
}
/** Nobody is away before the session exists. */
@Test
fun `a fresh session is not away`() {
assertFalse(MainMenuState().awayFromChat)
}
}
@@ -0,0 +1,158 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The frame around the main window: how big it opens, and what its title says.
*
* Pure arithmetic and string building, which is why it is tested here rather than looked
* at. The clamp exists because a window remembered from a larger monitor opens
* unusable — its buttons off-screen, and no way to reach them.
*/
class MainWindowFrameTest {
@Test
fun `a remembered size is used as it is when it fits`() {
val size = MainWindowFrame.startupSize(
stored = doubleArrayOf(768.0, 1234.0),
screenHeight = 1440.0,
screenWidth = 2560.0,
)
assertEquals(768.0, size.heightDp)
assertEquals(1234.0, size.widthDp)
}
/** The margin leaves room for the window decoration and the screen edge. */
@Test
fun `a window remembered from a larger screen is cut down to fit`() {
val size = MainWindowFrame.startupSize(
stored = doubleArrayOf(1400.0, 3000.0),
screenHeight = 1080.0,
screenWidth = 1920.0,
)
assertEquals(1040.0, size.heightDp)
assertEquals(1880.0, size.widthDp)
}
@Test
fun `no remembered size at all opens at the default`() {
val size = MainWindowFrame.startupSize(null, screenHeight = 1440.0, screenWidth = 2560.0)
assertEquals(768.0, size.heightDp)
assertEquals(1234.0, size.widthDp)
}
@Test
fun `a half written size falls back per dimension`() {
val size = MainWindowFrame.startupSize(
stored = doubleArrayOf(0.0, 1500.0),
screenHeight = 1440.0,
screenWidth = 2560.0,
)
assertEquals(768.0, size.heightDp, "a stored zero height should fall back")
assertEquals(1500.0, size.widthDp, "a usable stored width should survive")
}
@Test
fun `an unusable stored array falls back entirely`() {
assertEquals(768.0, MainWindowFrame.startupSize(doubleArrayOf(600.0), 1440.0, 2560.0).heightDp)
assertEquals(
768.0,
MainWindowFrame.startupSize(doubleArrayOf(Double.NaN, Double.NaN), 1440.0, 2560.0).heightDp,
)
}
/** A screen smaller than the margin must still yield a window with a size. */
@Test
fun `an absurdly small screen still yields a positive size`() {
val size = MainWindowFrame.startupSize(doubleArrayOf(768.0, 1234.0), 20.0, 20.0)
assertTrue(size.heightDp > 0.0)
assertTrue(size.widthDp > 0.0)
}
// ---- the title ---------------------------------------------------------
@Test
fun `the title carries the chat state`() {
assertEquals(
"Connected to: 2 as DN9APW",
MainWindowFrame.title(chatState = "Connected to: 2 as DN9APW", profileName = null),
)
}
/** The root profile is the only one, so naming it would only add noise. */
@Test
fun `the root profile is not named in the title`() {
assertEquals("DISCONNECTED!", MainWindowFrame.title("DISCONNECTED!", profileName = null))
}
@Test
fun `a named profile is appended so two windows can be told apart`() {
assertEquals(
"DISCONNECTED! - Contest station",
MainWindowFrame.title("DISCONNECTED!", profileName = "Contest station"),
)
}
// ---- the chat state line ----------------------------------------------
/**
* What the title says while the link is not up. The operator reads this to tell a
* scheduled reconnect from a dead session, so the wording is pinned.
*/
@Test
fun `every connection state has its own offline wording`() {
assertEquals("CONNECTION LOST – reconnect scheduled", MainWindowFrame.offlineChatState(On4KstConnectionState.RECONNECT_WAIT))
assertEquals("Connecting to ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING))
assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT))
assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.AUTHENTICATING))
assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_MAIN_CHAT))
assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_SECOND_CHAT))
assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.DISCONNECTED))
assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.STOPPING))
}
@Test
fun `no connection state at all reads as disconnected`() {
assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(null))
}
/**
* The title carries the whole connection line while the chat is usable.
*
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
* JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window
* opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window
* manager nothing and told the operator less.
*/
@Test
fun `the title carries the live connection line once the chat is usable`() {
val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
"(73 users online, 73 shown), 29 messages total."
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail))
}
@Test
fun `an unusable chat says what it is doing instead`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"),
)
}
@Test
fun `a blank detail falls through rather than leaving the title empty`() {
// The feed substitutes the state name for a blank detail, but it may not have run yet.
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "),
)
assertEquals(
MainWindowFrame.offlineChatState(null),
MainWindowFrame.chatState(null, null),
)
}
}
@@ -0,0 +1,173 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
* nothing Compose can see. Mirroring them into Compose state is what makes the button row
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
assertEquals(listOf("MYQRG", "pse sked"), mirror.entries)
}
/**
* The roster is written through by the settings window on every committed edit. The
* mirror only catches up when it is told to, which is what the refresh hook is for.
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "SECONDQRG", "qrz?"))
assertEquals(listOf("MYQRG"), mirror.entries, "the mirror moved without being refreshed")
mirror.refresh()
assertEquals(listOf("MYQRG", "SECONDQRG", "qrz?"), mirror.entries)
}
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
prefs.getLst_txtShortCutBtnList().clear()
mirror.refresh()
assertTrue(mirror.entries.isEmpty())
}
/**
* The button row is drawn from this, so reading it has to register as a read — the
* failure that produced most of this migration's invisible defects.
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
mirror.entries.forEach { it.length }
}
assertTrue(read.isNotEmpty(), "the mirror was read without subscribing")
}
}
/**
* The splitters of the main window against the positions the profile actually stores.
*
* A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so.
*/
class MainWindowSplitterDefaultsTest {
private val prefs = ChatPreferences()
@Test
fun `the message column has as many dividers as the profile stores positions`() {
val stored = prefs.getGUImessageSectionSplitpane_dividerposition()
assertEquals(
MainWindowState.MESSAGE_PANE_COUNT - 1,
stored.size,
"the message splitter has ${MainWindowState.MESSAGE_PANE_COUNT} panes but the " +
"profile stores ${stored.size} positions",
)
val splitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, stored) {}
assertEquals(
stored.map { it.toFloat() },
splitter.positions,
"the stored positions were rejected and silently replaced by an even split",
)
}
@Test
fun `the station column has as many dividers as the profile stores positions`() {
val stored = prefs.getGUImainWindowRightSplitPane_dividerposition()
assertEquals(MainWindowState.RIGHT_PANE_COUNT - 1, stored.size)
val splitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
@Test
fun `the outer splitter takes the one position the profile stores`() {
val stored = prefs.getGUImainWindowLeftSplitPane_dividerposition()
assertEquals(1, stored.size)
val splitter = SplitterState(paneCount = 2, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
}
/**
* What the window draws about its surroundings — the link, and which other windows are
* open — has to be Compose state as well.
*
* These come from the controller and from AtomicBooleans inside the window hosts, none of
* which Compose can see. Reading them through a plain lambda at composition time draws them
* once: the badge would freeze on whatever the link was when the window opened, and the
* menu's "hide options" would keep saying the wrong thing.
*/
class MainWindowSurroundingsTest {
@Test
fun `the connection state starts disconnected and follows what it is told`() {
val state = MainWindowSurroundings()
assertEquals(On4KstConnectionState.DISCONNECTED, state.connectionState)
state.connectionState = On4KstConnectionState.ONLINE
assertEquals(On4KstConnectionState.ONLINE, state.connectionState)
}
@Test
fun `drawing the surroundings subscribes to every value`() {
val state = MainWindowSurroundings()
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
state.connectionState
state.connectionDetail
state.settingsWindowOpen
state.monitorWindowOpen
}
assertEquals(4, read.size, "a value was read without subscribing: $read")
}
/**
* The menu and the badge must never disagree about the link — they are two renderings of
* one fact, and an operator comparing them would not know which to believe.
*/
@Test
fun `the menu follows the same connection state as the badge`() {
val menu = MainMenuState()
val state = MainWindowSurroundings(menu)
state.connectionState = On4KstConnectionState.ONLINE
assertEquals(On4KstConnectionState.ONLINE, menu.connectionState)
assertTrue(menu.canUseChatActions)
}
}
@@ -0,0 +1,133 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import kst4contest.observe.SimpleValue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
/**
* The columns of the two monitor tables.
*
* Two things are worth pinning. The ids are part of the operator's stored layout, so
* renaming one silently loses their column widths. And every value has to survive a
* half-filled message: these tables are fed straight from the wire, and a
* NullPointerException in a live table during a contest is the worst possible moment.
*/
class MonitorColumnsTest {
@Test
fun `the DX cluster columns keep the ids the stored layout uses`() {
assertEquals(
listOf("time", "call-tx", "locator-tx", "call-rx", "locator-rx", "qrg", "message", "worked"),
MonitorColumns.dxCluster().map { it.id },
)
}
@Test
fun `the DX cluster columns keep their headings`() {
assertEquals(
listOf("Time", "Call tx", "LOC tx", "Call rx", "LOC rx", "QRG", "Message", "wkd"),
MonitorColumns.dxCluster().map { it.title() },
)
}
@Test
fun `a DX cluster row without sender or receiver reads as empty, not as a crash`() {
val bare = ClusterMessage()
MonitorColumns.dxCluster().forEach { column ->
assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
}
}
@Test
fun `the worked column shows a cross, as the JavaFX table did`() {
val worked = ClusterMessage().apply { isReceiverWkd = true }
val notWorked = ClusterMessage()
val column = MonitorColumns.dxCluster().first { it.id == "worked" }
assertEquals("X", column.value(worked))
assertEquals("", column.value(notWorked))
}
@Test
fun `the QSO monitor keeps the nine columns the JavaFX table actually showed`() {
// Ten were built there and nine added; the "Name" column was never shown.
assertEquals(
listOf("time", "call-tx", "last-qrg-tx", "worked-tx", "call-rx", "last-qrg-rx",
"worked-rx", "message", "category"),
MonitorColumns.qsoOfTheOther().map { it.id },
)
}
@Test
fun `a QSO monitor row without sender or receiver reads as empty, not as a crash`() {
val bare = ChatMessage()
MonitorColumns.qsoOfTheOther().forEach { column ->
assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
}
}
@Test
fun `the time column shows a clock time, not the epoch off the wire`() {
// The server sends epoch seconds; the JavaFX cell ran them through
// Utils4KST.time_convertEpochToReadable, which is H:mm:ss in Etc/UTC.
val spot = ClusterMessage().apply { timeGenerated = "1759000000" }
val column = MonitorColumns.dxCluster().first { it.id == "time" }
assertEquals("19:06:40", column.value(spot))
}
@Test
fun `an unreadable time is shown as it came rather than throwing`() {
val spot = ClusterMessage().apply { timeGenerated = "not a number" }
val column = MonitorColumns.dxCluster().first { it.id == "time" }
assertEquals("not a number", column.value(spot))
}
@Test
fun `the QSO time column is formatted the same way`() {
val message = ChatMessage().apply { messageGeneratedTime = "1759000000" }
val column = MonitorColumns.qsoOfTheOther().first { it.id == "time" }
assertEquals("19:06:40", column.value(message))
}
@Test
fun `the cluster QRG is padded the way the main window pads it`() {
val spot = ClusterMessage().apply {
receiver = ChatMember().apply { frequency = SimpleValue("144.3") }
}
val column = MonitorColumns.dxCluster().first { it.id == "qrg" }
assertEquals("144.300", column.value(spot),
"the same column in the main window pads to three places; two tables of "
+ "the same data must not disagree")
}
@Test
fun `the category comes from the sender, as it did in the JavaFX table`() {
val sender = ChatMember().apply { chatCategory = ChatCategory(2) }
val message = ChatMessage().apply { this.sender = sender }
val column = MonitorColumns.qsoOfTheOther().first { it.id == "category" }
assertEquals(
sender.chatCategory.getChatCategoryName(sender.chatCategory.categoryNumber),
column.value(message),
"the old cell read getSender().getChatCategory(); the message carries a "
+ "field of the same name that is not always the same value",
)
}
}
@@ -0,0 +1,93 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
* Compose can see. A field drawn straight from one renders once and then goes stale — and
* these three in particular move on their own: the rotator writes the QTF, and the
* frequency follower writes MYQRG.
*
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField.
*/
class ObservedValueTest {
@Test
fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
assertEquals("144.370", observed.value)
}
/** The rotator moving must reach the screen without anything asking it to. */
@Test
fun `a change to the underlying value reaches the mirror by itself`() {
val prefs = ChatPreferences()
prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF())
prefs.getActualQTF().set(135.0)
assertEquals(135.0, observed.value)
}
@Test
fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences()
val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null)
assertEquals(null, observed.value)
}
/**
* The field is drawn from this, so reading it has to register as a read. Without this
* the whole exercise is pointless: the value would be correct and invisible.
*/
@Test
fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
observed.value
}
assertTrue(read.isNotEmpty(), "the mirrored value was read without subscribing")
}
/**
* A mirror that is dropped must stop listening. A profile switch builds a new window
* over the same preferences, and a leaked listener keeps the old one alive and writing.
*/
@Test
fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
prefs.getMYQRGFirstCat().set("432.200")
assertEquals("144.370", observed.value, "a released mirror kept following")
}
@Test
fun `releasing twice is harmless`() {
val prefs = ChatPreferences()
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
observed.release()
}
}
@@ -0,0 +1,69 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
class OperatorProfilePickerStateTest {
private fun profile(id: String, name: String = id, root: Boolean = false, shared: Boolean = false) =
OperatorProfile(id, name, root, shared)
@Test
fun `the preselected profile is selected when it is in the list`() {
val state = OperatorProfilePickerState(
listOf(profile("root"), profile("contest")), preselectedProfileId = "contest")
assertEquals("contest", state.selected?.profileId)
assertTrue(state.canConfirm)
}
@Test
fun `an unknown preselection falls back to the first profile`() {
val state = OperatorProfilePickerState(
listOf(profile("root"), profile("contest")), preselectedProfileId = "gone")
assertEquals("root", state.selected?.profileId,
"a profile that disappeared must not leave the dialog unusable")
}
@Test
fun `no preselection falls back to the first profile`() {
val state = OperatorProfilePickerState(listOf(profile("root")), preselectedProfileId = null)
assertEquals("root", state.selected?.profileId)
}
@Test
fun `an empty list cannot be confirmed`() {
val state = OperatorProfilePickerState(emptyList(), preselectedProfileId = null)
assertNull(state.selected)
assertFalse(state.canConfirm)
}
@Test
fun `selecting changes the selection`() {
val state = OperatorProfilePickerState(
listOf(profile("root"), profile("contest")), preselectedProfileId = "root")
state.select(profile("contest"))
assertEquals("contest", state.selected?.profileId)
}
@Test
fun `the worked database label matches the JavaFX dialog`() {
// The dialog this replaces showed exactly these two strings, and the rule
// behind them: the root profile and a sharing profile use the shared file.
assertEquals("shared station worked database",
workedDatabaseDescription(profile("root", root = true)))
assertEquals("shared station worked database",
workedDatabaseDescription(profile("sharer", shared = true)))
assertEquals("own worked database",
workedDatabaseDescription(profile("owner")))
}
}
@@ -0,0 +1,88 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.awt.Window
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.swing.Timer
/**
* The picker must be openable from the user-interface thread.
*
* Since the dispatcher moved to the AWT event thread, `ComposeMenuActions.switchOperatorProfile`
* delivers the File menu's profile switch there. The picker's former thread-plus-latch
* construction deadlocked against that: a `CountDownLatch.await()` does not pump events, while
* every continuation of the window's own `application { }` — composition, the button callbacks,
* even the return that would release the latch — is posted to that same thread by skiko's
* SwingDispatcher. The window never appeared and the client froze for good.
*/
class OperatorProfilePickerWindowTest {
private fun profiles() = listOf(
OperatorProfile().apply { profileId = "root"; displayName = "Root" },
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 }
@Test
fun opensAndReturnsWhenCalledOnTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
val worker = Executors.newSingleThreadExecutor()
try {
closer.start()
// 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)
} finally {
closer.stop()
worker.shutdownNow()
}
}
@Test
fun opensAndReturnsWhenCalledOffTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
try {
closer.start()
// 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")
} finally {
closer.stop()
}
}
@Test
fun theSelectionRulesAreUnchanged() {
val state = OperatorProfilePickerState(profiles(), "second")
assertEquals("second", state.selected?.profileId, "the preselected profile must win")
assertTrue(state.canConfirm)
state.select(profiles()[0])
assertEquals("root", state.selected?.profileId)
}
}
@@ -0,0 +1,169 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.PaletteRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The three layers and which one wins, role by role.
*
* The provenance is not decoration. Two sources that can decide the same colour is a place
* where nobody can later say why a colour is what it is, and saying so per role is the one
* thing that makes the arrangement defensible -- so it is pinned as hard as the colours.
*/
class PaletteResolutionTest {
private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
private val red = Color(0xFFFF0000)
private val green = Color(0xFF00FF00)
@Test
fun withNothingSetEveryRoleComesFromTheShippedSheet() {
val resolved = resolvePalette(shipped, emptyMap(), emptyMap())
assertEquals(PaletteRole.values().toSet(), resolved.roles.keys)
resolved.roles.forEach { (role, it) ->
assertEquals(PaletteSource.SHIPPED, it.source, "$role")
}
assertEquals(shipped.base, resolved.palette.base)
assertEquals(shipped.accent, resolved.palette.accent)
}
@Test
fun aFileOverridesTheShippedSheetForTheRolesItNames() {
val resolved = resolvePalette(shipped, mapOf(PaletteRole.SURFACE to red), emptyMap())
assertEquals(red, resolved.roles[PaletteRole.SURFACE]!!.colour)
assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.SURFACE]!!.source)
// And only those roles.
assertEquals(PaletteSource.SHIPPED, resolved.roles[PaletteRole.ACCENT]!!.source)
assertEquals(shipped.accent, resolved.roles[PaletteRole.ACCENT]!!.colour)
}
@Test
fun aChangeOverridesBothTheFileAndTheShippedSheet() {
val resolved = resolvePalette(
shipped,
mapOf(PaletteRole.SURFACE to red),
mapOf(PaletteRole.SURFACE to green),
)
assertEquals(green, resolved.roles[PaletteRole.SURFACE]!!.colour)
assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.SURFACE]!!.source)
}
@Test
fun theResolvedRolesReachThePaletteTheWindowsDraw() {
val resolved = resolvePalette(
shipped,
emptyMap(),
mapOf(
PaletteRole.SURFACE to red,
PaletteRole.WINDOW_SURFACE to green,
PaletteRole.FIELD_INTERIOR to red,
PaletteRole.TEXT to green,
PaletteRole.ACCENT to red,
PaletteRole.SEPARATOR to green,
),
)
assertEquals(red, resolved.palette.base)
assertEquals(green, resolved.palette.windowBackground)
assertEquals(red, resolved.palette.controlInnerBackground)
assertEquals(green, resolved.palette.labelTextFill)
assertEquals(red, resolved.palette.accent)
assertEquals(green, resolved.palette.separatorLine)
}
@Test
fun theDerivedFieldsStayWithTheShippedSheet() {
// The hover gradient, the pressed border and the font size are not roles the operator
// sets here; they follow the shipped sheet rather than becoming further decisions.
val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red))
assertEquals(shipped.buttonHoverGradient, resolved.palette.buttonHoverGradient)
assertEquals(shipped.buttonPressedBorder, resolved.palette.buttonPressedBorder)
assertEquals(shipped.fontSizePx, resolved.palette.fontSizePx)
}
@Test
fun aChosenAccentIsAlsoTheAccentTheOperatorActuallySees() {
/*
* Theme.kt maps Material's primary onto textAccent and not onto accent, because
* -fx-accent only paints JavaFX's selection bar while the green of .text-field .text
* is what reads as the accent. Leave textAccent on the sheet's green and setting the
* accent changes almost nothing visible -- a setting written through and not drawn,
* which is precisely the fault class the drawing-layer tests exist for.
*/
val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.ACCENT to red))
assertEquals(red, resolved.palette.accent)
assertEquals(red, resolved.palette.textAccent)
}
@Test
fun anUntouchedAccentLeavesTheSheetsOwnTextAccentAlone() {
// Without an override the sheet's gradient midpoint stands; it is not the accent.
val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red))
assertEquals(shipped.textAccent, resolved.palette.textAccent)
}
@Test
fun aFileRestatingTheShippedAccentDoesNotRepaintTheTextAccent() {
/*
* The root profile's stylesheet is written by copyResourceIfRequired at startup and
* is the shipped sheet, so layer 2 declares the accent on every existing installation
* with nobody having chosen anything. Keying the text accent on provenance rather
* than on the value repainted the evening primary from green to blue on upgrade.
*/
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
val resolved = resolvePalette(
evening,
mapOf(PaletteRole.ACCENT to evening.accent),
emptyMap(),
)
assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.ACCENT]!!.source)
assertEquals(
evening.textAccent,
resolved.palette.textAccent,
"a file that merely restates the shipped accent changed the accent that is seen",
)
}
@Test
fun resettingToTheShippedAccentRestoresTheShippedTextAccent() {
/*
* resetToShipped writes all six roles as explicit overrides, because it has to beat a
* stylesheet file. If the text accent follows provenance, the one button the spec
* names as producing "exactly the shipped colours" produces something else -- and
* disagrees with "discard my changes", which clears layer 3 and does restore it.
*/
val resolved = resolvePalette(
shipped,
emptyMap(),
mapOf(PaletteRole.ACCENT to shipped.accent),
)
assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.ACCENT]!!.source)
assertEquals(shipped.textAccent, resolved.palette.textAccent)
}
@Test
fun theWindowSurfaceIsNotDraggedAlongByTheSurface() {
/*
* The reason these are two roles rather than one: the sheets state the window
* surface separately, and an operator who sets only the control surface must not
* have the window surface changed under them.
*/
val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red))
assertEquals(red, resolved.palette.base)
assertEquals(shipped.windowBackground, resolved.palette.windowBackground)
}
}
@@ -0,0 +1,109 @@
package kst4contest.view.compose
import kst4contest.model.ChatPreferences
import kst4contest.model.OperatorProfile
import kst4contest.model.PaletteRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* The factory binds the store to one profile's preferences and one profile's stylesheet.
*
* Two wires, and each of them is a place where one profile could end up reading or writing
* another's colours. The stylesheet path is injected here rather than derived inside, so the
* test does not have to write into the operator's home directory to check which file is read.
*/
class PaletteStoreFactoryTest {
private val daylight = OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
eveningFile = File("/nonexistent/evening.css"),
)
@Test
fun aChangeLandsInTheDesignsOwnPreference() {
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesDay)
assertTrue(prefs.guiOptions_paletteOverridesEvening.isEmpty())
}
@Test
fun anEveningChangeLandsInTheEveningPreference() {
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesEvening)
assertTrue(prefs.guiOptions_paletteOverridesDay.isEmpty())
}
@Test
fun whatIsAlreadyStoredIsInForceFromTheStart() {
// The store is built at startup; a colour saved last session has to be there at once.
val prefs = ChatPreferences()
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
val store = PaletteStoreFactory.create(prefs, daylight)
assertEquals(PaletteSource.CHANGED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source)
}
@Test
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
store.resolved(false).roles.forEach { (role, resolved) ->
assertEquals(PaletteSource.SHIPPED, resolved.source, "$role")
}
}
@Test
fun aStylesheetInTheProfileSuppliesTheRolesItNames() {
val file = File.createTempFile("kst4contest-profile", ".css")
try {
file.writeText(".root { -fx-accent: #123456; }")
val store = PaletteStoreFactory.create(
ChatPreferences(),
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
)
assertEquals(
PaletteSource.FILE,
store.resolved(false).roles[PaletteRole.ACCENT]!!.source,
)
assertEquals(colourOf("#123456"), store.resolved(false).palette.accent)
} finally {
file.delete()
}
}
@Test
fun theFilesOfAProfileFollowOperatorProfilePaths() {
/*
* The one assertion that ties the factory to the project's path convention. A
* different shape here would have one profile reading another profile's colours.
*/
val other = OperatorProfile("OP2", "DN9APW", false, false)
val files = OperatorProfilePaletteFiles.of(other)
assertTrue(
files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"),
"the daylight file is ${files.dayFile.path}",
)
assertTrue(
files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"),
"the evening file is ${files.eveningFile.path}",
)
}
}
@@ -0,0 +1,278 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.PaletteOverrides
import kst4contest.model.PaletteRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The store, and the three ways back.
*
* Built over four lambdas rather than over ChatPreferences, so none of this touches the
* operator's home directory: that constructor copies resources into it.
*/
class PaletteStoreTest {
private val stored = mutableMapOf(false to "", true to "")
private var fileRoles: Map<PaletteRole, Color> = emptyMap()
private fun store() = PaletteStore(
shippedOf = { dark ->
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { fileRoles },
storedOverridesOf = { dark -> stored.getValue(dark) },
storeOverrides = { dark, encoded -> stored[dark] = encoded },
)
@Test
fun theImmutableLayersAreReadOnceAndNotOnEveryResolve() {
/*
* resolved() is called from inside Kst4ContestTheme, so it runs on every recomposition
* of every window. Reading the classpath and the profile's stylesheet there means two
* CSS parses and a disk read per recomposition, on the UI thread -- and this project's
* standing lesson is that a green build says nothing about what the UI thread is doing.
*
* The override state is the live part; the two layers under it are immutable for the
* run, which is also what both manuals promise ("changes to the file take effect after
* a restart").
*/
var shippedReads = 0
var fileReads = 0
val store = PaletteStore(
shippedOf = { dark ->
shippedReads++
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { fileReads++; emptyMap() },
storedOverridesOf = { "" },
storeOverrides = { _, _ -> },
)
repeat(20) { store.resolved(false) }
store.set(false, PaletteRole.ACCENT, "#FF0000")
repeat(20) { store.resolved(false) }
assertEquals(1, shippedReads, "the shipped sheet was parsed more than once")
assertEquals(1, fileReads, "the profile stylesheet was read more than once")
}
@Test
fun eachDesignReadsItsOwnLayersOnceWhenItIsFirstNeeded() {
var reads = 0
val store = PaletteStore(
shippedOf = { dark ->
reads++
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { emptyMap() },
storedOverridesOf = { "" },
storeOverrides = { _, _ -> },
)
repeat(5) { store.resolved(false) }
assertEquals(1, reads, "the daylight sheet was read more than once")
repeat(5) { store.resolved(true) }
assertEquals(2, reads, "the evening sheet was not read, or was read more than once")
}
@Test
fun aChangeIsVisibleInTheResolvedPaletteAndInTheStorage() {
val store = store()
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent)
assertEquals(
PaletteSource.CHANGED,
store.resolved(false).roles[PaletteRole.ACCENT]!!.source,
)
assertEquals("accent=#FF0000", stored.getValue(false))
}
@Test
fun anUnparsableColourLeavesTheRoleAsItWas() {
// The operator typed something that is not a colour. Nothing may turn black.
val store = store()
val before = store.resolved(false).palette.accent
store.set(false, PaletteRole.ACCENT, "rot")
assertEquals(before, store.resolved(false).palette.accent)
assertEquals("", stored.getValue(false))
}
@Test
fun theTwoDesignsDoNotDragEachOtherAlong() {
val store = store()
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
assertEquals(
PaletteSource.CHANGED,
store.resolved(true).roles[PaletteRole.ACCENT]!!.source,
)
assertEquals(
PaletteSource.SHIPPED,
store.resolved(false).roles[PaletteRole.ACCENT]!!.source,
)
}
@Test
fun resetToShippedBeatsAFileAsWell() {
/*
* The acceptance criterion of this stage: "exactly the shipped colours". With a file
* in the middle, clearing the changes would not get there -- the file would.
*/
fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00))
val store = store()
val shippedAccent = store.shipped(false).accent
store.resetToShipped(false)
assertEquals(shippedAccent, store.resolved(false).palette.accent)
assertEquals(
PaletteSource.CHANGED,
store.resolved(false).roles[PaletteRole.ACCENT]!!.source,
)
assertEquals(6, PaletteOverrides.parse(stored.getValue(false)).size)
}
@Test
fun resetToShippedReproducesEveryShippedRoleExactly() {
/*
* The acceptance criterion says "exactly the shipped colours -- not something that
* resembles them", and the path goes through hex: resetToShipped writes each role as
* a six-digit string and reads it back. That is only lossless because Compose packs an
* sRGB Color at 8 bits per channel, so every channel is already an exact k/255. Pinned
* for all six roles and both designs, because the day the packing changes, nothing
* else would notice.
*/
for (dark in listOf(false, true)) {
val store = store()
val shipped = store.shipped(dark)
store.resetToShipped(dark)
val after = store.resolved(dark).palette
assertEquals(shipped.base, after.base, "surface, dark=$dark")
assertEquals(shipped.windowBackground, after.windowBackground, "window, dark=$dark")
assertEquals(shipped.controlInnerBackground, after.controlInnerBackground, "field, dark=$dark")
assertEquals(shipped.labelTextFill, after.labelTextFill, "text, dark=$dark")
assertEquals(shipped.accent, after.accent, "accent, dark=$dark")
assertEquals(shipped.separatorLine, after.separatorLine, "separator, dark=$dark")
assertEquals(shipped.textAccent, after.textAccent, "text accent, dark=$dark")
}
}
@Test
fun resetToShippedAlsoWinsOverAFileThatRestatesTheShippedSheet() {
/*
* The root profile's real situation: its stylesheet is the shipped sheet, written by
* copyResourceIfRequired. Resetting there must land on the shipped palette and not on
* something the file nudged.
*/
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
fileRoles = JavaFxStylesheet.declaredRoles(
JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText()
)
val store = store()
store.resetToShipped(true)
assertEquals(evening.accent, store.resolved(true).palette.accent)
assertEquals(evening.textAccent, store.resolved(true).palette.textAccent)
assertEquals(evening.labelTextFill, store.resolved(true).palette.labelTextFill)
}
@Test
fun discardingChangesLetsTheFileApplyAgain() {
fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00))
val store = store()
store.set(false, PaletteRole.ACCENT, "#FF0000")
store.discardChanges(false)
assertEquals(Color(0xFF00FF00), store.resolved(false).palette.accent)
assertEquals(PaletteSource.FILE, store.resolved(false).roles[PaletteRole.ACCENT]!!.source)
assertTrue(stored.getValue(false).isEmpty())
}
@Test
fun theSnapshotRestoresWhatWasInForceWhenItWasTaken() {
val store = store()
store.set(false, PaletteRole.ACCENT, "#FF0000")
store.takeSnapshot()
store.set(false, PaletteRole.ACCENT, "#0000FF")
store.set(false, PaletteRole.TEXT, "#00FF00")
store.restoreSnapshot()
// Including the change that was already there: the snapshot is not "shipped".
assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent)
assertEquals(PaletteSource.SHIPPED, store.resolved(false).roles[PaletteRole.TEXT]!!.source)
}
@Test
fun theSnapshotRestoresBothDesignsAndNotOnlyTheOneInForce() {
/*
* takeSnapshot captures both, and the button says "back to how it was". An operator
* who changed a colour, switched day/evening from the main window's menu, changed
* another and then asked to go back means both -- undoing half would leave a state
* they never chose.
*/
val store = store()
store.takeSnapshot()
store.set(false, PaletteRole.ACCENT, "#FF0000")
store.set(true, PaletteRole.ACCENT, "#00FF00")
store.restoreSnapshot()
assertEquals(
PaletteSource.SHIPPED,
store.resolved(false).roles[PaletteRole.ACCENT]!!.source,
"the daylight change survived",
)
assertEquals(
PaletteSource.SHIPPED,
store.resolved(true).roles[PaletteRole.ACCENT]!!.source,
"the evening change survived",
)
}
@Test
fun restoringWithoutASnapshotChangesNothing() {
val store = store()
store.set(false, PaletteRole.ACCENT, "#FF0000")
store.restoreSnapshot()
assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent)
}
@Test
fun theLifelineReadsTheShippedSheetAndNotTheResolvedPalette() {
/*
* The rescue button is drawn in these colours. If it ever read the resolved palette,
* it would vanish in exactly the palette it exists to undo -- and nobody would find
* out until they needed it.
*/
val store = store()
val shippedBase = store.shipped(false).base
PaletteRole.values().forEach { store.set(false, it, "#000000") }
assertEquals(shippedBase, store.shipped(false).base)
assertEquals(Color.Black, store.resolved(false).palette.base)
}
}
@@ -0,0 +1,70 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.observe.SimpleValue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
/**
* The public message tab, the third of the three below the station list.
*
* The other two reuse the monitor tables of Etappe 4 under their own layout ids; this
* one is its own. Its ids are the operator's stored widths, and its rows come off the
* wire half-filled.
*/
class PublicMessageColumnsTest {
@Test
fun `the ids are the ones the stored layout uses`() {
assertEquals(
listOf("time", "callsign", "name", "last-qrg", "message", "category"),
PublicMessageColumns.all().map { it.id },
)
}
@Test
fun `the headings are the ones the operator reads`() {
assertEquals(
listOf("Time", "Callsign", "Name", "Last QRG", "Message", "Category"),
PublicMessageColumns.all().map { it.title() },
)
}
@Test
fun `a message without a sender reads as empty, not as a crash`() {
val bare = ChatMessage()
PublicMessageColumns.all().forEach { column ->
assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
}
}
@Test
fun `the time is a clock time, as it is in every other table`() {
val message = ChatMessage().apply { messageGeneratedTime = "1759000000" }
assertEquals("19:06:40", PublicMessageColumns.all().first { it.id == "time" }.value(message))
}
@Test
fun `callsign, name and QRG come from the sender`() {
val message = ChatMessage().apply {
sender = ChatMember().apply {
callSign = "DN9APW"
name = "Philipp"
frequency = SimpleValue("144.3")
}
messageText = "CQ contest"
}
val columns = PublicMessageColumns.all()
assertEquals("DN9APW", columns.first { it.id == "callsign" }.value(message))
assertEquals("Philipp", columns.first { it.id == "name" }.value(message))
assertEquals("144.300", columns.first { it.id == "last-qrg" }.value(message),
"padded like every other QRG column")
assertEquals("CQ contest", columns.first { it.id == "message" }.value(message))
}
}
@@ -0,0 +1,226 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The panel below the station list, for the station currently selected.
*
* Its nine "tag not qrv" marks are written back to the station and propagated to the
* active members, so getting one wrong hides a station from every list that filters on
* it. The sked controls create a real reminder. Both are worth pinning.
*/
class SelectedStationStateTest {
private class Session {
val propagated = mutableListOf<String>()
var skedsCreated = 0
var lastSkedMinutes: Int? = null
var lastSkedMode: String? = null
val failMarked = mutableListOf<String>()
val failReset = mutableListOf<String>()
fun state() = SelectedStationState(
propagateNotQrv = { propagated.add(it.callSign.orEmpty()) },
createSked = { _, minutes, _, mode ->
skedsCreated++
lastSkedMinutes = minutes
lastSkedMode = mode
true
},
markSkedFail = { failMarked.add(it.callSign.orEmpty()) },
resetSkedFail = { failReset.add(it.callSign.orEmpty()) },
)
}
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun `without a selection there is nothing to act on`() {
val state = Session().state()
assertNull(state.selected)
assertFalse(state.canCreateSked, "a sked needs a station to be with")
assertFalse(state.canLookUp, "there is no callsign to look up")
}
@Test
fun `tagging a band writes it to the station and propagates`() {
val session = Session()
val state = session.state()
val dn9apw = member("DN9APW")
state.select(dn9apw)
state.tagNotQrv(NotQrvBand.B_144, true)
assertFalse(dn9apw.isQrv144, "not QRV means the QRV flag goes false")
assertEquals(listOf("DN9APW"), session.propagated,
"every list filtering on QRV has to learn about it")
}
@Test
fun `tag not qrv all covers every band`() {
val session = Session()
val state = session.state()
val member = member("DO5AMF")
state.select(member)
state.tagNotQrvAll(true)
NotQrvBand.entries.forEach { band ->
assertTrue(state.isTaggedNotQrv(band), "band ${band.label}")
}
assertFalse(member.isQrv50)
assertFalse(member.isQrv10G)
}
@Test
fun `clearing tag not qrv all puts every band back`() {
val state = Session().state()
val member = member("DO5AMF")
state.select(member)
state.tagNotQrvAll(true)
state.tagNotQrvAll(false)
NotQrvBand.entries.forEach { band ->
assertFalse(state.isTaggedNotQrv(band), "band ${band.label}")
}
assertTrue(member.isQrv144)
}
@Test
fun `selecting another station shows that station's tags`() {
val state = Session().state()
val tagged = member("DN9APW").apply { isQrv432 = false }
val untagged = member("DO5AMF")
state.select(tagged)
assertTrue(state.isTaggedNotQrv(NotQrvBand.B_432))
state.select(untagged)
assertFalse(state.isTaggedNotQrv(NotQrvBand.B_432),
"the panel shows the selected station, not the one before it")
}
@Test
fun `a sked needs a selection and is created once`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.skedMinutes = 10
assertTrue(state.canCreateSked)
assertTrue(state.createSked())
assertEquals(1, session.skedsCreated)
assertEquals(10, session.lastSkedMinutes)
}
@Test
fun `creating a sked without a selection does nothing`() {
val session = Session()
val state = session.state()
assertFalse(state.createSked())
assertEquals(0, session.skedsCreated)
}
@Test
fun `the message filter is one choice, not four`() {
val state = Session().state()
state.messageFilter = SelectedMessageFilter.PM_TO_ME
assertEquals(SelectedMessageFilter.PM_TO_ME, state.messageFilter)
state.messageFilter = SelectedMessageFilter.PUBLIC
assertEquals(SelectedMessageFilter.PUBLIC, state.messageFilter,
"the JavaFX radio buttons were one toggle group; two cannot be on")
}
/**
* No filter is chosen until the operator chooses one. JavaFX has the
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
* and starting on a filter would silently hide messages the operator never asked to
* hide.
*/
@Test
fun `no message filter is chosen to begin with`() {
assertNull(Session().state().messageFilter)
}
@Test
fun `the lookup addresses carry the selected callsign`() {
val state = Session().state()
state.select(member("DN9APW"))
assertTrue(state.qrzComUrl().endsWith("DN9APW"))
assertTrue(state.qrzCqUrl().endsWith("DN9APW"))
}
@Test
fun `the sked offers the minutes the JavaFX choice offered`() {
assertEquals(
listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20),
SelectedStationState.SKED_MINUTES,
)
assertEquals(5, Session().state().skedMinutes, "the default it selected")
}
@Test
fun `the sked offers the two modes`() {
assertEquals(listOf("SSB", "CW"), SelectedStationState.SKED_MODES)
}
@Test
fun `the reminder offers the three offset patterns`() {
assertEquals(listOf("2+1", "5+2+1", "10+5+2+1"), SelectedStationState.REMINDER_OFFSETS)
}
@Test
fun `the chosen mode reaches the sked`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.skedMode = "CW"
state.createSked()
assertEquals("CW", session.lastSkedMode)
}
@Test
fun `marking a sked as failed names the selected station`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.markSkedFail()
assertEquals(listOf("DN9APW"), session.failMarked)
}
@Test
fun `resetting the failure names it too`() {
val session = Session()
val state = session.state()
state.select(member("DO5AMF"))
state.resetSkedFail()
assertEquals(listOf("DO5AMF"), session.failReset)
}
@Test
fun `marking a failure without a selection does nothing`() {
val session = Session()
session.state().markSkedFail()
assertTrue(session.failMarked.isEmpty())
}
}
@@ -0,0 +1,44 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertTrue
import java.io.File
/**
* Guards against a setting quietly disappearing during the port.
*
* The JavaFX settings window called these ChatPreferences setters. Every one of
* them must still be called from the Compose settings sources, or an operator
* loses a setting they could configure before — and nothing about that fails to
* compile.
*
* The list was derived mechanically: the setters called between settingsStage and
* the Profiles tab, intersected with the setters ChatPreferences actually declares,
* so JavaFX control setters like setDisable or setPrefWidth are excluded.
*
* This checks text occurrence, not runtime behaviour. It cannot tell whether a
* setter is called with the right value, only that it is called at all. That is
* little, but it is the only check that covers all of them mechanically.
*/
class SettingsFieldCoverageTest {
@Test
fun `every setting the JavaFX window could write is still written`() {
val expected = File("src/test/resources/settings-fields.txt")
.readLines().map { it.trim() }.filter { it.isNotEmpty() }
val composeDir = File("src/main/kotlin/kst4contest/view/compose")
val composeSources = if (composeDir.isDirectory) {
composeDir.walkTopDown().filter { it.extension == "kt" }
.joinToString("\n") { it.readText() }
} else {
""
}
val missing = expected.filterNot { composeSources.contains("$it(") }
assertTrue(missing.isEmpty(),
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
missing.joinToString("\n") { " $it" })
}
}
@@ -0,0 +1,50 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertTrue
import java.io.File
/**
* The second net. SettingsFieldCoverageTest only covers ChatPreferences setters,
* so a write to any other object slips through it — and the JavaFX settings window
* had five such writes:
*
* - setChatCategoryMain and setChatCategorySecondChat on ChatController, called on
* every category selection alongside the ChatPreferences write. Dropping them
* leaves the running session on the old category while the stored setting says
* otherwise.
* - setCallSign, setFrequency and setQra on the own ChatMember, kept in step with
* the station fields.
*
* The list was derived mechanically: the setters called in the settings range that
* ChatPreferences does not declare, intersected with the setters the project's own
* classes declare, which drops JavaFX control setters like setDisable.
*/
class SettingsForeignWriteCoverageTest {
@Test
fun `every write outside ChatPreferences is still performed`() {
val expected = File("src/test/resources/settings-foreign-writes.txt")
.readLines().map { it.trim() }.filter { it.isNotEmpty() }
val composeDir = File("src/main/kotlin/kst4contest/view/compose")
val sources = if (composeDir.isDirectory) {
composeDir.walkTopDown().filter { it.extension == "kt" }
.joinToString("\n") { it.readText() }
} else {
""
}
/*
* A name counts as covered when it appears as a call or as the target of a
* function reference. The category writes reach ChatController through the
* callbacks StationTabState takes, so the wiring in SettingsWindow is what
* satisfies them.
*/
val missing = expected.filterNot { sources.contains("$it(") || sources.contains("::$it") }
assertTrue(missing.isEmpty(),
"writes outside ChatPreferences not performed by any Compose source " +
"(${missing.size} of ${expected.size}):\n" + missing.joinToString("\n") { " $it" })
}
}
@@ -0,0 +1,225 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Compose brings no SplitPane, so this is ours. An operator arranges the main window
* once for their screen and expects it back at the next contest, which makes the stored
* divider positions a setting rather than decoration.
*/
class SplitterStateTest {
// ---- what it starts from ----------------------------------------------
@Test
fun `the stored positions are what it starts from`() {
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.51)) {}
assertEquals(listOf(0.51f), state.positions)
}
/**
* A profile written by an older version has fewer positions than the window now has
* dividers. Falling back beats starting with a window whose panes have no size.
*/
@Test
fun `too few stored positions fall back to an even split`() {
val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62)) {}
assertEquals(listOf(0.2f, 0.4f, 0.6f, 0.8f), state.positions)
}
@Test
fun `too many stored positions fall back as well`() {
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.3, 0.6, 0.9)) {}
assertEquals(listOf(0.5f), state.positions)
}
@Test
fun `positions out of order fall back rather than drawing inside out`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.8, 0.2)) {}
assertEquals(listOf(1f / 3f, 2f / 3f), state.positions)
}
@Test
fun `a position outside the pane falls back`() {
assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(1.4)) {}.positions)
assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(-0.1)) {}.positions)
}
@Test
fun `a single pane has no divider at all`() {
val state = SplitterState(paneCount = 1, stored = doubleArrayOf()) {}
assertTrue(state.positions.isEmpty())
}
// ---- dragging ----------------------------------------------------------
@Test
fun `dragging a divider moves it`() {
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {}
state.drag(index = 0, to = 0.7f, totalPx = 1000f)
assertEquals(0.7f, state.positions[0], 0.001f)
}
/**
* A divider stops at its neighbours. In JavaFX pushing one along shoved the next out
* of the way, which is how operators ended up with panes they could not get back.
*/
@Test
fun `a divider stops at its neighbour rather than pushing it`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
state.drag(index = 0, to = 0.9f, totalPx = 1000f)
assertTrue(state.positions[0] < state.positions[1], "the first divider passed the second")
assertEquals(0.6f, state.positions[1], 0.001f, "the second divider moved")
}
@Test
fun `a divider stops at the previous one too`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
state.drag(index = 1, to = 0.05f, totalPx = 1000f)
assertTrue(state.positions[1] > state.positions[0])
}
/** Every pane keeps a minimum, or a table can be dragged away to nothing by accident. */
@Test
fun `no pane can be dragged smaller than its minimum`() {
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {}
state.drag(index = 0, to = 0.01f, totalPx = 1000f)
assertEquals(0.1f, state.positions[0], 0.001f)
}
@Test
fun `the minimum holds at the far edge as well`() {
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {}
state.drag(index = 0, to = 0.99f, totalPx = 1000f)
assertEquals(0.9f, state.positions[0], 0.001f)
}
/** A window too narrow for the minimums must still lay out, not divide by zero. */
@Test
fun `an impossibly narrow window still yields usable positions`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.33, 0.66), minPanePx = 100f) {}
state.drag(index = 0, to = 0.5f, totalPx = 50f)
assertTrue(state.positions.all { it.isFinite() })
assertTrue(state.positions[0] <= state.positions[1])
}
// ---- saving ------------------------------------------------------------
/**
* Saved on release, not on every mouse move. The JavaFX version called
* requestLayoutSave() on every pixel of a drag and wrote the profile to disk while
* the operator was still holding the mouse down.
*/
@Test
fun `dragging saves nothing until the mouse is released`() {
var saved: DoubleArray? = null
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saved = it }
state.drag(index = 0, to = 0.7f, totalPx = 1000f)
assertNull(saved, "a drag in progress wrote to the profile")
state.dragFinished()
assertArrayEquals(doubleArrayOf(0.7), saved!!, 0.001)
}
/** A drag that changed nothing must not write the profile either. */
@Test
fun `a release without a change saves nothing`() {
var saves = 0
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saves++ }
state.dragFinished()
assertEquals(0, saves)
state.drag(index = 0, to = 0.5f, totalPx = 1000f)
state.dragFinished()
assertEquals(0, saves, "a drag that moved nothing was saved")
}
/** The counterpart of the deferred Etappe 4 minor: a refused move is not a change. */
@Test
fun `a refused drag is not saved`() {
var saves = 0
val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.9), minPanePx = 100f) { saves++ }
state.drag(index = 0, to = 0.99f, totalPx = 1000f)
state.dragFinished()
assertEquals(0, saves, "a drag that was clamped back to where it started was saved")
}
// ---- the dividers take room too ---------------------------------------
/**
* The panes and the divider handles are laid out as siblings, so the panes may only
* have what is left after the handles. Distributing the full length instead overflows
* the splitter by one handle width per divider — 20 dp for the five-pane message
* column — and the last pane is clipped off the bottom.
*/
@Test
fun `panes and dividers together fit the splitter exactly`() {
val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)) {}
val total = 800f
val handle = 5f
val available = SplitterState.availableFor(total, dividerPx = handle, paneCount = 5)
val panes = state.paneSizes(available)
assertEquals(
total,
panes.sum() + handle * 4,
0.01f,
"panes plus handles must be the splitter's length",
)
}
@Test
fun `a splitter with one pane needs no room for dividers`() {
assertEquals(500f, SplitterState.availableFor(500f, dividerPx = 5f, paneCount = 1))
}
/** A splitter too small for its own handles must not hand out negative lengths. */
@Test
fun `a splitter smaller than its handles hands out nothing rather than less than nothing`() {
val available = SplitterState.availableFor(10f, dividerPx = 5f, paneCount = 5)
assertEquals(0f, available)
val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {}
assertTrue(state.paneSizes(available).all { it >= 0f })
}
// ---- what a draw subscribes to ----------------------------------------
@Test
fun `drawing the splitter subscribes to the positions`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
state.positions.forEach { it.toString() }
}
assertTrue(read.isNotEmpty(), "the positions were read without subscribing")
}
@Test
fun `the pane sizes follow from the positions`() {
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.25, 0.75)) {}
assertEquals(listOf(250f, 500f, 250f), state.paneSizes(totalPx = 1000f))
}
@Test
fun `the pane sizes always add up to the whole`() {
val state = SplitterState(paneCount = 4, stored = doubleArrayOf(0.1, 0.2, 0.9)) {}
assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
}
private fun assertNull(value: Any?, message: String) {
assertFalse(value != null, message)
}
}
@@ -0,0 +1,136 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The 22 columns of the station list.
*
* The ids are the operator's stored layout — renaming one loses their column widths
* without a word — and these rows come from the network, so every value has to survive
* a half-filled station.
*
* "wkdany" sits at the END of the worked group, not the start. Pinned to the JavaFX
* table, which adds the band sub-columns under `isStn_bandActiveXX()` and only then
* `workedCol.getColumns().add(wkdAny_subcol)` (`Kst4ContestApplication.java:2496`).
*
* A band cell's text comes from the `formatBand` the caller supplies, because the
* JavaFX cell factory distinguishes worked from new from unavailable; the production
* caller passes `formatBandCellStatus` (`:10460`).
*/
class StationColumnsTest {
private fun columns() = StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, worked -> if (worked) "X" else "" },
)
@Test
fun `the ids are the ones the stored layout uses`() {
assertEquals(
listOf(
"callsign", "name", "qra", "qrb", "qtf", "qrg", "airscout", "tropo",
"score", "activity", "band-50", "band-70", "band-144", "band-432",
"band-1296", "band-2320", "band-3400", "band-5760", "band-10g",
"worked-any", "not-qrv", "category",
),
columns().map { it.id },
)
}
@Test
fun `the headings are the ones the operator reads`() {
assertEquals(
listOf(
"Callsign", "Name", "QRA", "QRB", "QTF", "QRG", "AP [minutes / pot%]",
"Tropo", "Score", "Act", "50", "70", "144", "432", "23",
"13", "9", "6", "3", "wkdany", "NOT QRV @", "Category",
),
columns().map { it.title() },
)
}
@Test
fun `the worked columns are grouped under one heading`() {
// Without the group, ten columns headed "50 70 144 432 23 13 9 6 3 wkdany"
// say nothing about what they mean. The JavaFX table put them under "worked",
// and added wkdany last.
val grouped = columns().filter { it.group == "worked" }.map { it.id }
assertEquals(
listOf("band-50", "band-70", "band-144", "band-432", "band-1296",
"band-2320", "band-3400", "band-5760", "band-10g", "worked-any"),
grouped,
)
}
@Test
fun `columns outside that group carry no group`() {
assertNull(columns().first { it.id == "callsign" }.group)
assertNull(columns().first { it.id == "category" }.group)
}
@Test
fun `a half-filled station reads as empty, not as a crash`() {
val bare = ChatMember()
columns().forEach { column ->
assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
}
}
@Test
fun `a band column shows a cross only when that band was worked`() {
val worked144 = ChatMember().apply { isWorked144 = true }
val band144 = columns().first { it.id == "band-144" }
val band432 = columns().first { it.id == "band-432" }
assertEquals("X", band144.value(worked144))
assertEquals("", band432.value(worked144))
}
@Test
fun `the NOT QRV column names the bands the station is not on`() {
/*
* Every QRV flag starts true — a station is assumed to be on every band until
* it says otherwise — so a fresh row leaves this column empty and it fills as
* bands are marked off. Listing the QRV bands instead would put a full column
* on almost every row and mean the opposite of its heading.
*/
val member = ChatMember().apply {
isQrv432 = false
isQrv1240 = false
}
val notQrv = columns().first { it.id == "not-qrv" }.value(member)
assertTrue(notQrv.contains("70cm"), "432 was marked off")
assertTrue(notQrv.contains("23cm"), "1240 too")
assertTrue(!notQrv.contains("2m"), "144 was never marked off, so it is not listed")
}
@Test
fun `a station on every band has an empty NOT QRV column`() {
assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember()))
}
@Test
fun `tropo and score come from outside the station`() {
val columns = StationColumns.all(
tropoOf = { "12 dB" },
priorityScoreOf = { 250.0 },
formatBand = { _, _, _ -> "" },
)
val member = ChatMember()
assertEquals("12 dB", columns.first { it.id == "tropo" }.value(member),
"reachability is computed by the controller, not carried by the row")
assertEquals("250", columns.first { it.id == "score" }.value(member))
}
}
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The filter bar's rules.
*
* Its 25 toggles are not 25 states. Eighteen switch a predicate in the controller on
* or off, eight are one mutually exclusive group that only writes a bearing into the
* QTF field, and one colours a cell without filtering anything. Getting that wrong
* would put a station in front of the operator that their filter excludes — or hide
* one it does not.
*/
class StationFilterStateTest {
@Test
fun `nothing is filtered to begin with`() {
assertTrue(StationFilterState().activeFilters.isEmpty())
}
@Test
fun `a toggle switches exactly its own filter`() {
val filters = StationFilterState()
filters.onlyNewGrids = true
assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters)
}
@Test
fun `grid colouring is not a filter`() {
val filters = StationFilterState()
filters.gridColouring = true
assertTrue(filters.activeFilters.isEmpty(),
"it colours the locator cell; a station must not vanish because of it")
assertTrue(filters.gridColouring)
}
@Test
fun `each band hides only stations worked on that band`() {
val filters = StationFilterState()
filters.setHideWorkedOn(WorkedBandFilter.B_144, true)
assertEquals(setOf(StationFilter.HIDE_WORKED_144), filters.activeFilters)
filters.setHideWorkedOn(WorkedBandFilter.B_432, true)
assertEquals(
setOf(StationFilter.HIDE_WORKED_144, StationFilter.HIDE_WORKED_432),
filters.activeFilters,
)
}
@Test
fun `a bearing button picks one direction and its degrees`() {
val filters = StationFilterState()
filters.toggleBearing(Bearing.NE)
assertEquals(Bearing.NE, filters.bearing)
assertEquals(45.0, filters.qtfDegrees)
}
@Test
fun `the bearings are mutually exclusive, as their toggle group was`() {
val filters = StationFilterState()
filters.toggleBearing(Bearing.N)
filters.toggleBearing(Bearing.SW)
assertEquals(Bearing.SW, filters.bearing)
assertEquals(225.0, filters.qtfDegrees)
}
/**
* Pressing a direction switches the QTF filter on.
*
* Verified against the JavaFX toggle group rather than assumed: its
* selectedToggleProperty listener does
* `chatMemberTableFilterQtfEnableChkbx.setSelected(true)` whenever a direction is
* selected, and `false` when the selection is cleared
* (`Kst4ContestApplication.java:8758-8767`). An earlier version of this test claimed
* the opposite and had to be taken on faith; it is pinned to the source now.
*/
@Test
fun `picking a bearing switches the QTF filter on, as the toggle group did`() {
val filters = StationFilterState()
filters.toggleBearing(Bearing.E)
assertEquals(setOf(StationFilter.QTF), filters.activeFilters)
assertEquals(90.0, filters.qtfDegrees)
}
/** Pressing the selected direction again clears it, and the filter with it. */
@Test
fun `pressing the same bearing again switches the QTF filter off`() {
val filters = StationFilterState()
filters.toggleBearing(Bearing.E)
filters.toggleBearing(Bearing.E)
assertNull(filters.bearing)
assertTrue(filters.activeFilters.isEmpty())
}
@Test
fun `the QTF filter can also be switched on without a direction`() {
val filters = StationFilterState()
filters.qtfEnabled = true
assertEquals(setOf(StationFilter.QTF), filters.activeFilters)
}
@Test
fun `the maximum QRB filter needs its own switch`() {
val filters = StationFilterState()
filters.maxQrbKm = 300.0
assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing")
filters.maxQrbEnabled = true
assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
}
@Test
fun `a search text filters while it is there and stops when it is cleared`() {
val filters = StationFilterState()
filters.searchText = "DN9"
assertEquals(setOf(StationFilter.SEARCH_TEXT), filters.activeFilters)
filters.searchText = " "
assertTrue(filters.activeFilters.isEmpty(),
"blank is not a search; leaving it would hide every station")
}
@Test
fun `switching a filter off removes it again`() {
val filters = StationFilterState()
filters.hideInactive = true
filters.onlyNewGrids = true
filters.hideInactive = false
assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters)
assertFalse(filters.hideInactive)
}
}
@@ -0,0 +1,414 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ThreadStateMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import androidx.compose.runtime.snapshots.Snapshot
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The status bar tells the operator whether the link is alive and reminds them of a
* sked. Both are read at a glance mid contest, so the mapping from state to badge is
* pinned here rather than left to the eye.
*/
class StatusIndicatorStateTest {
// ---- the connection badge ----------------------------------------------
@Test
fun `online shows the plain badge`() {
assertEquals(ConnectionBadge.ONLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.ONLINE))
assertEquals("LINK", ConnectionBadge.ONLINE.label)
}
@Test
fun `every step of the handshake counts as busy`() {
listOf(
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT,
On4KstConnectionState.STOPPING,
).forEach {
assertEquals(ConnectionBadge.BUSY, ConnectionIndicator.badgeFor(it), "$it")
}
assertEquals("LINK…", ConnectionBadge.BUSY.label)
}
/**
* A scheduled reconnect is not a working link. It reads as offline on purpose: the
* operator must see that nothing is being sent right now.
*/
@Test
fun `waiting for a reconnect reads as offline`() {
assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.RECONNECT_WAIT))
assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.DISCONNECTED))
assertEquals("LINK!", ConnectionBadge.OFFLINE.label)
}
/** Before the first callback there is no state, and no state is not a link. */
@Test
fun `no state at all reads as offline`() {
assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(null))
}
@Test
fun `no connection state is left unmapped`() {
On4KstConnectionState.entries.forEach {
assertNotNull(ConnectionIndicator.badgeFor(it), "$it has no badge")
}
}
@Test
fun `the tooltip names the state and the detail`() {
assertEquals(
"ON4KST link: ONLINE\nLogged in as DN9APW",
ConnectionIndicator.tooltipFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"),
)
}
@Test
fun `a missing detail falls back to the state name`() {
assertEquals(
"ON4KST link: DISCONNECTED\nDISCONNECTED",
ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, " "),
)
assertEquals(
"ON4KST link: DISCONNECTED\nDISCONNECTED",
ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, null),
)
}
// ---- the macOS menu title ---------------------------------------------
/**
* The menu title splits finer than the badge: shutting down and reconnecting each
* get their own wording, because in the menu bar there is room to say it.
*/
@Test
fun `the macOS title distinguishes what the badge merges`() {
assertEquals("🟢 LINK: Connected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.ONLINE))
assertEquals("🟡 LINK: Connecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.CONNECTING))
assertEquals("🟡 LINK: Disconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.STOPPING))
assertEquals("🔴 LINK: Reconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.RECONNECT_WAIT))
assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.DISCONNECTED))
assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(null))
}
/**
* The detail line under the macOS menu title. Same text the tooltip's second line
* carries, because it is the same information reaching the operator by the other route.
*/
@Test
fun `the macOS menu carries the detail as its own line`() {
assertEquals(
"Logged in as DN9APW",
ConnectionIndicator.detailFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"),
)
}
@Test
fun `a missing macOS detail falls back to the state name`() {
assertEquals(
"DISCONNECTED",
ConnectionIndicator.detailFor(On4KstConnectionState.DISCONNECTED, " "),
)
assertEquals("DISCONNECTED", ConnectionIndicator.detailFor(null, null))
}
@Test
fun `no connection state is left without a macOS title`() {
On4KstConnectionState.entries.forEach {
assertTrue(
ConnectionIndicator.macOsMenuTitle(it).contains("LINK"),
"$it has no macOS title",
)
}
}
// ---- the blinking notices ---------------------------------------------
@Test
fun `a notice starts hidden`() {
val notice = BlinkingNotice()
assertFalse(notice.visible)
assertEquals("", notice.text)
}
@Test
fun `showing a notice reveals it with its text`() {
val notice = BlinkingNotice()
notice.show("REMINDER: DL0ABC T-5m")
assertTrue(notice.visible)
assertEquals("REMINDER: DL0ABC T-5m", notice.text)
assertEquals("REMINDER: DL0ABC T-5m", notice.tooltip)
}
/**
* The badge sits in one row with the menu, so a long text is cut. The full text
* stays in the tooltip — nothing is lost, it is only not shouted.
*/
@Test
fun `a long text is cut for the badge but kept in the tooltip`() {
val long = "REMINDER: DL0ABC/P T-10m on 1296 MHz, please turn the antenna"
val notice = BlinkingNotice()
notice.show(long)
assertEquals(38, notice.text.length)
assertTrue(notice.text.endsWith("..."))
assertEquals(long.take(35) + "...", notice.text)
assertEquals(long, notice.tooltip)
}
@Test
fun `a text that just fits is left alone`() {
val exactly38 = "x".repeat(38)
val notice = BlinkingNotice()
notice.show(exactly38)
assertEquals(exactly38, notice.text)
}
@Test
fun `a separate tooltip survives the cut`() {
val notice = BlinkingNotice()
notice.show("BAND+ DL0ABC", tooltip = "DL0ABC is still missing 23cm and 13cm")
assertEquals("BAND+ DL0ABC", notice.text)
assertEquals("DL0ABC is still missing 23cm and 13cm", notice.tooltip)
}
@Test
fun `hiding a notice takes it out of the bar`() {
val notice = BlinkingNotice()
notice.show("REMINDER: DL0ABC T-5m")
notice.hide()
assertFalse(notice.visible)
}
/**
* A second reminder while the first is still blinking must blink again, not sit
* there statically. The revision is what the blink restarts on.
*/
@Test
fun `a second notice restarts the blink`() {
val notice = BlinkingNotice()
notice.show("first")
val afterFirst = notice.revision
notice.show("second")
assertTrue(notice.revision > afterFirst)
assertEquals("second", notice.text)
}
/**
* The blink runs in the view, so it is cancelled if the bar leaves the composition —
* a splitter dragged shut, a profile switch. Without a generation-aware hide the
* notice would stay visible for the rest of the session: a magenta reminder stuck in
* the status bar for a sked that has long passed.
*/
@Test
fun `a notice can be hidden by the generation that showed it`() {
val notice = BlinkingNotice()
notice.show("REMINDER: DL0ABC T-5m")
val generation = notice.revision
notice.hide(generation)
assertFalse(notice.visible)
}
/**
* And a stale blink must not hide the notice that replaced it. When a second reminder
* arrives while the first is still blinking, the first blink's cleanup runs after the
* second has already been shown.
*/
@Test
fun `an older generation cannot hide a newer notice`() {
val notice = BlinkingNotice()
notice.show("first")
val firstGeneration = notice.revision
notice.show("second")
notice.hide(firstGeneration)
assertTrue(notice.visible, "the stale blink hid the reminder that replaced it")
assertEquals("second", notice.text)
}
@Test
fun `the blink lasts as long as it did in JavaFX`() {
assertEquals(24, BlinkingNotice.BLINK_CYCLES)
assertEquals(500L, BlinkingNotice.BLINK_CYCLE_MILLIS)
assertEquals(12_000L, BlinkingNotice.blinkDurationMillis)
}
// ---- what triggers a notice -------------------------------------------
@Test
fun `the reminder text names the call and the minutes left`() {
assertEquals("REMINDER: DL0ABC T-5m", StatusIndicators.reminderText("DL0ABC", 5))
}
@Test
fun `a band upgrade is recognized by the key`() {
val notice = StatusIndicators.bandUpgradeNoticeFor(
key = "BandUpgradeWatcher",
message = threadState(nick = "watcher", description = "BAND+ DL0ABC", info = "23cm missing"),
)
assertNotNull(notice)
assertEquals("BAND+ DL0ABC", notice!!.first)
assertEquals("23cm missing", notice.second)
}
@Test
fun `a band upgrade is recognized by the thread nickname`() {
assertNotNull(
StatusIndicators.bandUpgradeNoticeFor(
key = "worker-7",
message = threadState(nick = "bandUpgrade", description = "BAND+", info = "x"),
),
)
}
@Test
fun `any other thread message is not a band upgrade`() {
assertNull(
StatusIndicators.bandUpgradeNoticeFor(
key = "ClusterReader",
message = threadState(nick = "cluster", description = "12 spots", info = "running"),
),
)
assertNull(StatusIndicators.bandUpgradeNoticeFor(key = "bandupgrade", message = null))
assertNull(StatusIndicators.bandUpgradeNoticeFor(key = null, message = null))
}
/**
* ThreadStateMessage never hands out a blank description — it falls back to "on"
* or "FAILED" itself. So the notice shows that, and the tooltip, which has no such
* fallback, borrows the button text rather than appearing empty.
*/
@Test
fun `a band upgrade without its own wording borrows what it has`() {
val notice = StatusIndicators.bandUpgradeNoticeFor(
key = "bandupgrade",
message = ThreadStateMessage("x", true, " ", false),
)
assertNotNull(notice)
assertEquals("on", notice!!.first)
assertEquals("on", notice.second)
}
// ---- the background thread buttons ------------------------------------
@Test
fun `a thread reports itself into the bar once`() {
val buttons = ThreadStatusButtons()
buttons.update("ClusterReader", threadState(nick = "cluster", description = "12 spots", info = "running"))
buttons.update("ClusterReader", threadState(nick = "cluster", description = "13 spots", info = "still running"))
assertEquals(1, buttons.buttons.size)
assertEquals("ClusterReader: 13 spots", buttons.buttons.single().label)
assertEquals("still running", buttons.buttons.single().tooltip)
}
/** The bar is read left to right; a thread keeps the place it first took. */
@Test
fun `threads keep the order they first reported in`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
buttons.update("Sked", threadState(nick = "s", description = "b", info = "b"))
buttons.update("Cluster", threadState(nick = "c", description = "c", info = "c"))
assertEquals(listOf("Cluster", "Sked"), buttons.buttons.map { it.sourceName })
}
/**
* The flash is the whole point of these buttons: it shows the thread is alive.
* It is on when the message lands and the view times it out again.
*/
@Test
fun `a fresh message flashes the button`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
val button = buttons.buttons.single()
assertTrue(button.highlighted)
button.clearHighlight()
assertFalse(button.highlighted)
buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b"))
assertTrue(button.highlighted)
}
@Test
fun `the flash lasts as long as it did in JavaFX`() {
assertEquals(200L, ThreadStatusButtons.HIGHLIGHT_MILLIS)
}
// ---- what a draw subscribes to ----------------------------------------
/**
* The bar is drawn from these values, so reading them has to register as a read.
* A plain field would draw once and then quietly go stale — the failure mode that
* cost this migration eleven defects, none of which any assertion could see.
*/
@Test
fun `drawing a notice subscribes to what can change`() {
val notice = BlinkingNotice()
notice.show("REMINDER: DL0ABC T-5m")
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
notice.visible
notice.text
notice.tooltip
notice.revision
}
assertEquals(4, read.size, "a notice value was read without subscribing: $read")
}
@Test
fun `drawing the thread buttons subscribes to the list and the labels`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
buttons.buttons.forEach {
it.label
it.tooltip
it.highlighted
}
}
// the list itself plus three values of the single button
assertEquals(4, read.size, "a button value was read without subscribing: $read")
}
/**
* A thread that reports again does not change the list, only its own button. If the
* label were not state, the bar would freeze on the first message a worker sent.
*/
@Test
fun `a later message changes the button without touching the list`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
val listBefore = buttons.buttons
val button = buttons.buttons.single()
buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b"))
assertSame(listBefore, buttons.buttons)
assertSame(button, buttons.buttons.single())
assertEquals("Cluster: b", button.label)
}
private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
ThreadStateMessage(nick, true, info, false).apply {
setRunningInformationTextDescription(description)
}
}
@@ -0,0 +1,163 @@
package kst4contest.view.compose
import kst4contest.model.PaletteRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Which roles a stylesheet actually names.
*
* Not the same question as `read`, and conflating the two is the trap of this stage: `read`
* fills a missing role from the Modena defaults, so a file saying only `-fx-base` would
* silently override all six. A layer needs presence, not a complete palette.
*
* How far apart the two questions are is visible in the shipped sheets themselves -- the
* daylight one states no colour at all, and `read` still returns a full palette for it.
*/
class StylesheetDeclaredRolesTest {
private fun shipped(name: String): String =
JavaFxStylesheet::class.java.getResource(name)!!.readText()
@Test
fun aSheetNamingOneRoleDeclaresOnlyThatRole() {
// The accent, deliberately: nothing is derived from it, so one named means one role.
val declared = JavaFxStylesheet.declaredRoles(".root { -fx-accent: #0096C9; }")
assertEquals(setOf(PaletteRole.ACCENT), declared.keys)
}
@Test
fun aRoleStatedOutsideRootIsFoundAtItsOwnSelector() {
/*
* Three of the six live outside .root. A reader that only looked there would never
* find the text colour or the separator line -- which is half the palette.
*/
val declared = JavaFxStylesheet.declaredRoles(
"""
.label { -fx-text-fill: #112233; }
.separator *.line { -fx-background-color: #445566; }
""".trimIndent()
)
assertEquals(setOf(PaletteRole.TEXT, PaletteRole.SEPARATOR), declared.keys)
}
@Test
fun aSheetStatingTheBaseWithoutTheBackgroundStatesTheDerivedWindowSurfaceToo() {
/*
* What JavaFX itself does: Modena's -fx-background is derive(-fx-base, 26.4%), so a
* sheet that states -fx-base and says nothing about -fx-background has stated the
* window surface as well. Without this the most natural hand edit is nearly inert --
* an operator sets -fx-base to #303030, restarts, and the windows stay light grey,
* because base reaches the screen only through the menu strip and the selection tint.
*
* The file layer only. In the settings tab the two stay independent, which is the
* whole reason the spec keeps them as separate roles: six fields are visible there and
* the operator can set both.
*/
val declared = JavaFxStylesheet.declaredRoles(".root { -fx-base: #303030; }")
assertEquals(setOf(PaletteRole.SURFACE, PaletteRole.WINDOW_SURFACE), declared.keys)
assertEquals(
JavaFxStylesheet.derive(declared.getValue(PaletteRole.SURFACE), 26.4),
declared.getValue(PaletteRole.WINDOW_SURFACE),
)
}
@Test
fun aStatedBackgroundIsNotReplacedByADerivedOne() {
val declared = JavaFxStylesheet.declaredRoles(
".root { -fx-base: #303030; -fx-background: #112233; }"
)
assertEquals(colourOf("#112233"), declared.getValue(PaletteRole.WINDOW_SURFACE))
}
@Test
fun aSheetWithNoBaseDerivesNoWindowSurface() {
val declared = JavaFxStylesheet.declaredRoles(".label { -fx-text-fill: #112233; }")
assertEquals(setOf(PaletteRole.TEXT), declared.keys)
}
@Test
fun theSamePropertyUnderAnotherSelectorIsNotARole() {
// -fx-background-color appears a dozen times in the evening sheet. One is the role.
val declared = JavaFxStylesheet.declaredRoles(
".scroll-bar { -fx-background-color: #445566; } .button:hover { -fx-text-fill: red; }"
)
assertTrue(declared.isEmpty(), "found roles where there are none: ${declared.keys}")
}
@Test
fun aSheetNamingNothingDeclaresNothing() {
assertTrue(JavaFxStylesheet.declaredRoles("").isEmpty())
assertTrue(JavaFxStylesheet.declaredRoles("/* just a comment */").isEmpty())
assertTrue(JavaFxStylesheet.declaredRoles(".button { -fx-padding: 2px; }").isEmpty())
}
@Test
fun theShippedDaylightSheetStatesNoColourAtAll() {
/*
* Measured, not assumed: its .root carries only -fx-font-size, and every colour in
* the daylight design comes from JavaFX's Modena defaults by way of `read`. If this
* ever starts declaring a role, the daylight palette has gained a second source and
* the resolver's layer 1 needs looking at.
*/
val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultDay.css"))
assertTrue(declared.isEmpty(), "the daylight sheet now declares ${declared.keys}")
}
@Test
fun theShippedEveningSheetStatesEveryRole() {
/*
* It states the surface, the field interior, the accent, the label text and the
* separator line outright, and the window surface by implication -- it names -fx-base
* and no -fx-background, which is how JavaFX derives one. This pins the
* selector-and-property mapping against a real file: get a selector wrong and a role
* drops out without any other test noticing.
*/
val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultEvening.css"))
assertEquals(PaletteRole.values().toSet(), declared.keys)
}
@Test
fun aMissingFileDeclaresNothingRatherThanThrowing() {
val absent = File("/nonexistent/kst4contest/does-not-exist.css")
assertTrue(JavaFxStylesheet.declaredRolesOfFile(absent).isEmpty())
}
@Test
fun anUnreadableOrNonsenseFileDeclaresNothingRatherThanThrowing() {
/*
* A client that will not start because of a broken colour file is worse than one in
* the wrong colours. Half-written files and files that are not CSS at all both exist,
* and this one is hand-edited by the operator.
*/
val nonsense = File.createTempFile("kst4contest-palette", ".css")
try {
nonsense.writeText("this is not css { -fx-base")
assertTrue(JavaFxStylesheet.declaredRolesOfFile(nonsense).isEmpty())
} finally {
nonsense.delete()
}
val directory = File.createTempFile("kst4contest-palette-dir", "").let {
it.delete()
it.mkdirs()
it
}
try {
assertTrue(JavaFxStylesheet.declaredRolesOfFile(directory).isEmpty())
} finally {
directory.delete()
}
}
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.PaletteRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* A colour change reaches a composed window without anything else happening.
*
* This is the one mechanism the stage's acceptance criterion hangs on, and it used not to
* work: `Kst4ContestTheme` took its palette from `remember(darkMode)`, so a change reached
* nothing until somebody toggled day/evening. A state-level test of the store cannot see
* that fault -- the store was always right, the theme never asked it again.
*
* Headless: no window is opened, only a composition.
*/
class ThemeReadsPaletteStoreTest {
private var stored = ""
private fun store() = PaletteStore(
shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") },
fileRolesOf = { emptyMap() },
storedOverridesOf = { stored },
storeOverrides = { _, encoded -> stored = encoded },
)
@OptIn(ExperimentalTestApi::class)
@Test
fun aColourSetAfterTheFirstDrawReachesTheComposition() = runComposeUiTest {
val store = store()
val seen = mutableListOf<Color>()
setContent {
CompositionLocalProvider(LocalPaletteStore provides store) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
// Reading the palette is what a control does; record what it was handed.
seen += LocalJavaFxPalette.current.base
}
}
}
waitForIdle()
val firstDraw = seen.last()
store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000")
waitForIdle()
assertEquals(
Color(0xFFFF0000),
seen.last(),
"the composition still holds the palette from before the change, which is the "
+ "remember(darkMode) fault this store exists to fix",
)
assertEquals(store.shipped(false).base, firstDraw, "the first draw was not the shipped base")
}
@OptIn(ExperimentalTestApi::class)
@Test
fun withoutAStoreTheShippedPaletteIsStillDrawn() {
/*
* Not caution: the operator profile picker composes before a profile, and therefore
* before a store, exists. It must still have colours.
*/
runComposeUiTest {
var seen: Color? = null
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
seen = LocalJavaFxPalette.current.base
}
}
waitForIdle()
assertEquals(JavaFxStylesheet.read("/KST4ContestDefaultDay.css").base, seen)
}
}
}
@@ -0,0 +1,51 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Pins the field order of the timeline candidate.
*
* The JavaFX builder produced its own DTO and copied it across field by field; the builder
* now constructs this type directly. Three of the ten values are plain numbers of the same
* kind — minuteBucket, laneIndex and opportunityPotentialPercent are all Int — so a swapped
* pair would compile, run, and quietly draw the right station in the wrong lane at the wrong
* minute. Nothing else checks that.
*/
class TimelineCandidateFieldsTest {
@Test
fun theTenValuesKeepTheirPositions() {
val category = ChatCategory(ChatCategory.VUHF)
val candidate = TimelineCandidate(
"DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip",
)
assertEquals("DL1ABC", candidate.callSignRaw)
assertEquals("DL1ABC/P", candidate.displayCallSign)
assertEquals(category, candidate.preferredChatCategory)
assertEquals(90_000L, candidate.timeUntilMs)
assertEquals(3, candidate.minuteBucket)
assertEquals(1, candidate.laneIndex)
assertEquals(215.0, candidate.targetAzimuth)
assertEquals(42.5, candidate.score)
assertEquals(77, candidate.opportunityPotentialPercent)
assertEquals("tooltip", candidate.tooltipText)
}
@Test
fun theSortTheStripReliesOnIsByMinuteThenLane() {
val category = ChatCategory(ChatCategory.VUHF)
fun candidate(minute: Int, lane: Int) = TimelineCandidate(
"DL1ABC", "DL1ABC", category, 0L, minute, lane, 0.0, 0.0, 0, null,
)
val sorted = listOf(candidate(2, 0), candidate(1, 1), candidate(1, 0))
.sortedWith(compareBy({ it.minuteBucket }, { it.laneIndex }))
// The builder sorts before handing the list over; the strip draws in that order.
assertEquals(listOf(1 to 0, 1 to 1, 2 to 0), sorted.map { it.minuteBucket to it.laneIndex })
}
}
@@ -0,0 +1,316 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The timeline is the one view in this client that is read as a picture rather than as
* text, so its geometry and its fading rules are pinned here. Everything in this file
* is the original JavaFX behaviour; where it looks surprising, it is surprising there
* too and the test says so.
*/
class TimelineStateTest {
private val width = 400f
// ---- the potential colours --------------------------------------------
@Test
fun `the potential steps break where they always did`() {
assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(100))
assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(95))
assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(94))
assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(75))
assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(74))
assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(50))
assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(49))
assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(0))
}
/** A missing potential arrives as a negative number and must not fall through. */
@Test
fun `an unknown potential is the lowest step`() {
assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(-1))
}
// ---- the geometry ------------------------------------------------------
/**
* Counter-intuitive and deliberate: the strip is not a progress bar. Time grows to
* the right, so what is about to happen sits at the LEFT edge.
*/
@Test
fun `now is on the left and the far future on the right`() {
val imminent = TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!!
val distant = TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!!
assertTrue(imminent < distant, "time must grow to the right")
}
@Test
fun `the middle of the window is the middle of the strip`() {
assertEquals(
200f,
TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS / 2, width, TimelineGeometry.SKED_MARGIN)!!,
0.01f,
)
}
/** Both edges are held off, or half a marker would hang outside the strip. */
@Test
fun `a marker never reaches the edge`() {
assertEquals(
TimelineGeometry.SKED_MARGIN,
TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!!,
)
assertEquals(
width - TimelineGeometry.SKED_MARGIN,
TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!!,
)
}
/**
* Candidates are held further from the edge than skeds. Their labels are wider, so
* the original picked two different margins — the difference is real and kept.
*/
@Test
fun `candidates keep further from the edge than skeds`() {
assertTrue(TimelineGeometry.CANDIDATE_MARGIN > TimelineGeometry.SKED_MARGIN)
assertEquals(
TimelineGeometry.CANDIDATE_MARGIN,
TimelineGeometry.xFor(0, width, TimelineGeometry.CANDIDATE_MARGIN)!!,
)
}
@Test
fun `an event outside the window is not placed at all`() {
assertNull(TimelineGeometry.xFor(-1, width, TimelineGeometry.SKED_MARGIN))
assertNull(
TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS + 1, width, TimelineGeometry.SKED_MARGIN),
)
}
/**
* A pane dragged narrower than two margins used to throw: coerceIn(30f, 10f) is an
* empty range, and Kotlin refuses it rather than picking a side. The operator runs a
* tiling compositor, so a strip narrower than 60 px is a Tuesday, not a corner case.
*/
@Test
fun `a strip narrower than its own margins still places a marker`() {
val x = TimelineGeometry.xFor(
timeUntilMs = 5 * 60 * 1000,
width = 40f,
margin = TimelineGeometry.CANDIDATE_MARGIN,
)
assertEquals(20f, x!!, 0.01f, "a narrow strip should centre the marker")
}
@Test
fun `a strip of no width at all does not throw`() {
val x = TimelineGeometry.xFor(0, width = 0f, margin = TimelineGeometry.SKED_MARGIN)
assertEquals(0f, x!!, 0.01f)
}
@Test
fun `the window looks thirty minutes ahead`() {
assertEquals(30L * 60L * 1000L, TimelineGeometry.PREVIEW_MILLIS)
}
// ---- the lanes ---------------------------------------------------------
@Test
fun `the lanes stack downwards in even steps`() {
assertEquals(2f, TimelineGeometry.laneY(0))
assertEquals(22f, TimelineGeometry.laneY(1))
}
/**
* The candidate lanes stay above the axis and the skeds below it. This is the one
* place where this port does not copy the original: there the lanes were laid out
* for a 40 px strip that the call site had already made 80 px tall, so the candidate
* labels, the sked diamonds and the sked labels all landed on the same pixels while
* the lower half stayed empty.
*/
@Test
fun `nothing is drawn across the axis or off the strip`() {
val laneBottom = TimelineGeometry.laneY(TimelineGeometry.LANE_COUNT - 1) + TimelineGeometry.LANE_HEIGHT
assertTrue(
laneBottom <= TimelineGeometry.AXIS_Y,
"candidate lanes reach the axis: $laneBottom vs ${TimelineGeometry.AXIS_Y}",
)
assertTrue(
TimelineGeometry.SKED_Y > TimelineGeometry.AXIS_Y,
"skeds must sit below the axis",
)
assertTrue(
TimelineGeometry.SKED_Y + TimelineGeometry.LANE_HEIGHT <= TimelineGeometry.HEIGHT,
"the sked lane runs off the bottom",
)
}
/** A third lane would land on the axis, so it shares the last one instead. */
@Test
fun `a lane beyond the available ones folds onto the last`() {
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2))
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7))
assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1))
}
// ---- the labels --------------------------------------------------------
@Test
fun `a label sits to the right of its marker`() {
assertEquals(54f, TimelineGeometry.labelStartX(markerX = 50f, labelWidth = 60f, stripWidth = width, gap = 4f))
}
/** Near the right edge it flips instead of running off — half an hour away there is always room. */
@Test
fun `a label near the right edge flips to the left`() {
assertEquals(
336f,
TimelineGeometry.labelStartX(markerX = 400f, labelWidth = 60f, stripWidth = width, gap = 4f),
)
}
@Test
fun `a label wider than the strip still starts on it`() {
assertEquals(
0f,
TimelineGeometry.labelStartX(markerX = 10f, labelWidth = 900f, stripWidth = width, gap = 4f),
)
}
// ---- the antenna fading -----------------------------------------------
@Test
fun `a target dead ahead glows and grows`() {
val e = AntennaEmphasis.forAzimuth(currentAzimuth = 90.0, targetAzimuth = 90.0, beamWidthDeg = 50.0)
assertEquals(1f, e.iconAlpha)
assertTrue(e.glowing)
assertEquals(1.10f, e.scale)
}
@Test
fun `the glow reaches exactly half a beam width`() {
assertTrue(AntennaEmphasis.forAzimuth(0.0, 25.0, 50.0).glowing)
assertFalse(AntennaEmphasis.forAzimuth(0.0, 25.1, 50.0).glowing)
}
/**
* Between half a beam and a full beam the marker stays bright without the glow. The
* station is still workable; it is just not centred.
*/
@Test
fun `just off centre stays bright without the glow`() {
val e = AntennaEmphasis.forAzimuth(0.0, 40.0, 50.0)
assertEquals(1f, e.iconAlpha)
assertFalse(e.glowing)
assertEquals(1f, e.scale)
}
@Test
fun `the full beam width is still bright`() {
assertEquals(1f, AntennaEmphasis.forAzimuth(0.0, 50.0, 50.0).iconAlpha)
}
/**
* Beyond the beam the marker fades but does not vanish. An operator must be able to
* see a chance in a direction they are not pointing, or turning the rotator would
* never occur to them.
*/
@Test
fun `a target outside the beam fades to a ghost but stays visible`() {
val e = AntennaEmphasis.forAzimuth(0.0, 120.0, 50.0)
assertEquals(0.30f, e.iconAlpha)
assertFalse(e.glowing)
assertTrue(e.iconAlpha > 0f, "a ghost is still a marker")
}
/** North is the case that breaks a naive subtraction: 350° and 10° are 20° apart. */
@Test
fun `the beam reaches across north`() {
assertTrue(AntennaEmphasis.forAzimuth(350.0, 10.0, 50.0).glowing)
assertTrue(AntennaEmphasis.forAzimuth(10.0, 350.0, 50.0).glowing)
}
@Test
fun `an unknown azimuth is left readable`() {
assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, -1.0, 50.0))
assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, Double.NaN, 50.0))
}
// ---- the state --------------------------------------------------------
@Test
fun `the default beam width is the one the original assumed`() {
assertEquals(50.0, TimelineState().beamWidthDeg)
}
@Test
fun `a nonsense beam width is refused rather than drawn`() {
val state = TimelineState()
state.setBeamWidth(0.0)
assertEquals(50.0, state.beamWidthDeg)
state.setBeamWidth(-30.0)
assertEquals(50.0, state.beamWidthDeg)
state.setBeamWidth(Double.NaN)
assertEquals(50.0, state.beamWidthDeg)
state.setBeamWidth(12.5)
assertEquals(12.5, state.beamWidthDeg)
}
@Test
fun `the antenna direction decides the emphasis`() {
val state = TimelineState()
state.antennaAzimuth = 200.0
assertTrue(state.emphasisFor(200.0).glowing)
assertEquals(0.30f, state.emphasisFor(20.0).iconAlpha)
}
// ---- what a draw subscribes to ----------------------------------------
/**
* The strip is redrawn on the score pulse, on every rotator step and on every
* resize. Each of those has to reach the screen on its own, so reading any of it
* must register as a read.
*/
@Test
fun `drawing the timeline subscribes to what can change`() {
val state = TimelineState()
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
state.skeds.size
state.candidates.size
state.antennaAzimuth
state.beamWidthDeg
}
assertEquals(4, read.size, "a timeline value was read without subscribing: $read")
}
@Test
fun `replacing the candidates replaces them rather than appending`() {
val state = TimelineState()
state.replaceCandidates(listOf(candidate("DL0ABC", lane = 0)))
state.replaceCandidates(listOf(candidate("DL0XYZ", lane = 0), candidate("DL0QRS", lane = 1)))
assertEquals(listOf("DL0XYZ", "DL0QRS"), state.candidates.map { it.displayCallSign })
}
private fun candidate(call: String, lane: Int) = TimelineCandidate(
callSignRaw = call,
displayCallSign = call,
preferredChatCategory = null,
timeUntilMs = 60_000,
minuteBucket = 1,
laneIndex = lane,
targetAzimuth = 90.0,
score = 1.0,
opportunityPotentialPercent = 80,
tooltipText = null,
)
}
@@ -0,0 +1,140 @@
package kst4contest.view.compose
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.unit.Density
import kst4contest.controller.ScoreService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The window the "more" button opens.
*
* Its whole content is one line per candidate, so the line is the thing worth pinning:
* an operator reads the callsign and the score and nothing else, and the JavaFX version
* spelled it a particular way for two releases.
*/
class TopPriorityCandidatesWindowTest {
/**
* The category is irrelevant to the label and is left null on purpose: a candidate
* that reached the list without one must still read as a line rather than as a
* crash.
*/
private fun candidate(call: String, score: Double) =
ScoreService.TopCandidate(call, call, null, score)
private val twoCandidates =
listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0))
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
}
return picked
}
private fun ImageComposeScene.clickAt(y: Float, timeMillis: Long) {
val at = Offset(100f, y)
sendPointerEvent(PointerEventType.Press, at, timeMillis = timeMillis)
sendPointerEvent(PointerEventType.Release, at, timeMillis = timeMillis)
}
@Test
fun `a candidate reads as its callsign and its score`() {
assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0)))
}
/** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */
@Test
fun `the score is rounded to whole points`() {
assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6)))
assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4)))
}
@Test
fun `a score of zero still reads as a score`() {
assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0)))
}
/**
* Review Focus 3. Empty is the state the window opens in before the first score
* run, and a list that throws while measuring takes the window with it — which is
* the failure class the map's layout tests exist to catch.
*/
@Test
fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking ->
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
}
try {
scene.render()
} finally {
scene.close()
}
}
}
/**
* A double click picks the candidate under it, and picks the right one.
*
* Both clicks need an explicit `timeMillis`. `detectTapGestures` discards a second
* press that arrives less than `doubleTapMinTimeMillis` (40 ms) after the first
* release, and back-to-back `sendPointerEvent` calls default to the same timestamp
* — so a synthetic burst reads as one click, not two. The gap below is 50 ms.
*
* Note the asymmetry if you change these numbers: the 40 ms floor is measured
* against the timestamps supplied here, while the 300 ms ceiling is measured
* against the wall clock, so an implausible 2000 ms "gap" would also pass. 50 ms is
* used because it is what the assertion claims to be testing.
*
* The y of 32 is the second row, so this pins the per-row binding rather than only
* that something fired.
*/
@Test
fun `a double click picks the candidate it lands on`() {
assertEquals("PA6I", picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
scene.clickAt(y = 10f, timeMillis = 1050L)
})
assertEquals("DK7SE", picking { scene ->
scene.clickAt(y = 32f, timeMillis = 1000L)
scene.clickAt(y = 32f, timeMillis = 1050L)
})
}
/**
* A single click must not pick a candidate.
*
* The window exists so the operator can read more than the first two candidates,
* which means clicking about in it has to be safe: picking closes the window. The
* JavaFX list required `getClickCount() >= 2` for exactly that reason.
*
* This is the half that protects the window's reason to exist, so it stays even
* though the positive case above now covers the binding.
*/
@Test
fun `a single click does not pick a candidate`() {
val picked = picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
Thread.sleep(400)
}
assertNull(picked) { "a single click picked $picked and would have closed the window" }
}
}
@@ -0,0 +1,122 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The two priority buttons beside the station list.
*
* They are a shortcut to the station the score says to work next, so what they name
* and what they do when pressed is the whole of their value.
*
* `refresh()` is what fills them, and the score service is what calls it
* (`Kst4ContestApplication` wires it to `topCandidates()` and calls it once at
* startup). Reading the ranking on demand instead would never reach the screen: the
* ranking is not Compose state, so a button derived from it could not ask for a
* redraw when the score changed.
*/
class TopPriorityStateTest {
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun `the buttons name the highest scoring stations and their score`() {
val pa6i = member("PA6I")
val dk7se = member("DK7SE")
val state = TopPriorityState(
topStations = { listOf(pa6i to 968.0, dk7se to 895.0) },
onSelect = { },
)
state.refresh()
assertEquals(listOf("1 PA6I 968", "2 DK7SE 895"), state.entries.map { it.label })
}
@Test
fun `only the first two are offered`() {
val state = TopPriorityState(
topStations = { (1..5).map { member("DL${it}ABC") to it.toDouble() } },
onSelect = { },
)
state.refresh()
assertEquals(2, state.entries.size, "the rest are behind the more button")
}
@Test
fun `fewer than two stations gives fewer buttons`() {
val state = TopPriorityState(topStations = { listOf(member("DN9APW") to 10.0) }, onSelect = { })
state.refresh()
assertEquals(1, state.entries.size)
}
@Test
fun `with no stations there is nothing to press`() {
val state = TopPriorityState(topStations = { emptyList() }, onSelect = { })
state.refresh()
assertTrue(state.entries.isEmpty())
}
@Test
fun `pressing one selects that station`() {
val pa6i = member("PA6I")
var selected: ChatMember? = null
val state = TopPriorityState(topStations = { listOf(pa6i to 968.0) }, onSelect = { selected = it })
state.refresh()
state.entries.first().select()
assertSame(pa6i, selected)
}
@Test
fun `a station without a callsign is skipped rather than shown blank`() {
val state = TopPriorityState(
topStations = { listOf(ChatMember() to 500.0, member("DN9APW") to 400.0) },
onSelect = { },
)
state.refresh()
assertEquals(listOf("1 DN9APW 400"), state.entries.map { it.label })
}
/**
* Nothing is shown until the score service has spoken.
*
* Deliberate: the ranking lives outside Compose, so the buttons are pushed from the
* score service rather than pulled during composition. An earlier version derived
* them on read, which looked simpler and could never have updated the screen.
*/
@Test
fun `the buttons stay empty until the score service fills them`() {
val state = TopPriorityState(topStations = { listOf(member("PA6I") to 968.0) }, onSelect = { })
assertTrue(state.entries.isEmpty(), "nothing should appear before the first refresh")
state.refresh()
assertEquals(listOf("1 PA6I 968"), state.entries.map { it.label })
}
@Test
fun `a later refresh replaces what the buttons said`() {
var ranking = listOf(member("PA6I") to 968.0)
val state = TopPriorityState(topStations = { ranking }, onSelect = { })
state.refresh()
ranking = listOf(member("DK7SE") to 1200.0)
state.refresh()
assertEquals(listOf("1 DK7SE 1200"), state.entries.map { it.label })
}
}
@@ -0,0 +1,31 @@
package kst4contest.view.compose
import androidx.compose.material3.Typography
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class TypographyScalingTest {
@Test
fun `the base size replaces the body size and the rest keeps its ratio`() {
val original = Typography()
val originalBody = original.bodyMedium.fontSize.value
val originalTitle = original.titleMedium.fontSize.value
val scaled = original.scaledTo(originalBody * 2f)
assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f)
assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f,
"every style scales by the same factor, so the design keeps its proportions")
}
@Test
fun `a base size of zero or less is ignored`() {
val original = Typography()
assertEquals(original.bodyMedium.fontSize.value,
original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f)
assertEquals(original.bodyMedium.fontSize.value,
original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f)
}
}
@@ -0,0 +1,123 @@
package kst4contest.view.compose
import kst4contest.model.UpdateInformation
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
/**
* When the update window opens, and what it says.
*
* The decision matters in both directions: a window that opens without an update is a
* daily annoyance, and one that stays shut when an update exists defeats the whole
* mechanism.
*/
class UpdateWindowStateTest {
private fun semantic(version: String) = UpdateInformation().apply {
latestSemanticVersionOnServer = version
}
private fun numeric(number: Double) = UpdateInformation().apply {
latestVersionNumberOnServer = number
}
private fun state(information: UpdateInformation?) =
UpdateWindowState(information, currentVersion = "1.50.0", currentVersionNumber = 1.50)
@Test
fun `a newer semantic version on the server is an update`() {
assertTrue(state(semantic("1.51.0")).updateAvailable)
assertTrue(state(semantic("2.0.0")).updateAvailable)
}
@Test
fun `the same or an older semantic version is not`() {
assertFalse(state(semantic("1.50.0")).updateAvailable,
"the running version is not an update to itself")
assertFalse(state(semantic("1.49.9")).updateAvailable,
"a server rolled back must not push the operator downwards")
}
@Test
fun `without a semantic version the numeric comparison decides`() {
val older = numeric(1.49)
val newer = numeric(1.51)
assertFalse(older.hasSemanticVersion(), "this is the path the test is about")
assertFalse(state(older).updateAvailable)
assertTrue(state(newer).updateAvailable)
}
@Test
fun `absent update information is not an update`() {
// The check runs at startup. A download that failed must not keep the
// application from coming up, and must not claim an update either.
assertFalse(state(null).updateAvailable)
}
@Test
fun `the version lines carry the product name, not the directory name`() {
val state = state(semantic("1.51.0"))
assertEquals("KST4Contest 1.50.0", state.installedVersion)
assertEquals("KST4Contest 1.51.0", state.latestVersion)
}
@Test
fun `a change log row becomes a heading with its lines`() {
val information = semantic("1.51.0").apply {
changeLog = arrayListOf(
arrayOf("1.51.0", "Compose settings window", "Tabs sized to their titles"),
arrayOf("1.50.0", "Gradle build"),
)
}
val sections = state(information).changeLog
assertEquals(2, sections.size)
assertEquals("1.51.0", sections[0].title)
assertEquals(
listOf("Compose settings window", "Tabs sized to their titles"),
sections[0].entries,
)
assertEquals(listOf("Gradle build"), sections[1].entries)
}
@Test
fun `a heading without lines is kept`() {
val information = semantic("1.51.0").apply {
changeLog = arrayListOf(arrayOf("1.51.0"))
}
val sections = state(information).changeLog
assertEquals(1, sections.size,
"a version that brought no listed change is still worth showing as released")
assertTrue(sections[0].entries.isEmpty())
}
@Test
fun `known bugs read the same way as the change log`() {
val information = semantic("1.51.0").apply {
bugList = arrayListOf(arrayOf("Profile switch", "The confirmation opens behind"))
}
val sections = state(information).knownBugs
assertEquals("Profile switch", sections[0].title)
assertEquals(listOf("The confirmation opens behind"), sections[0].entries)
}
@Test
fun `an empty update information yields empty text rather than null`() {
val state = state(UpdateInformation())
assertEquals("", state.majorChanges)
assertEquals("", state.adminMessage)
assertEquals("", state.releasePageUrl)
assertTrue(state.changeLog.isEmpty())
assertTrue(state.knownBugs.isEmpty())
}
}
@@ -0,0 +1,45 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* Enter, Escape and Ctrl+1…Ctrl+0 belong to the whole window, not to the message field.
*
* JavaFX hung all three on the SCENE (7455-7474), and the tables even re-fired the send
* button from their own key filter. An operator who has just clicked a station, a message row
* or a button must still be able to press Enter and have the message go — and Ctrl+1…Ctrl+0
* is how snippets are used at all, since the operator's hands are on the keyboard.
*/
class WindowShortcutsTest {
@Test
fun `control and the digit keys map to the ten snippet slots`() {
assertEquals(0, WindowShortcuts.snippetIndexFor("1"))
assertEquals(1, WindowShortcuts.snippetIndexFor("2"))
assertEquals(8, WindowShortcuts.snippetIndexFor("9"))
}
/** Ctrl+0 is the tenth, not the first — the row of digits reads 1…9 then 0. */
@Test
fun `control zero is the tenth slot`() {
assertEquals(9, WindowShortcuts.snippetIndexFor("0"))
}
@Test
fun `any other key is not a snippet shortcut`() {
assertNull(WindowShortcuts.snippetIndexFor("a"))
assertNull(WindowShortcuts.snippetIndexFor(""))
assertNull(WindowShortcuts.snippetIndexFor("12"))
}
/** All ten slots are reachable and none is reachable twice. */
@Test
fun `the ten digits cover the ten slots exactly once`() {
val indices = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
.mapNotNull { WindowShortcuts.snippetIndexFor(it) }
assertEquals((0..9).toList(), indices)
}
}
@@ -0,0 +1,278 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.IOException
import java.nio.file.Files
import javax.imageio.ImageIO
import kotlin.math.abs
/**
* What the map actually puts on the canvas.
*
* Asserted on the rendered image, because that is the only place the answers live: the
* drawing code takes a viewport and paints, and no amount of reading it proves a dot
* came out 12 px wide. These are the acceptance criteria of the projection rewrite and
* of the grid planner, in the form the operator experiences them.
*
* Tiles never arrive here. The fetcher is asked asynchronously and the test renders one
* frame, so what is measured is the map's own drawing over its own background — which is
* also the case an operator meets without a network.
*/
class ComposeStationMapDrawingTest {
private val canvasWidth = 900
private val canvasHeight = 600
/** The default station-dot border, `#4da6ff` in the Leaflet stylesheet. */
private val dotBorder = 0x4DA6FF
/** The home marker and the antenna beam, `#ff4d4d`. */
private val homeColour = 0xFF4D4D
/** The Maidenhead label colour, `#63067a`. */
private val gridLabel = 0x63067A
private val darkMapBackground = 0x23282D
/*
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
* construction. The default fetcher read the user's disk cache and the real tile
* server, and on CI a tile now and then made it in before the frame was rendered.
*/
private val offlineTileFetcher = TileFetcher(
Files.createTempDirectory("kst4contest-tile-cache-test"),
{ throw IOException("no tile source in this test") },
)
private fun station(call: String, locator: String, lat: Double, lon: Double) =
MapCallsignRawSnapshot(
call, call, locator, lat, lon, "", emptyMap(),
false, false, false, false, 0.0, 0.0, 0, 0L,
)
private fun render(
zoom: Float,
markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
ownLocator6: String = "",
): BufferedImage {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap(
markers = markers,
tileFetcher = offlineTileFetcher,
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = false,
selectedMarkerCallsign = null,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = 90.0,
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
onMarkerClicked = null,
darkMode = true,
modifier = Modifier.fillMaxSize(),
)
}
try {
return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
} finally {
scene.close()
}
}
private fun countColour(image: BufferedImage, rgb: Int): Int {
var count = 0
for (x in 0 until image.width) {
for (y in 0 until image.height) {
if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) count++
}
}
return count
}
/** The widest horizontal run of a colour, which for a ring is its outer diameter. */
private fun widestRun(image: BufferedImage, rgb: Int): Int {
var widest = 0
for (y in 0 until image.height) {
var first = -1
var last = -1
for (x in 0 until image.width) {
if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) {
if (first < 0) first = x
last = x
}
}
if (first >= 0) widest = maxOf(widest, last - first + 1)
}
return widest
}
/** The mean position of a colour, or null when it is absent. */
private fun centroidOf(image: BufferedImage, rgb: Int): Pair<Double, Double>? {
var sumX = 0L
var sumY = 0L
var count = 0
for (x in 0 until image.width) {
for (y in 0 until image.height) {
if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) {
sumX += x
sumY += y
count++
}
}
}
return if (count == 0) null else Pair(sumX.toDouble() / count, sumY.toDouble() / count)
}
/**
* The invariant the projection rewrite exists to make obvious: a size written in the
* drawing code is the size that reaches the canvas. The previous version reached the
* same geometry by dividing each size by the zoom fraction inside a scaled canvas,
* which held only as long as every single site remembered to divide.
*/
@Test
fun `a station dot is the same size at every zoom`() {
val widths = listOf(6.0f, 6.3f, 6.5f, 6.9f, 11.5f, 16.25f).map { widestRun(render(it), dotBorder) }
assertTrue(widths.all { it > 0 }) { "no station dot was drawn at all: $widths" }
assertEquals(1, widths.distinct().size, "the dot changed size across zooms: $widths")
}
/**
* Home, the beam and the path line have to sit in the same coordinate system as the
* stations.
*
* In the previous version they did not: `scale(scaleFactor)` closed before the beam
* block, which then used the unscaled formula while the markers above it had been
* drawn scaled. At an integer zoom the factor is 1 and the two agreed; at any zoom
* with a fraction the beam started somewhere other than home.
*/
@Test
fun `home sits exactly on a station at the same position, at every zoom`() {
/* Off the centre of the view on purpose: at the centre a scaling error is zero. */
val home = kst4contest.locatorUtils.Location("JO50JP")
val homeLat = home.latitude.toDegrees()
val homeLon = home.longitude.toDegrees()
val stationOnTopOfHome = listOf(station("DL0HOME", "JO50JP", homeLat, homeLon))
for (zoom in listOf(6f, 6.5f, 7.25f, 8.25f)) {
/*
* Two renders of the same place rather than one: the station dot is drawn
* over the beam layer and would simply hide the home dot.
*/
val withStationOnly = render(zoom, markers = stationOnTopOfHome, ownLocator6 = "")
val withHomeOnly = render(zoom, markers = emptyList(), ownLocator6 = "JO50JP")
val homeCentre = centroidOf(withHomeOnly, homeColour)
val stationCentre = centroidOf(withStationOnly, dotBorder)
assertTrue(homeCentre != null && stationCentre != null) {
"at zoom $zoom: home=$homeCentre station=$stationCentre"
}
assertTrue(
abs(homeCentre!!.first - stationCentre!!.first) <= 1.0 &&
abs(homeCentre.second - stationCentre.second) <= 1.0
) {
"at zoom $zoom home sits at $homeCentre but the station at the same place is at $stationCentre"
}
}
}
@Test
fun `an unreachable tile source leaves the map in its own colour, not the system grey`() {
val image = render(6f, markers = emptyList())
assertTrue(countColour(image, darkMapBackground) > image.width * image.height / 2) {
"the map background should cover the canvas while no tiles have arrived"
}
}
/** `#63067a`, centred in the cell — not white, not in the corner. */
@Test
fun `grid labels are drawn in the colour the Leaflet map used`() {
assertTrue(countColour(render(6f), gridLabel) > 0) { "no grid label was drawn" }
}
/**
* At six-digit precision the grid has far more cells than it has room for labels.
* The planner answers that with a stride; without it every cell got one and the map
* disappeared under text, which is what the 30 September screenshot shows.
*/
@Test
fun `grid labels stay sparse where the cells are small`() {
val image = render(12f)
val labelPixels = countColour(image, gridLabel)
val canvasPixels = image.width * image.height
assertTrue(labelPixels < canvasPixels / 20) {
"grid labels cover $labelPixels of $canvasPixels pixels, which is a carpet rather than a grid"
}
}
/** Four-digit squares at zoom 6, two-digit fields at zoom 4. */
@Test
fun `the grid gets finer as the map zooms in`() {
val atFour = countColour(render(4f), gridLabel)
val atSix = countColour(render(6f), gridLabel)
assertTrue(atFour > 0 && atSix > 0) { "labels missing: zoom 4 -> $atFour, zoom 6 -> $atSix" }
assertTrue(atSix > atFour) {
"a four-digit grid should show more labels than a two-digit one: $atSix vs $atFour"
}
}
/**
* Every grid line has to be drawn once.
*
* One stroked rectangle per cell paints each interior line twice — once by the cell
* on either side — so at the specified 0.48 a shared edge reaches about 0.73 while
* the outermost line stays at 0.48. The result is a grid of visibly uneven weight,
* which is the kind of thing that makes the map not look like the original. Leaflet
* drew each line once.
*
* Asserted as "no pixel carries more line colour than one full pass can put there".
* Antialiasing of a 1.4 px line at a fractional position legitimately produces many
* shades BELOW that value; none can legitimately exceed it.
*/
@Test
fun `no grid line is painted over itself`() {
/* #e1e7ec at 0.48 over #23282d — one pass, fully covering a pixel. */
val singlePassRed = (0.48 * 0xE1 + 0.52 * 0x23).toInt()
val image = render(6f, markers = emptyList())
/*
* Only rows that cross vertical lines. A row lying on a horizontal grid line is
* bright across its whole width, and where the two cross they legitimately
* overlap — Leaflet's polylines crossed too.
*/
var brightest = 0
for (y in 0 until image.height) {
var lit = 0
var rowBrightest = 0
for (x in 0 until image.width) {
val pixel = image.getRGB(x, y) and 0xFFFFFF
if (pixel == darkMapBackground) continue
lit++
val red = (pixel shr 16) and 0xFF
if (red > rowBrightest) rowBrightest = red
}
val crossesAHorizontalLine = lit > image.width / 10
if (!crossesAHorizontalLine && rowBrightest > brightest) brightest = rowBrightest
}
assertTrue(brightest > 0) { "no grid line was drawn at all" }
assertTrue(brightest <= singlePassRed + 2) {
"a grid pixel reached $brightest where one pass tops out at $singlePassRed — " +
"the line was painted more than once"
}
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Test
/**
* The map canvas has to survive every zoom and every canvas size.
*
* Markers carry labels, and a label is measured against the canvas it is drawn on. A
* label whose anchor has drifted outside the canvas makes Compose build text
* constraints from a negative remaining width, which is not a Constraints value that
* exists. Fractional zoom levels are included deliberately: the canvas scales its whole
* drawing by the fraction, so sizes that are fine at zoom 6 are not at zoom 6.5.
*/
class ComposeStationMapLayoutTest {
private fun station(call: String, lat: Double, lon: Double, selected: Boolean = false) =
MapCallsignRawSnapshot(
call, call, "JO50JP", lat, lon, "144, 432", emptyMap(),
true, false, false, selected, 100.0, 90.0, 0, 0L,
)
private val stations = listOf(
station("DN9APW", 50.5, 10.5),
station("DL3JIN", 50.9, 11.9),
station("F5JMI", 44.0, 5.0, selected = true),
station("SM7KOJ", 60.0, 15.0),
)
private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
ComposeStationMap(
markers = stations,
tileFetcher = TileFetcher(),
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = true,
selectedMarkerCallsign = "F5JMI",
ownLocator6 = "JO50JP",
antennaAzimuthDeg = 229.0,
antennaBeamWidthDeg = 60.0,
maxQrbKm = 800.0,
onMarkerClicked = null,
darkMode = true,
modifier = Modifier.fillMaxSize(),
)
}
try {
scene.render()
} finally {
scene.close()
}
}
@Test
fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f)
@Test
fun `renders at a fractional zoom`() = renderAt(900, 600, 6.5f)
@Test
fun `renders when the canvas is narrow`() = renderAt(40, 600, 6.5f)
@Test
fun `renders when the canvas is short`() = renderAt(900, 30, 6.5f)
@Test
fun `renders at the highest zoom`() = renderAt(900, 600, 17.5f)
@Test
fun `renders at one pixel`() = renderAt(1, 1, 6.5f)
}
@@ -0,0 +1,65 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Test
/**
* The analysis column has to survive being narrow.
*
* The station map splitter can be dragged until this column is a sliver, and the
* window may be resized below any width the layout assumed. Both happened, and both
* ended in an IllegalArgumentException out of Constraints rather than a squeezed
* column — the window then put up an error dialog over the map.
*
* Rendering is the assertion: ImageComposeScene measures, lays out and draws, which is
* exactly where invalid constraints are built. It ships with compose.desktop.currentOs,
* so this needs no test dependency the project does not already have.
*/
class PathAnalysisDetailsLayoutTest {
/** Narrower than the 130 dp the assessment label used to reserve for itself. */
private val narrowWidthPx = 120
private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
val scene = ImageComposeScene(
width = widthPx,
height = 600,
density = Density(1f),
) {
PathAnalysisDetails(
result = result,
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
scene.render()
} finally {
scene.close()
}
}
@Test
fun `renders when the column is narrower than the assessment label`() {
renderAt(narrowWidthPx, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
}
@Test
fun `renders at a width no operator could drag it below`() {
renderAt(1, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
}
@Test
fun `renders without a result`() {
renderAt(narrowWidthPx, null)
}
@Test
fun `renders at a comfortable width`() {
renderAt(350, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
}
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
/**
* The value column keeps its share of a narrow analysis pane.
*
* Every row of this column pairs a label with a value, and the value is what an
* operator reads: a locator, a distance, a propagation assessment. The JavaFX original
* used a GridPane whose value column took the remaining width. The Compose port gave
* the assessment label a fixed 130 dp instead, so once the splitter narrowed the pane
* the label kept its 130 dp and the assessment was squeezed — visible as clipped text
* in the 30 September screenshot.
*
* Measured on the rendered image rather than through a callback, so the production
* composable carries nothing that exists only for this test. The assessment box is the
* one opaque coloured band in the column, which makes its left edge findable.
*/
class PathAnalysisDetailsWidthTest {
/** The severity-0 assessment background in dark mode, as PathAnalysisDetails paints it. */
private val assessmentBackground = 0x33383E
private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
val png = scene.render().encodeToData()!!.bytes
return ImageIO.read(ByteArrayInputStream(png))
} finally {
scene.close()
}
}
/** The smallest x at which the assessment background appears, or -1 when it does not. */
private fun assessmentLeftEdge(image: BufferedImage): Int {
for (x in 0 until image.width) {
for (y in 0 until image.height) {
if ((image.getRGB(x, y) and 0xFFFFFF) == assessmentBackground) return x
}
}
return -1
}
@Test
fun `the assessment value starts where every other value starts`() {
val paneWidth = 200
val leftEdge = assessmentLeftEdge(render(paneWidth))
assertTrue(leftEdge >= 0) { "the assessment box was not drawn at all" }
assertTrue(
leftEdge <= paneWidth * 0.45,
{
"the assessment value starts at x=$leftEdge of $paneWidth px; " +
"DetailRow starts its values at about 38%, so this column is out of line"
},
)
}
/**
* The real column scrolls, so height is not what limits it. At 60 px every value
* above wraps into a stack of lines and pushes this row far down; the height here
* is generous for that reason, not because the pane is ever this tall.
*/
@Test
fun `the assessment box is still drawn in a sliver of a pane`() {
assertTrue(assessmentLeftEdge(render(60, heightPx = 3000)) >= 0)
}
}
@@ -0,0 +1,46 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import org.junit.jupiter.api.Test
/**
* The terrain profile chart has to survive being small.
*
* It is the only part of the map window that draws text onto a canvas whose size it
* does not control: the splitter above it and the window around it both shrink it. A
* canvas narrower than the chart's own margins puts the axis labels outside the canvas,
* and Compose builds the text measurement constraints from the distance between the
* label and the canvas edge — a distance that is negative once the label is outside.
*/
class PathProfileChartLayoutTest {
private fun renderAt(widthPx: Int, heightPx: Int) {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true)
}
try {
scene.render()
} finally {
scene.close()
}
}
@Test
fun `renders at the height the window gives it`() = renderAt(800, 210)
@Test
fun `renders when narrower than its own left margin`() = renderAt(40, 210)
@Test
fun `renders when shorter than its own header`() = renderAt(800, 20)
@Test
fun `renders at one pixel`() = renderAt(1, 1)
}
@@ -0,0 +1,119 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.SplitterOrientation
import kst4contest.view.compose.SplitterPane
import kst4contest.view.compose.SplitterState
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Test
/**
* The map window's body, assembled the way the window assembles it.
*
* Each part of this body renders on its own at one pixel. The window still put up a
* Constraints error dialog, and the answer was a barrier that leaves the window blank
* below 500x400 dp — so the combination is what was never checked. The splitter hands
* its panes a width it computed, the left pane stacks a weighted canvas above a fixed
* 210 dp chart, and the right pane scrolls. That arrangement is this test.
*/
class StationMapBodyLayoutTest {
private val stations = listOf(
MapCallsignRawSnapshot(
"DN9APW", "DN9APW", "JO50JP", 50.5, 10.5, "144, 432", emptyMap(),
true, false, false, false, 100.0, 90.0, 0, 0L,
),
)
@Composable
private fun Body(pathAnalysisVisible: Boolean) {
val splitter = SplitterState(paneCount = 2, stored = doubleArrayOf(0.65), save = { })
val leftPane: @Composable () -> Unit = {
Column(Modifier.fillMaxHeight()) {
ComposeStationMap(
markers = stations,
tileFetcher = TileFetcher(),
mapState = MapState(zoom = 6.5f, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = true,
selectedMarkerCallsign = null,
ownLocator6 = "JO50JP",
antennaAzimuthDeg = 229.0,
antennaBeamWidthDeg = 60.0,
maxQrbKm = 800.0,
onMarkerClicked = null,
darkMode = true,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
if (pathAnalysisVisible) {
PathProfileChart(
modifier = Modifier.fillMaxWidth().height(210.dp),
darkMode = true,
)
}
}
}
val rightPane: @Composable () -> Unit = {
PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
darkMode = true,
modifier = Modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
)
}
if (pathAnalysisVisible) {
SplitterPane(
state = splitter,
orientation = SplitterOrientation.HORIZONTAL,
panes = listOf(leftPane, rightPane),
modifier = Modifier.fillMaxSize(),
)
} else {
leftPane()
}
}
private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
Body(pathAnalysisVisible)
}
try {
scene.render()
} finally {
scene.close()
}
}
@Test
fun `renders at the window's minimum size`() = renderAt(900, 600)
/** Below the barrier that commit e7c8a9b9 installed. */
@Test
fun `renders below the barrier that hid the defect`() = renderAt(499, 399)
@Test
fun `renders when shorter than the chart it stacks`() = renderAt(900, 150)
@Test
fun `renders when the detail pane is a sliver`() = renderAt(120, 400)
@Test
fun `renders without the path analysis`() = renderAt(300, 200, pathAnalysisVisible = false)
@Test
fun `renders at one pixel`() = renderAt(1, 1)
}
@@ -0,0 +1,82 @@
package kst4contest.view.compose.map
import androidx.compose.ui.graphics.ImageBitmap
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The decoded tiles the map draws from.
*
* A decoded 256x256 tile is about 262 KB, against roughly 20 KB for the PNG it came
* from. The encoded cache below this one is capped at 2048 entries; this one was not
* capped at all, so an evening of panning at zoom 12 to 15 retained hundreds of
* megabytes of bitmaps that will never be looked at again.
*/
class TileBitmapCacheTest {
private fun bitmap() = ImageBitmap(1, 1)
@Test
fun `a tile that was put in comes back out`() {
val cache = TileBitmapCache(maxEntries = 4)
val tile = bitmap()
cache.put("6/1/1", tile)
assertEquals(tile, cache["6/1/1"])
}
@Test
fun `an unknown tile is absent rather than an error`() {
assertNull(TileBitmapCache(maxEntries = 4)["6/1/1"])
}
@Test
fun `the cache does not grow past its bound`() {
val cache = TileBitmapCache(maxEntries = 3)
repeat(10) { cache.put("6/$it/0", bitmap()) }
assertEquals(3, cache.size)
}
/** The one dropped is the one that has gone longest without being drawn. */
@Test
fun `the least recently drawn tile is the one dropped`() {
val cache = TileBitmapCache(maxEntries = 3)
cache.put("a", bitmap())
cache.put("b", bitmap())
cache.put("c", bitmap())
/* Drawing "a" again makes "b" the oldest. */
cache["a"]
cache.put("d", bitmap())
assertNotNull(cache["a"])
assertNull(cache["b"])
assertNotNull(cache["c"])
assertNotNull(cache["d"])
}
@Test
fun `a tile that turned out to be unusable can be removed`() {
val cache = TileBitmapCache(maxEntries = 3)
cache.put("6/1/1", bitmap())
cache.remove("6/1/1")
assertNull(cache["6/1/1"])
assertEquals(0, cache.size)
}
@Test
fun `a bound of zero keeps nothing rather than everything`() {
val cache = TileBitmapCache(maxEntries = 0)
cache.put("6/1/1", bitmap())
assertEquals(0, cache.size)
}
}
@@ -0,0 +1,64 @@
package kst4contest.view.compose.map
import kst4contest.view.map.MapViewport
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Which tiles a view needs.
*
* The world wraps east to west; the tile index does not. A view straddling the
* antimeridian asks for indices outside the valid range, and asking the tile service
* for one of those is a 404 — a hole in the map rather than a wrapped tile.
*/
class TileKeyTest {
@Test
fun `a tile index past the eastern edge comes back round to the west`() {
assertEquals(0, wrapTileX(64, 63))
assertEquals(1, wrapTileX(65, 63))
assertEquals(63, wrapTileX(-1, 63))
assertEquals(62, wrapTileX(-2, 63))
assertEquals(0, wrapTileX(-64, 63))
}
@Test
fun `a tile index inside the range is left alone`() {
assertEquals(0, wrapTileX(0, 63))
assertEquals(33, wrapTileX(33, 63))
assertEquals(63, wrapTileX(63, 63))
}
@Test
fun `every tile a view asks for exists at that zoom`() {
for (zoom in 3..10) {
val lastIndex = (1 shl zoom) - 1
/* Hard against the antimeridian, where the wrap actually happens. */
val viewport = MapViewport(179.5, 0.0, zoom.toDouble(), 1200.0, 800.0)
val keys = visibleTileKeys(viewport)
assertTrue(keys.isNotEmpty()) { "no tiles requested at zoom $zoom" }
assertTrue(keys.all { it.x in 0..lastIndex && it.y in 0..lastIndex }) {
"zoom $zoom asked for " + keys.filter { it.x !in 0..lastIndex || it.y !in 0..lastIndex }
}
assertTrue(keys.all { it.zoom == zoom })
}
}
@Test
fun `a view beyond the poles asks for no tiles above or below the world`() {
val viewport = MapViewport(10.0, 84.0, 3.0, 1200.0, 800.0)
val keys = visibleTileKeys(viewport)
val lastIndex = (1 shl 3) - 1
assertTrue(keys.all { it.y in 0..lastIndex }) { "asked for a row that does not exist: $keys" }
}
@Test
fun `the cache key is the path the tile service uses`() {
assertEquals("6/33/21", TileKey(6, 33, 21).cacheKey)
}
}
@@ -0,0 +1,132 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class AirscoutTabStateTest {
private class Session {
val prefs = ChatPreferences()
val state by lazy { AirscoutTabState(prefs) }
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
s.prefs.setAirScout_asUDPListenerEnabled(true)
s.prefs.setAirScout_asServerNameString("AS")
s.prefs.setAirScout_asClientNameString("KST")
s.prefs.setAirScout_asCommunicationPort(9872)
s.prefs.setAirScout_autoBandSelectionEnabled(true)
s.prefs.setAirScout_asBandString("1440000")
assertTrue(s.state.airScout_asUDPListenerEnabled)
assertEquals("AS", s.state.airScout_asServerNameString)
assertEquals("KST", s.state.airScout_asClientNameString)
assertEquals(9872, s.state.airScout_asCommunicationPort)
assertTrue(s.state.airScout_autoBandSelectionEnabled)
assertEquals("1440000", s.state.airScout_asBandString)
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
val s = Session()
s.state.airScout_asUDPListenerEnabled = true
s.state.airScout_asServerNameString = "AS2"
s.state.airScout_asClientNameString = "KST2"
s.state.airScout_asCommunicationPort = 9873
s.state.airScout_autoBandSelectionEnabled = true
s.state.airScout_asBandString = "4320000"
assertTrue(s.prefs.isAirScout_asUDPListenerEnabled())
assertEquals("AS2", s.prefs.getAirScout_asServerNameString())
assertEquals("KST2", s.prefs.getAirScout_asClientNameString())
assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
assertTrue(s.prefs.isAirScout_autoBandSelectionEnabled())
assertEquals("4320000", s.prefs.getAirScout_asBandString())
}
@Test
fun `identifiers are stored trimmed`() {
val s = Session()
s.state.airScout_asServerNameString = " AS "
s.state.airScout_asClientNameString = " KST "
assertEquals("AS", s.prefs.getAirScout_asServerNameString())
assertEquals("KST", s.prefs.getAirScout_asClientNameString())
}
@Test
fun `an invalid identifier keeps the configured one instead of falling back`() {
// The AirScout protocol carries these identifiers inside quoted fields:
// empty, quotation marks and line breaks are what the JavaFX window rejected.
// Guarding before the write matters because the ChatPreferences setter does
// not keep the old value on bad input, it substitutes the factory default —
// an unguarded keystroke would silently reset the operator's identifier.
val s = Session()
s.prefs.setAirScout_asServerNameString("AS7")
s.prefs.setAirScout_asClientNameString("KST7")
for (rejected in listOf("", " ", "A\"S", "A\rS", "A\nS")) {
s.state.airScout_asServerNameString = rejected
s.state.airScout_asClientNameString = rejected
assertFalse(AirscoutTabState.isValidIdentifier(rejected), "must be rejected: <$rejected>")
}
assertEquals("AS7", s.prefs.getAirScout_asServerNameString())
assertEquals("KST7", s.prefs.getAirScout_asClientNameString())
}
@Test
fun `a port outside the accepted range keeps the configured one`() {
val s = Session()
s.prefs.setAirScout_asCommunicationPort(9999)
s.state.airScout_asCommunicationPort = 0
s.state.airScout_asCommunicationPort = 65536
assertEquals(9999, s.prefs.getAirScout_asCommunicationPort(),
"the ChatPreferences setter would have substituted 9872 here")
s.state.airScout_asCommunicationPort = 1
assertEquals(1, s.prefs.getAirScout_asCommunicationPort())
s.state.airScout_asCommunicationPort = 65535
assertEquals(65535, s.prefs.getAirScout_asCommunicationPort(),
"the JavaFX field accepted 1..65535; the range must not get narrower")
}
@Test
fun `the band value is stored as the protocol spells it`() {
val s = Session()
s.prefs.setAirScout_asBandString("1440000")
s.state.airScout_asBandString = " 04320000 "
assertEquals("4320000", s.prefs.getAirScout_asBandString(),
"the JavaFX field stored Long.toString of the parsed value")
s.state.airScout_asBandString = "0"
s.state.airScout_asBandString = "-1"
s.state.airScout_asBandString = "144 MHz"
assertEquals("4320000", s.prefs.getAirScout_asBandString(),
"a value that is not a positive number was rejected, not stored — the "
+ "ChatPreferences setter would have substituted 1440000 here")
}
@Test
fun `the forced band value is only editable while automatic selection is off`() {
val s = Session()
s.state.airScout_autoBandSelectionEnabled = true
assertFalse(s.state.bandValueEditable,
"the JavaFX band field was disabled while auto per station was selected")
s.state.airScout_autoBandSelectionEnabled = false
assertTrue(s.state.bandValueEditable)
}
}
@@ -0,0 +1,138 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
class BeaconTabStateTest {
private class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences()
var timerRestarts = 0
var validated = mutableListOf<String>()
fun state() = BeaconTabState(
prefs,
validateBeaconTemplate = { template ->
validated.add(template)
templateRefusal?.let { throw IllegalArgumentException(it) }
},
restartBeaconTimer = { timerRestarts++ },
mainCategoryName = "144 MHz and up",
secondCategoryName = "50 MHz",
)
}
@Test
fun `the enable flags write through at once`() {
val s = Session()
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
state.bcn_beaconsEnabledSecondCat = true
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertTrue(s.prefs.isBcn_beaconsEnabledSecondCat())
state.bcn_beaconsEnabledSecondCat = false
assertFalse(s.prefs.isBcn_beaconsEnabledSecondCat())
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
}
@Test
fun `a valid beacon template is validated and stored verbatim`() {
val s = Session()
val state = s.state()
val template = "CQ MYCALL MYLOC QRV MYQRG"
assertNull(state.commitBeaconTextMainCat(template))
assertEquals(template, s.prefs.getBcn_beaconTextMainCat(),
"MYCALL and MYQRG are resolved by MessageVariableResolver; "
+ "any rewriting would break that silently")
assertEquals(listOf(template), s.validated)
}
@Test
fun `the second category has its own template`() {
val s = Session()
val state = s.state()
state.commitBeaconTextSecondCat("CQ MYCALL on MYQRG")
assertEquals("CQ MYCALL on MYQRG", s.prefs.getBcn_beaconTextSecondCat())
assertEquals("Hi, pse call us", s.prefs.getBcn_beaconTextMainCat(),
"the main-category template is untouched")
}
@Test
fun `a refused template is reported and the stored one stands`() {
val s = Session("Message too long")
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
val state = s.state()
val refusal = state.commitBeaconTextMainCat("x".repeat(500))
assertNotNull(refusal)
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat(),
"the JavaFX field restored the previous template on an alert")
}
@Test
fun `the shared interval never reads below the minimum`() {
val s = Session()
s.prefs.setBcn_beaconIntervalInMinutesMainCat(0)
assertEquals(ChatController.MIN_BEACON_INTERVAL_MINUTES, s.state().beaconIntervalMinutes)
}
@Test
fun `a valid interval is written to both categories and restarts the timer`() {
val s = Session()
val state = s.state()
assertNull(state.commitBeaconInterval("7"))
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat(),
"the legacy second-category value stays synchronized with the shared interval")
assertEquals(1, s.timerRestarts)
}
@Test
fun `an interval below the minimum is refused and the timer is left alone`() {
val s = Session()
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
val state = s.state()
assertNotNull(state.commitBeaconInterval("0"))
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts,
"restarting the timer per rejected keystroke would disturb the cooldown")
}
@Test
fun `an interval that is not a whole number is refused`() {
val s = Session()
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
val state = s.state()
assertNotNull(state.commitBeaconInterval("2,5"))
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
}
@Test
fun `the category names are the labels the tab shows`() {
val s = Session()
assertEquals("144 MHz and up", s.state().mainCategoryName)
assertEquals("50 MHz", s.state().secondCategoryName)
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.input.key.Key
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The keyboard path to the three ways back.
*
* The spec requires one for all three, and says why: with no "not yet applied" state the ways
* back are the only way back, and they must be reachable without colour perception. Tabbing
* blindly through six text fields to find three buttons is reachable in the sense that a
* locked door with the key inside is openable.
*
* Only the decision is tested, not the KeyEvent plumbing -- the same split WindowShortcuts
* uses, and for the same reason: a desktop KeyEvent wraps a native AWT event and testing it
* would test Compose rather than this.
*/
class ColourShortcutsTest {
@Test
fun theThreeWaysBackHaveTheirOwnKeys() {
assertEquals(ColourAction.BACK_TO_PREVIOUS, ColourShortcuts.actionFor(true, Key.Z))
assertEquals(ColourAction.DISCARD_CHANGES, ColourShortcuts.actionFor(true, Key.D))
assertEquals(ColourAction.RESET_TO_SHIPPED, ColourShortcuts.actionFor(true, Key.R))
}
@Test
fun eachActionHasExactlyOneKeyAndNoKeyHasTwoActions() {
val keys = listOf(Key.Z, Key.D, Key.R)
val actions = keys.map { ColourShortcuts.actionFor(true, it) }
assertEquals(ColourAction.values().toList(), actions)
assertEquals(ColourAction.values().size, actions.toSet().size)
}
@Test
fun withoutControlTheKeysAreJustTypingAndMustReachTheFields() {
/*
* Six hex fields sit in this tab. A bare letter shortcut would eat the typing it is
* meant to protect -- #DDEEFF contains a D.
*/
assertNull(ColourShortcuts.actionFor(false, Key.Z))
assertNull(ColourShortcuts.actionFor(false, Key.D))
assertNull(ColourShortcuts.actionFor(false, Key.R))
}
@Test
fun anyOtherKeyIsNotAShortcut() {
assertNull(ColourShortcuts.actionFor(true, Key.A))
assertNull(ColourShortcuts.actionFor(true, Key.Enter))
assertNull(ColourShortcuts.actionFor(true, Key.Escape))
}
@Test
fun theKeysDoNotCollideWithTheMainWindowsOwnShortcuts() {
/*
* The main window takes Enter, Escape and Ctrl+1 to Ctrl+0. None of the three here may
* be one of those, or an operator with the settings window focused would fire both.
*/
for (digit in listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")) {
assertNull(
kst4contest.view.compose.WindowShortcuts.snippetIndexFor("z"),
"a letter key became a snippet slot",
)
}
assertNull(ColourShortcuts.actionFor(true, Key.Enter))
assertNull(ColourShortcuts.actionFor(true, Key.Escape))
}
@Test
fun everyActionIsLabelledForTheOperatorToRead() {
// A shortcut nobody is told about is not a second safety net.
ColourAction.values().forEach {
assert(ColourShortcuts.labelFor(it).isNotBlank()) { "$it has no label" }
}
}
}
@@ -0,0 +1,219 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.graphics.Color
import kst4contest.model.PaletteRole
import kst4contest.view.compose.ContrastPair
import kst4contest.view.compose.JavaFxStylesheet
import kst4contest.view.compose.PaletteSource
import kst4contest.view.compose.OperatorProfilePaletteFiles
import kst4contest.view.compose.PaletteStore
import kst4contest.view.i18n.Strings
import kst4contest.view.i18n.Translations
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* The colours tab as a typed facade over the store.
*
* Built over a store made of lambdas, so nothing here touches the operator's home directory.
* What is pinned is what the tab shows and what it refuses: a role's colour, where that
* colour came from, and the fact that nonsense typed into a field changes nothing.
*/
class ColoursTabStateTest {
private val stored = mutableMapOf(false to "", true to "")
private var fileRoles: Map<PaletteRole, Color> = emptyMap()
private var darkMode = false
private fun baseStrings(): Strings {
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)
return Strings { key -> base[key] ?: key }
}
private fun state(): ColoursTabState {
val store = PaletteStore(
shippedOf = { dark ->
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { fileRoles },
storedOverridesOf = { dark -> stored.getValue(dark) },
storeOverrides = { dark, encoded -> stored[dark] = encoded },
)
return ColoursTabState(
store,
{ darkMode },
OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
eveningFile = File("/nonexistent/evening.css"),
),
)
}
@Test
fun everyRoleIsShownWithItsColourAndItsProvenance() {
val tab = state()
val rows = tab.roles()
assertEquals(PaletteRole.values().toList(), rows.map { it.role })
rows.forEach {
assertEquals(PaletteSource.SHIPPED, it.source, "${it.role}")
assertTrue(it.hex.matches(Regex("#[0-9A-F]{6}")), "${it.role} shows '${it.hex}'")
/*
* The label is resolved at the point of use rather than held, so the tab retitles
* its rows when the language changes. Resolved here against the base language.
*/
assertTrue(it.label(baseStrings()).isNotBlank(), "${it.role} has no label")
}
}
@Test
fun aSetColourIsShownAsChanged() {
val tab = state()
val refusal = tab.set(PaletteRole.ACCENT, "#FF0000")
val row = tab.roles().single { it.role == PaletteRole.ACCENT }
assertEquals("#FF0000", row.hex)
assertEquals(PaletteSource.CHANGED, row.source)
assertNull(refusal, "a valid colour was refused: $refusal")
}
@Test
fun aRoleTakenFromTheFileSaysSo() {
/*
* The mitigation the spec calls mandatory: with two sources that can decide the same
* colour, "why is this colour what it is" has to be answerable per role.
*/
fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00))
val tab = state()
assertEquals(
PaletteSource.FILE,
tab.roles().single { it.role == PaletteRole.ACCENT }.source,
)
}
@Test
fun nonsenseTypedIntoAFieldChangesNothingAndIsReported() {
val tab = state()
val before = tab.roles().single { it.role == PaletteRole.TEXT }.hex
/*
* The refusal is returned rather than pushed, which is the contract Form.committed
* expects: it puts the field back to the stored value and shows the reason.
*/
for (nonsense in listOf("rot", "", "#GGG", "#1234567", "ECECEC", " ")) {
val refusal = tab.set(PaletteRole.TEXT, nonsense)
assertTrue(
refusal != null && refusal.isNotBlank(),
"'$nonsense' was accepted or refused without a reason",
)
}
assertEquals(before, tab.roles().single { it.role == PaletteRole.TEXT }.hex)
assertEquals(PaletteSource.SHIPPED, tab.roles().single { it.role == PaletteRole.TEXT }.source)
}
@Test
fun settingARoleToTheColourItAlreadyHasIsNotARefusal() {
/*
* set() decides by comparing the colour before and after, so a valid colour that
* happens to equal the current one changes nothing -- and reporting that as "'#ECECEC'
* is not a colour" would be a lie the operator cannot argue with. The field commits on
* focus loss, so this happens whenever somebody tabs through without editing.
*/
val tab = state()
val unchanged = tab.roles().single { it.role == PaletteRole.SURFACE }.hex
assertNull(tab.set(PaletteRole.SURFACE, unchanged), "an unchanged colour was refused")
assertNull(tab.set(PaletteRole.SURFACE, unchanged.lowercase()), "case alone was refused")
}
@Test
fun theRowsFollowTheActiveDesign() {
val tab = state()
tab.set(PaletteRole.ACCENT, "#FF0000")
darkMode = true
// The evening design has its own overrides; the daylight change must not show here.
assertEquals(
PaletteSource.SHIPPED,
tab.roles().single { it.role == PaletteRole.ACCENT }.source,
)
}
@Test
fun theWarningsDescribeTheResolvedPaletteAgainstTheShippedOne() {
val tab = state()
assertTrue(tab.warnings().isEmpty(), "the untouched state warns: ${tab.warnings()}")
tab.set(PaletteRole.TEXT, "#FFFFFF")
// White label text on the light window surface is unreadable and worse than shipped.
assertTrue(
tab.warnings().any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE },
"no warning for white on light grey: ${tab.warnings()}",
)
}
@Test
fun theLifelineColoursComeFromTheShippedSheet() {
/*
* The reason this accessor exists at all. A tab reading its rescue colours from the
* resolved palette would draw them in exactly the palette they exist to undo.
*/
val tab = state()
val shippedBase = tab.shipped().base
PaletteRole.values().forEach { tab.set(it, "#000000") }
assertEquals(shippedBase, tab.shipped().base)
assertFalse(shippedBase == Color.Black, "the shipped base is black, so this proves nothing")
}
@Test
fun theThreeWaysBackDoTheThreeDifferentThings() {
fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00))
val tab = state()
// Back to how it was when the tab opened, including a change already present.
tab.set(PaletteRole.ACCENT, "#FF0000")
tab.rememberCurrentState()
tab.set(PaletteRole.ACCENT, "#0000FF")
tab.restoreRemembered()
assertEquals("#FF0000", tab.roles().single { it.role == PaletteRole.ACCENT }.hex)
// Discard my changes: the file applies again.
tab.discardChanges()
assertEquals(
PaletteSource.FILE,
tab.roles().single { it.role == PaletteRole.ACCENT }.source,
)
// Back to shipped: beats the file too.
tab.resetToShipped()
val row = tab.roles().single { it.role == PaletteRole.ACCENT }
assertEquals(PaletteSource.CHANGED, row.source)
assertEquals(tab.shipped().accent, kst4contest.view.compose.colourOf(row.hex))
}
@Test
fun theFileRouteNamesThisDesignsOwnStylesheet() {
val tab = state()
assertTrue(tab.stylesheetPath().endsWith("day.css"), tab.stylesheetPath())
assertFalse(tab.stylesheetExists(), "a nonexistent file was reported as present")
darkMode = true
assertTrue(tab.stylesheetPath().endsWith("evening.css"), tab.stylesheetPath())
}
}
@@ -0,0 +1,84 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LanguageStoreFactory
import kst4contest.view.i18n.SYSTEM_LANGUAGE
import org.junit.jupiter.api.Test
import java.util.Locale
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class GuiOptionsTabStateTest {
/*
* Over lambdas rather than over the preferences, so switching the language in a test does
* not write into the operator's home directory. What this file is about is the GUI tab's
* booleans; the language is covered by LanguageStoreTest.
*/
private var storedLanguage = SYSTEM_LANGUAGE
private fun languageStore() = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.UK,
)
@Test
fun theLanguageIsWrittenThroughTheStoreAndReachesThePreference() {
/*
* Over the real factory rather than the lambda store above, because what this asserts
* is the wiring: setting the language on the tab state has to land in the preference
* that gets saved, and it has to do so through the store, which is what repaints the
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
assertEquals("de", state.language)
assertEquals("de", prefs.guiOptions_language)
}
@Test
fun `reading comes straight from the preferences`() {
val prefs = ChatPreferences()
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
}
@Test
fun `writing reaches the preferences at once`() {
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
}
@Test
fun `the four startup filters are independent switches`() {
// The JavaFX tab offered them as four separate checkboxes, not a choice of
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_defaultFilterPmToMe = true
state.guiOptions_defaultFilterPmToOther = false
state.guiOptions_defaultFilterNothing = false
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_defaultFilterPmToMe())
assertFalse(prefs.isGuiOptions_defaultFilterPmToOther())
assertFalse(prefs.isGuiOptions_defaultFilterNothing())
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.graphics.Color
import kst4contest.view.compose.JavaFxStylesheet
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kst4contest.view.compose.CONTRAST_FLOOR
import kst4contest.view.compose.contrastRatio
/**
* The colours of the one control that refuses the operator's palette.
*
* The fault this guards against closes itself in: an operator who sets text and surface to
* the same black can no longer see the buttons that would undo it. So this control is drawn
* from the shipped sheet, always, and that is checked here against a palette in which every
* role has been changed.
*
* The composable itself takes the shipped palette as a required parameter and never reads
* LocalJavaFxPalette, so it has no way to reach the resolved palette at all. These tests
* cover the colour decision; that it cannot be fed the wrong palette is structural.
*/
class LifelineColoursTest {
private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
@Test
fun theColoursComeFromThePaletteItIsGiven() {
val colours = lifelineColours(shipped)
assertEquals(shipped.base, colours.container)
assertEquals(shipped.labelTextFill, colours.content)
}
@Test
fun aFullyBlackedOutPaletteGivesDifferentColoursThanTheShippedOne() {
/*
* The palette an operator locks themselves out with. If the lifeline ever read it,
* these two would agree and the button would be invisible exactly when needed.
*/
val blackedOut = shipped.copy(
base = Color.Black,
windowBackground = Color.Black,
controlInnerBackground = Color.Black,
labelTextFill = Color.Black,
accent = Color.Black,
separatorLine = Color.Black,
)
assertNotEquals(lifelineColours(shipped), lifelineColours(blackedOut))
}
@Test
fun theShippedColoursAreReadableAgainstEachOther() {
/*
* A lifeline has to be legible on its own terms. Both shipped sheets pass here, which
* is not true of every pair in them -- the accent does not -- so this is a real check
* and not a restatement.
*/
for (sheet in listOf("/KST4ContestDefaultDay.css", "/KST4ContestDefaultEvening.css")) {
val colours = lifelineColours(JavaFxStylesheet.read(sheet))
val ratio = contrastRatio(colours.content, colours.container)
assertTrue(ratio >= CONTRAST_FLOOR, "$sheet gives the lifeline a ratio of $ratio")
}
}
@Test
fun theBorderIsVisibleAgainstTheContainer() {
// The border is what makes it read as a control rather than as a patch of colour.
val colours = lifelineColours(shipped)
assertNotEquals(colours.container, colours.border)
}
}
@@ -0,0 +1,156 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class LogSynchTabStateTest {
private class Session(detected: String? = null) {
val prefs = ChatPreferences()
var restarts = 0
var stops = 0
val detectedBroadcast = detected
fun state() = LogSynchTabState(
prefs,
restartWintestListener = { restarts++ },
stopWintestListener = { stops++ },
detectWintestBroadcastAddress = { detectedBroadcast },
)
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(true)
s.prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly("/tmp/wkd.txt")
s.prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(true)
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
s.prefs.setLogsynch_wintestNetworkPort(9871)
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
val state = s.state()
assertTrue(state.logsynch_fileBasedWkdCallInterpreterEnabled)
assertEquals("/tmp/wkd.txt", state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly)
assertTrue(state.logsynch_ucxUDPWkdCallListenerEnabled)
assertEquals(12060, state.logsynch_ucxUDPWkdCallListenerPort)
assertEquals(9871, state.logsynch_wintestNetworkPort)
assertEquals("KST", state.logsynch_wintestNetworkStationNameOfKST)
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
val s = Session()
val state = s.state()
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
state.logsynch_ucxUDPWkdCallListenerEnabled = true
state.logsynch_ucxUDPWkdCallListenerPort = 12060
state.logsynch_wintestNetworkPort = 9871
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
}
@Test
fun `the Win-Test station name and broadcast address are stored trimmed`() {
// The JavaFX fields committed getText().trim(); a stray space in a station
// name or a broadcast address is a protocol value, not decoration.
val s = Session()
val state = s.state()
state.logsynch_wintestNetworkStationNameOfKST = " KST "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `enabling the Win-Test listener starts it and disabling stops it`() {
val s = Session()
val state = s.state()
state.logsynch_wintestNetworkListenerEnabled = true
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.restarts, "the running listener must follow the setting")
state.logsynch_wintestNetworkListenerEnabled = false
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.stops)
}
@Test
fun `a committed port change rebinds the listener only while it is enabled`() {
// Deliberately not on every keystroke: the port write goes through at once,
// but rebinding a UDP socket is bound to the commit, as the JavaFX field's
// focus-lost handler did.
val s = Session()
val state = s.state()
/*
* Switched off explicitly: ChatPreferences ships
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
* a fresh configuration already listens.
*/
state.logsynch_wintestNetworkListenerEnabled = false
val restartsAfterDisable = s.restarts
state.logsynch_wintestNetworkPort = 9872
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
state.commitWintestNetworkPort()
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
state.logsynch_wintestNetworkListenerEnabled = true
val restartsAfterEnable = s.restarts
state.logsynch_wintestNetworkPort = 9873
state.commitWintestNetworkPort()
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(restartsAfterEnable + 1, s.restarts)
}
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
s.state()
assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
"the JavaFX window auto-detected the subnet broadcast while the stored "
+ "value was still the catch-all default")
}
@Test
fun `a broadcast address the operator chose is never overwritten`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
s.state()
assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val s = Session(detected = null)
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
s.state()
assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
}
@@ -0,0 +1,65 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class MessageHandlingTabStateTest {
@Test
fun `the state starts from the stored preferences`() {
val prefs = ChatPreferences()
prefs.setMessageHandling_autoAnswerEnabled(true)
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
val state = MessageHandlingTabState(prefs)
assertTrue(state.autoAnswerEnabled)
assertTrue(state.autoAnswerToQRGRequestEnabled)
assertEquals("qrv 144.300", state.autoAnswerText)
}
@Test
fun `one checkbox drives both category flags`() {
// The JavaFX window offered a single checkbox and wrote both preferences from
// it; keeping only the main one would leave the second chat on its old value.
val prefs = ChatPreferences()
val state = MessageHandlingTabState(prefs)
state.autoAnswerEnabled = true
assertTrue(prefs.isMessageHandling_autoAnswerEnabled())
assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat())
state.autoAnswerEnabled = false
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
assertFalse(prefs.isMessageHandling_autoAnswerEnabledSecondCat())
}
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences()
val state = MessageHandlingTabState(prefs)
state.autoAnswerText = "qrv 432.200"
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextMainCat())
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
}
@Test
fun `the QRG reply flag writes through on its own`() {
val prefs = ChatPreferences()
val state = MessageHandlingTabState(prefs)
state.autoAnswerToQRGRequestEnabled = true
assertTrue(prefs.isMessageHandling_autoAnswerToQRGRequestEnabled())
assertFalse(prefs.isMessageHandling_autoAnswerEnabled(),
"the two options are independent")
}
}
@@ -0,0 +1,272 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
class NotificationTabStateTest {
private class Session {
val prefs = ChatPreferences()
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
var spotSent: ChatMember? = null
var spotRefusal: String? = null
fun state() = NotificationTabState(
prefs,
monitored,
applyDxClusterServerEnabled = { serverEnabledApplied = it },
applyDxClusterServerPort = { portApplied = it },
broadcastTestSpot = { spotSent = it; spotRefusal },
)
}
@Test
fun `the sound settings read from and write to the preferences at once`() {
val s = Session()
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
assertTrue(state.notify_playSimpleSounds)
state.notify_playSimpleSounds = false
state.notify_playCWCallsignsOnRxedPMs = true
state.notify_playVoiceCallsignsOnRxedPMs = true
assertFalse(s.prefs.isNotify_playSimpleSounds())
assertTrue(s.prefs.isNotify_playCWCallsignsOnRxedPMs())
assertTrue(s.prefs.isNotify_playVoiceCallsignsOnRxedPMs())
}
@Test
fun `the band-upgrade hints write through`() {
val s = Session()
val state = s.state()
state.notify_bandUpgradeHintOnLogEnabled = true
state.notify_bandUpgradePriorityBoostEnabled = true
assertTrue(s.prefs.isNotify_bandUpgradeHintOnLogEnabled())
assertTrue(s.prefs.isNotify_bandUpgradePriorityBoostEnabled())
}
@Test
fun `enabling the DX cluster server also tells the running session`() {
val s = Session()
val state = s.state()
state.enableDxClusterServer(true)
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
assertEquals(true, s.serverEnabledApplied,
"the JavaFX checkbox started or stopped the server as well, and the "
+ "settings coverage test cannot see that call")
}
@Test
fun `a valid port is stored and handed to the running session`() {
val s = Session()
val state = s.state()
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
assertEquals(7300, s.portApplied)
}
@Test
fun `a port outside the TCP range is refused and nothing is written`() {
val s = Session()
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
val refusal = state.commitDxClusterServerPort("70000")
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
assertNull(s.portApplied, "an refused port must not restart the server")
}
@Test
fun `a port that is not a number is refused`() {
val s = Session()
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
assertNotNull(state.commitDxClusterServerPort("acht"))
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
}
@Test
fun `an unchanged port is stored but does not restart the server`() {
val s = Session()
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
assertNull(state.commitDxClusterServerPort("8000"))
assertNull(s.portApplied,
"the JavaFX field only restarted the server when the port actually changed")
}
@Test
fun `a spotter callsign is upper-cased and stored`() {
val s = Session()
val state = s.state()
assertNull(state.commitSpotterCallSign(" do5amf "))
assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get())
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
}
@Test
fun `a spotter callsign that is no callsign is refused`() {
val s = Session()
s.prefs.setNotify_DXCSrv_SpottersCallSign("DO5AMF")
val state = s.state()
assertNotNull(state.commitSpotterCallSign("not a call"))
assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get())
}
@Test
fun `the fallback band round trips through the stored prefix`() {
val s = Session()
val state = s.state()
state.notify_optionalFrequencyPrefix = Band.B_432
assertEquals("432", s.prefs.getNotify_optionalFrequencyPrefix().get())
assertEquals(Band.B_432, state.notify_optionalFrequencyPrefix)
}
@Test
fun `an unsupported stored prefix falls back to 144 and is written back`() {
val s = Session()
s.prefs.setNotify_optionalFrequencyPrefix("4711")
val state = s.state()
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
}
@Test
fun `the monitoring list starts from the roster`() {
val s = Session()
s.monitored.setAll(listOf("DN9APW", "DO5AMF"))
assertEquals(listOf("DN9APW", "DO5AMF"), s.state().monitoredCallSigns.texts)
}
@Test
fun `a monitored callsign is appended as its base call`() {
val s = Session()
val state = s.state()
assertNull(state.addMonitoredCallSign(" dn9apw-70 "))
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
"monitoring works on the base call, so every SSID maps to the same entry")
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
"the JavaFX button added to the roster at once")
}
@Test
fun `a monitored callsign that is no callsign is refused`() {
val s = Session()
val state = s.state()
assertNotNull(state.addMonitoredCallSign("hello"))
assertTrue(state.monitoredCallSigns.texts.isEmpty())
}
@Test
fun `the same base call is not monitored twice`() {
val s = Session()
val state = s.state()
state.addMonitoredCallSign("DN9APW")
assertNotNull(state.addMonitoredCallSign("dn9apw-2"))
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts)
}
@Test
fun `editing a monitored entry normalizes it and reaches the roster`() {
val s = Session()
s.monitored.setAll(listOf("DN9APW"))
val state = s.state()
state.monitoredCallSigns.commitAt(0, "do5amf-2")
state.commitMonitoredCallSigns()
assertEquals(listOf("DO5AMF"), s.monitored.snapshot())
}
@Test
fun `an invalid edit of a monitored entry is refused and the roster is untouched`() {
val s = Session()
s.monitored.setAll(listOf("DN9APW"))
val state = s.state()
val outcome = state.monitoredCallSigns.commitAt(0, "still not a call")
state.commitMonitoredCallSigns()
assertTrue(outcome is kst4contest.view.compose.CommitOutcome.Refused)
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
}
@Test
fun `clearing a monitored entry removes it from the roster`() {
val s = Session()
s.monitored.setAll(listOf("DN9APW", "DO5AMF"))
val state = s.state()
val rosterBefore = s.monitored
state.monitoredCallSigns.commitAt(0, "")
state.commitMonitoredCallSigns()
assertEquals(listOf("DO5AMF"), s.monitored.snapshot())
assertSame(rosterBefore, s.monitored,
"the roster is filled, never replaced: a swap would orphan its listeners")
}
@Test
fun `the test spot carries the values the JavaFX button used`() {
val s = Session()
val state = s.state()
assertNull(state.sendTestSpot())
val spot = s.spotSent
assertNotNull(spot)
assertEquals("DO5AMF", spot!!.callSign)
assertEquals("300", spot.frequency.get())
assertEquals("DXC test: You donated \$100!", spot.qra,
"deliberate test data, kept verbatim")
}
@Test
fun `a refused test spot reports why`() {
val s = Session()
s.spotRefusal = "No DX Cluster client is connected to KST4Contest."
val state = s.state()
assertEquals("No DX Cluster client is connected to KST4Contest.", state.sendTestSpot())
}
}
@@ -0,0 +1,168 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.OperatorProfileStore
import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* Rules of the profiles tab.
*
* Creating and deleting profiles is deliberately not exercised here: both reach into
* the real application directory under the operator's home, and a test that writes
* there could destroy a profile in use. Every rule below refuses before the service
* is called, which is exactly the part worth pinning down.
*/
class ProfilesTabStateTest {
@TempDir
lateinit var registryDirectory: Path
private fun service() = OperatorProfileManagementService(
OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString())
)
private fun state(
active: OperatorProfileSelection? = null,
onActivation: (OperatorProfile) -> Unit = { },
) = ProfilesTabState(service(), { active }, onActivation)
@Test
fun `an absent registry still offers the default profile`() {
val state = state()
assertEquals(1, state.profiles.size)
assertTrue(state.profiles.first().isRootProfile)
assertEquals(state.profiles.first(), state.selected,
"the JavaFX table selected the first row when nothing was selected yet")
}
@Test
fun `the default profile cannot be deleted`() {
val state = state()
val notice = state.deleteSelected()
assertTrue(notice is ProfileNotice.Error)
assertEquals(
"The default profile uses the files of the installation itself and cannot be deleted.",
notice!!.message,
)
assertEquals(1, state.profiles.size, "nothing was removed")
}
@Test
fun `the default profile always uses the common worked stations`() {
val state = state()
val notice = state.changeWorkedDataModeOfSelected(false)
assertTrue(notice is ProfileNotice.Error,
"that database is the installation's own, so the choice does not exist")
assertFalse(state.canChangeWorkedDataMode(state.profiles.first()))
}
@Test
fun `the profile in use cannot be deleted`() {
val plainService = service()
val profile = plainService.listProfiles().first()
val state = ProfilesTabState(
plainService,
{ OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) },
{ },
)
val notice = state.deleteSelected()
assertTrue(notice is ProfileNotice.Error)
}
@Test
fun `switching to the profile already in use does nothing`() {
val plainService = service()
val profile = plainService.listProfiles().first()
var activations = 0
val state = ProfilesTabState(
plainService,
{ OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) },
{ activations++ },
)
val notice = state.activateSelected()
assertTrue(notice is ProfileNotice.Info)
assertEquals("This profile is already active.", notice!!.message)
assertEquals(0, activations,
"a switch restarts into the other profile; doing that for no reason would "
+ "drop the operator out of a running contest")
}
@Test
fun `an unchanged worked-stations mode is not written`() {
/*
* The registry is seeded in the temporary directory so a non-root profile
* exists without creating one, which would write into the operator's home.
*/
val store = OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString())
val shared = OperatorProfile("p1", "DN9APW", false, true)
store.saveProfiles(listOf(shared), shared.profileId)
val state = ProfilesTabState(OperatorProfileManagementService(store), { null }, { })
assertEquals(shared, state.selected)
assertNull(state.changeWorkedDataModeOfSelected(true),
"the mode already is the requested one, so nothing is written and nothing is said")
}
@Test
fun `selecting a profile that is not in the list clears the selection`() {
val state = state()
state.select(OperatorProfile("stale", "gone", false, false))
assertNull(state.selected,
"a profile deleted elsewhere must not stay selectable, or an action would "
+ "operate on a profile that no longer exists")
}
@Test
fun `a blank name creates nothing`() {
val state = state()
assertNull(state.createProfile(" ", false))
assertEquals(1, state.profiles.size)
}
@Test
fun `the duplicate name suggestion is the one the JavaFX dialog pre-filled`() {
val state = state()
assertEquals("Copy of " + state.profiles.first().displayName, state.duplicateNameSuggestion())
}
@Test
fun `the worked-stations column names the shared database for the root profile`() {
val root = OperatorProfile("root", "Default", true, false)
assertEquals("common station database", ProfilesTabState.describeWorkedDataMode(root),
"the root profile reads the installation's database whatever its flag says")
assertEquals(
"own database",
ProfilesTabState.describeWorkedDataMode(OperatorProfile("p", "DN9APW", false, false)),
)
}
@Test
fun `a profile never used shows no date`() {
val unused = OperatorProfile("p", "DN9APW", false, false)
assertEquals("", ProfilesTabState.describeLastUsed(unused))
}
}
@@ -0,0 +1,117 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
class ShortcutsTabStateTest {
private class Session {
val prefs = ChatPreferences()
var shortcutRefreshes = 0
var snippetRefreshes = 0
fun state() = ShortcutsTabState(
prefs,
refreshShortcutButtons = { shortcutRefreshes++ },
refreshTextSnippetContextMenus = { snippetRefreshes++ },
)
}
@Test
fun `both lists start from the stored rosters`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx"))
s.prefs.getLst_txtSnipList().setAll(listOf("qrv 144", "73"))
val state = s.state()
assertEquals(listOf("cq", "tnx"), state.shortcuts.texts)
assertEquals(listOf("qrv 144", "73"), state.snippets.texts)
}
@Test
fun `a new shortcut lands at the top and reaches the roster at once`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
val rosterBefore = s.prefs.getLst_txtShortCutBtnList()
state.shortcuts.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE)
state.commitShortcuts()
assertEquals(
listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"),
s.prefs.getLst_txtShortCutBtnList().snapshot(),
)
assertSame(rosterBefore, s.prefs.getLst_txtShortCutBtnList(),
"the roster is filled, never replaced: a swap would orphan its listeners")
}
@Test
fun `committing the shortcuts rebuilds the shortcut buttons`() {
val s = Session()
val state = s.state()
state.shortcuts.addAtTop("cq")
state.commitShortcuts()
assertTrue(s.shortcutRefreshes > 0,
"the buttons above the message field are built from this list")
assertEquals(0, s.snippetRefreshes, "the snippet menus are a different list")
}
@Test
fun `a cleared shortcut disappears from the roster`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx"))
val state = s.state()
state.shortcuts.commitAt(0, " ")
state.commitShortcuts()
assertEquals(listOf("tnx"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
"clearing the text was how the operator removed an entry")
}
@Test
fun `moving a shortcut reorders the roster`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("a", "b"))
val state = s.state()
state.shortcuts.select(0)
state.shortcuts.moveSelected(1)
state.commitShortcuts()
assertEquals(listOf("b", "a"), s.prefs.getLst_txtShortCutBtnList().snapshot())
}
@Test
fun `a new snippet lands at the top and reaches its own roster`() {
val s = Session()
s.prefs.getLst_txtSnipList().setAll(listOf("alt"))
val state = s.state()
state.snippets.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE)
state.commitSnippets()
assertEquals(
listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"),
s.prefs.getLst_txtSnipList().snapshot(),
)
assertTrue(s.snippetRefreshes > 0,
"the Ctrl+1..Ctrl+0 context menus are built from this list")
assertEquals(0, s.shortcutRefreshes)
}
@Test
fun `the placeholder text of a new entry is the one the JavaFX button inserted`() {
assertEquals(
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key",
ShortcutsTabState.NEW_ENTRY_TEMPLATE,
)
}
}
@@ -0,0 +1,98 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
class StationTabStateTest {
private class Session {
val prefs = ChatPreferences()
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val state = StationTabState(
prefs,
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
)
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
s.prefs.setStn_loginCallSign("DN9APW")
s.prefs.setStn_on4kstServersPort(23001)
assertEquals("DN9APW", s.state.stn_loginCallSign)
assertEquals(23001, s.state.stn_on4kstServersPort)
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
val s = Session()
s.state.stn_loginCallSign = "DO5AMF"
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
}
@Test
fun `a band toggle round trips`() {
val s = Session()
s.state.stn_bandActive144 = true
s.state.stn_bandActive432 = false
assertTrue(s.prefs.isStn_bandActive144())
assertEquals(false, s.prefs.isStn_bandActive432())
assertTrue(s.state.stn_bandActive144)
}
@Test
fun `selecting the main category writes both places it is held`() {
val s = Session()
val category = ChatCategory(2)
s.state.selectMainCategory(category)
assertEquals(2, s.prefs.getLoginChatCategoryMain().categoryNumber,
"the stored setting must follow the selection")
assertEquals(2, s.mainApplied?.categoryNumber,
"so must the running session; the JavaFX window wrote both, and the "
+ "settings coverage test cannot see the ChatController write")
}
@Test
fun `no second category is expressed as null`() {
val s = Session()
s.state.selectSecondCategory(null)
assertNull(s.prefs.getLoginChatCategorySecond(),
"the JavaFX window wrote null for 'no second chat'")
assertTrue(s.secondWasApplied, "the running session must learn about it too")
}
@Test
fun `the second chat switch lives with the login settings`() {
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
// next to the category pickers, and it decides what the Connect button does.
val prefs = ChatPreferences()
val state = StationTabState(prefs, { }, { })
state.loginToSecondChatEnabled = true
assertTrue(prefs.isLoginToSecondChatEnabled())
assertEquals(true, state.loginToSecondChatEnabled)
}
}
@@ -0,0 +1,124 @@
package kst4contest.view.compose.tabs
import kst4contest.view.compose.JavaFxStylesheet
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import java.nio.file.Files
/**
* Writing the shipped stylesheet into the profile, so it can be edited by hand.
*
* The one rule with teeth here is that an existing file is never overwritten. The file this
* writes to is the operator's own hand-edited work, and a button that silently replaced it
* would destroy an afternoon's work on one misclick.
*/
class StylesheetExportTest {
private fun tempDirectory(): File =
Files.createTempDirectory("kst4contest-export").toFile()
@Test
fun aMissingFileIsWrittenFromTheShippedSheet() {
val directory = tempDirectory()
val target = File(directory, "KST4ContestDefaultDay.css")
val outcome = exportShippedStylesheet(target, darkMode = false)
assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome")
assertTrue(target.isFile, "nothing was written")
assertEquals(
JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultDay.css")!!.readText(),
target.readText(),
"the written file is not the shipped sheet",
)
target.delete()
directory.delete()
}
@Test
fun theEveningDesignWritesTheEveningSheet() {
val directory = tempDirectory()
val target = File(directory, "KST4ContestDefaultEvening.css")
exportShippedStylesheet(target, darkMode = true)
assertEquals(
JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText(),
target.readText(),
)
target.delete()
directory.delete()
}
@Test
fun anExistingFileIsLeftAloneAndReported() {
/*
* The operator's hand-edited sheet. Overwriting it on a misclick is the one failure
* this button could cause that cannot be undone from inside the application.
*/
val directory = tempDirectory()
val target = File(directory, "KST4ContestDefaultDay.css")
target.writeText(".root { -fx-base: #112233; } /* mine */")
val outcome = exportShippedStylesheet(target, darkMode = false)
assertTrue(outcome is StylesheetExportOutcome.AlreadyThere, "outcome was $outcome")
assertEquals(".root { -fx-base: #112233; } /* mine */", target.readText())
target.delete()
directory.delete()
}
@Test
fun aMissingProfileDirectoryIsCreated() {
// Only the root profile's directory exists already; every other profile's may not.
val directory = tempDirectory()
val target = File(directory, "profiles/OP2/KST4ContestDefaultDay.css")
val outcome = exportShippedStylesheet(target, darkMode = false)
assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome")
assertTrue(target.isFile, "the directory was not created")
target.delete()
target.parentFile.delete()
target.parentFile.parentFile.delete()
directory.delete()
}
@Test
fun anUnwritablePlaceIsReportedRatherThanThrown() {
val target = File("/proc/kst4contest-cannot-write-here/day.css")
val outcome = exportShippedStylesheet(target, darkMode = false)
assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome")
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason)
}
@Test
fun whatIsWrittenCanBeReadBackAsRoles() {
/*
* The round trip the whole hand-editing route depends on: the file this writes has to
* be a file the layering can read. The evening sheet is the one that states colours.
*/
val directory = tempDirectory()
val target = File(directory, "KST4ContestDefaultEvening.css")
exportShippedStylesheet(target, darkMode = true)
// All six: five stated outright, the window surface derived from the stated base.
assertEquals(
kst4contest.model.PaletteRole.values().toSet(),
JavaFxStylesheet.declaredRolesOfFile(target).keys,
)
target.delete()
directory.delete()
}
}
@@ -0,0 +1,120 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class TrxSynchTabStateTest {
private class Session {
val prefs = ChatPreferences()
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
s.prefs.setTrxSynch_ucxLogUDPListenerEnabled(true)
s.prefs.setLogsynch_wintestQrgSyncEnabled(true)
s.prefs.setLogsynch_wintestUsePassQrg(true)
s.prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1("STN1")
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled)
assertTrue(s.state.logsynch_wintestQrgSyncEnabled)
assertTrue(s.state.logsynch_wintestUsePassQrg)
assertEquals("STN1", s.state.logsynch_wintestNetworkStationNameOfWintestClient1)
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = true
s.state.logsynch_wintestUsePassQrg = true
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
}
@Test
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
// Empty means "accept all" — the JavaFX label said so explicitly.
val s = Session()
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
}
@Test
fun `the own-QRG follower follows whether any source is enabled`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
assertEquals(listOf(true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = true
assertEquals(listOf(true, true), s.followerCalls)
// One source off is not all sources off: the follower must stay attached.
s.state.trxSynch_ucxLogUDPListenerEnabled = false
assertEquals(listOf(true, true, true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = false
assertEquals(listOf(true, true, true, false), s.followerCalls)
}
@Test
fun `an enabled source alone is never presented as a frequency`() {
// PROJECT_CONTEXT: automatic QRG updates need an enabled source AND valid
// incoming RadioInfo or Win-Test STATUS data. The tab must not let an
// operator read "source on" as "my QRG is being kept current".
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
assertTrue(s.state.anyQrgSourceEnabled)
/*
* Read from the translation rather than from a constant beside it: one sentence, one
* place. What this asserts is a content requirement on the English text, which is
* where that text now lives.
*/
val hint = kst4contest.view.i18n.Translations.BY_LANGUAGE
.getValue(kst4contest.view.i18n.Translations.BASE_LANGUAGE)
.getValue(TrxSynchTabState.QRG_SOURCE_HINT_KEY)
assertTrue(hint.contains("valid", ignoreCase = true) && hint.contains("data", ignoreCase = true),
"the hint must say that incoming data is required as well, not just a source: <$hint>")
assertTrue(hint.contains("RadioInfo") && hint.contains("STATUS"),
"naming both sources tells the operator what data he is waiting for: <$hint>")
}
@Test
fun `no enabled source means no automatic QRG at all`() {
val s = Session()
/*
* Both sources have to be switched off explicitly: ChatPreferences ships
* logsynch_wintestQrgSyncEnabled = true (ChatPreferences.java:258), so a fresh
* configuration already has one source enabled.
*/
s.state.logsynch_wintestQrgSyncEnabled = false
s.state.trxSynch_ucxLogUDPListenerEnabled = false
assertFalse(s.state.anyQrgSourceEnabled)
}
@Test
fun `a fresh configuration already has the Win-Test source enabled`() {
// Pins the shipped default rather than assuming it, which is what made the
// test above wrong on the first attempt.
assertTrue(Session().state.logsynch_wintestQrgSyncEnabled)
assertFalse(Session().state.trxSynch_ucxLogUDPListenerEnabled)
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.compose.tabs
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class WorkedDatabaseTabStateTest {
private class Recorder(private val resetResult: Int) {
var resetCalls = 0
var guiRefreshed = false
var refreshCalls = 0
val state = WorkedDatabaseTabState(
resetInDatabase = { resetCalls++; resetResult },
refreshGuiLists = { guiRefreshed = true },
refreshView = { refreshCalls++ },
)
}
@Test
fun `a reset asks before it touches anything`() {
val r = Recorder(resetResult = 5)
r.state.requestReset()
assertTrue(r.state.confirmationPending,
"the JavaFX button showed a confirmation dialog first")
assertEquals(0, r.resetCalls, "nothing may be reset before the operator confirms")
}
@Test
fun `cancelling a reset changes nothing`() {
val r = Recorder(resetResult = 5)
r.state.requestReset()
r.state.cancelReset()
assertFalse(r.state.confirmationPending)
assertEquals(0, r.resetCalls)
assertFalse(r.guiRefreshed)
}
@Test
fun `confirming resets the database and then the lists, in that order`() {
val r = Recorder(resetResult = 12)
r.state.requestReset()
r.state.confirmReset()
assertEquals(1, r.resetCalls)
assertTrue(r.guiRefreshed, "the runtime lists must follow the database")
assertEquals(12, r.state.lastAffectedLines)
assertFalse(r.state.confirmationPending)
}
@Test
fun `a failed reset reports an error and leaves the lists alone`() {
// resetWorkedDataInDB returns a negative number when it could not confirm a
// reset. The JavaFX handler stopped there and did not touch the GUI lists.
val r = Recorder(resetResult = -1)
r.state.requestReset()
r.state.confirmReset()
assertTrue(r.state.resetFailed)
assertFalse(r.guiRefreshed,
"clearing the runtime lists after a failed database reset would show the "
+ "operator a state the database does not have")
}
@Test
fun `refreshing is a plain action`() {
val r = Recorder(resetResult = 0)
r.state.refresh()
assertEquals(1, r.refreshCalls)
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.controller.On4KstConnectionState
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the connection indicator in the Compose status bar.
*
* The JavaFX method this came out of did two things in one breath: it wrote the Compose
* state and then touched a JavaFX tooltip. Deleting the construction that creates the
* tooltip would have left live callers running into the second half.
*/
class ConnectionStateFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
@Test
fun `the state and its detail reach the status bar`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.ONLINE, "logged in as DN9APW")
assertEquals(On4KstConnectionState.ONLINE, target.connectionState)
assertEquals("logged in as DN9APW", target.connectionDetail)
}
/**
* Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state
* and the state's own name for a blank detail; an operator reading "null" in a
* status bar learns nothing.
*/
@Test
fun `a missing state reads as disconnected`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher()).push(null, null)
assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState)
assertEquals("DISCONNECTED", target.connectionDetail)
}
@Test
fun `a blank detail reads as the state itself`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.CONNECTING, " ")
assertEquals("CONNECTING", target.connectionDetail)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
ConnectionStateFeed(MainWindowSurroundings(), dispatcher)
.push(On4KstConnectionState.ONLINE, "x")
assertEquals(1, dispatcher.deliveries)
}
}
@@ -0,0 +1,192 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Nothing outside the feeds writes the Compose state the feeds own.
*
* This exists because of how two bypasses survived a review of my own making. The claim
* "one path writes it now" was checked with `grep getSelectedStationMessages().replaceRows`
* and `grep getSurroundings().setConnection` — greps shaped like the code that had just
* been written, which therefore could not match a call through a local variable. Both
* missed a live second writer.
*
* A source-level assertion is the right instrument for that claim, because the claim is
* about the source and not about a run. It also catches the next bypass, which a
* hand-run grep does not.
*
* The first version of this test repeated the original mistake in a new spelling: it
* matched `selectedStationMessages` case-sensitively, so the accessor spelling with the
* capital S slipped through, and it required `getTimeline()` and the mutator on one
* line, so a two-line write through a local was invisible. Both are closed below. What
* remains uncovered is named in `the residual limit` at the foot of this file — stating
* it is the point, because claiming completeness is what failed the first two times.
*/
class FeedIsTheOnlyWriterTest {
/**
* Where the feeds themselves and the state declarations live.
*
* These files contain the mutator names legitimately: the feed is the one caller,
* and the state classes declare the methods. Everything else is a bypass.
*/
private val ownedByTheFeeds = listOf(
"/view/feed/",
"/view/compose/TimelineState.kt",
"/view/compose/DataTableState.kt",
"/view/compose/MainWindowState.kt",
)
/** Resolves whether the test runs from the module directory or the repository root. */
private fun sourceRoot(): File =
listOf(File("src/main"), File("app-desktop/src/main"))
.firstOrNull { it.isDirectory }
?: error("cannot find app-desktop/src/main from ${File(".").absolutePath}")
/**
* Every live line of the module's main sources, as path, number and text.
*
* The whole tree, not one file: the three states are reachable from the Kotlin in
* this source set too, and a bypass in `MainWindow.kt` would be unscanned otherwise.
*/
private fun sources(): List<File> {
val files = sourceRoot().walkTopDown()
.filter { it.isFile && (it.extension == "java" || it.extension == "kt") }
.filterNot { file -> ownedByTheFeeds.any { file.path.replace('\\', '/').contains(it) } }
.toList()
assertTrue(files.size > 20) { "only found ${files.size} sources; the scan is not reaching the tree" }
return files
}
private fun liveLines(): List<Triple<String, Int, String>> {
return sources().flatMap { file ->
file.readLines().mapIndexed { index, line -> Triple(file.name, index + 1, line) }
}.filterNot { (_, _, line) ->
line.trim().startsWith("//") || line.trim().startsWith("*")
}
}
private fun report(feed: String, offenders: List<Triple<String, Int, String>>) {
assertTrue(offenders.isEmpty()) {
"these write the state directly instead of going through $feed:\n" +
offenders.joinToString("\n") { (file, number, line) -> " $file:$number ${line.trim()}" }
}
}
/**
* `replaceRows` on the selected-station message table.
*
* The receiver has to be part of the match, because `replaceRows` belongs to seven
* other tables as well — stations, directed, public, cluster and the monitor's two
* fill themselves the same way and are none of this feed's business. Matched on the
* lowercased line so that both the local `selectedStationMessages` and the accessor
* `getSelectedStationMessages()` are caught; the first version matched only the
* local, which is the spelling the bug happened to have.
*/
@Test
fun `only the feed fills the selected-station message table`() {
report("SelectedStationMessagesFeed", liveLines().filter { (_, _, line) ->
val lowered = line.lowercase()
"selectedstationmessages" in lowered && "replacerows" in lowered
})
}
/**
* Nobody outside the feed takes a writable handle on that table.
*
* This is what closes the two-line write — `var t = …getSelectedStationMessages();`
* on one line and `t.replaceRows(rows);` on the next, the exact shape that defeated
* the original verification. To write it at all you must first call the accessor,
* and the feed's own construction is the only place that may.
*
* Scanned per statement rather than per line, because that construction spans two
* lines: a line-wise rule reports the feed building itself as its own bypass.
*/
@Test
fun `only the feed takes a handle on that table`() {
report("SelectedStationMessagesFeed", sources().flatMap { file ->
val code = file.readLines()
.map { if (it.trim().startsWith("//") || it.trim().startsWith("*")) "" else it }
var line = 1
code.joinToString("\n").split(";").mapNotNull { statement ->
val start = line
line += statement.count { it == '\n' }
if ("getSelectedStationMessages()" !in statement ||
"SelectedStationMessagesFeed(" in statement
) {
return@mapNotNull null
}
// Report the line the accessor is actually on, not where its statement
// began: a statement starts after the previous semicolon, which can be
// several lines of unrelated code earlier.
val within = statement.lines().indexOfFirst { "getSelectedStationMessages()" in it }
Triple(file.name, start + within, statement.lines()[within].trim())
}
})
}
/**
* The connection state and its detail belong together.
*
* A writer that sets the state without the detail leaves the badge beside
* "No ON4KST connection" until something else corrects it, which is the stale-pair
* case ConnectionStateFeed exists to end. No receiver is needed in the match: both
* names belong to this state alone, so a two-line write is caught as well.
*
* `.connectionState =` is matched too, because `MainWindowSurroundings` is Kotlin:
* Java call sites spell it `setConnectionState(`, but a Kotlin one would assign the
* property and the setter spelling would never appear. `MainWindowState.kt` itself
* is out of the scan — it declares the property and propagates it to the menu.
*
* The leading dot is required, and is not decoration: without it the rule reports
* `connectionState = state.surroundings.connectionState` in `MainWindow.kt`, which
* is a named argument handing the value to a composable — a read, not a write.
*/
@Test
fun `only the feed fills the connection indicator`() {
report("ConnectionStateFeed", liveLines().filter { (_, _, line) ->
"setConnectionState(" in line || "setConnectionDetail(" in line ||
Regex("""\.(connectionState|connectionDetail)\s*=[^=]""").containsMatchIn(line)
})
}
/**
* The timeline's four writes are the feed's business for the same reason.
*
* Matched on the mutator names alone — they belong to `TimelineState` and to nothing
* else — so a write through a local is caught too. `setBeamWidth(` is spelled with
* the bracket on purpose: `TimelineView.setBeamWidthDeg` is the JavaFX view's own
* method at `Kst4ContestApplication.java:6942` and is not this state.
*/
@Test
fun `only the feed fills the timeline`() {
report("TimelineFeed", liveLines().filter { (_, _, line) ->
"replaceSkeds(" in line || "replaceCandidates(" in line ||
"setAntennaAzimuth" in line || "setBeamWidth(" in line
})
}
/**
* The residual limit, stated rather than papered over.
*
* A Kotlin bypass could reach the message table as `state.selectedStationMessages`
* without the accessor, and split over two lines it would pass every rule above.
* Nothing does that today — `MainWindow.kt` only hands the table to the composable
* that renders it — and closing it would mean forbidding that read as well. If a
* Kotlin writer ever appears, this is the test that has to grow, not the place to
* conclude that none can exist.
*/
@Test
fun `the residual limit is the Kotlin property path`() {
val kotlinReads = liveLines().filter { (file, _, line) ->
file.endsWith(".kt") && "selectedStationMessages" in line
}
kotlinReads.forEach { (file, number, line) ->
assertTrue("replaceRows" !in line) { "$file:$number writes the table from Kotlin: ${line.trim()}" }
}
}
}
@@ -0,0 +1,81 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* The feeds are built where the Compose window's state is built, and nowhere else.
*
* This is the executable half of a warning that part 3 would otherwise have to read and
* believe. All three feeds are constructed inside the method that also constructs
* `composeMainWindowState`, which today is reached from `start()`. Part 3 removes
* `start()` piece by piece; if it follows the warning's table faithfully and rehomes the
* four timeline triggers, the constructions still go with the rest of `start()` and all
* three feeds stay null — a silently empty timeline, message table and connection badge,
* with no compile error and no failing test to show for it.
*
* Asserting "same method as the Compose state" rather than "not inside `start()`" is
* what makes it structural: part 3 cannot drop the feed construction without dropping
* the window's own state, which it will never do, because that is the window. It also
* survives the line numbers moving, which a range check against `start()` would not.
*
* This says nothing about who writes the states once they exist — that is
* `FeedIsTheOnlyWriterTest`, and the two together are the whole claim.
*/
class FeedLifetimeIsTiedToTheWindowTest {
private val source: File
get() = listOf(
File("src/main/java/kst4contest/view/Kst4ContestApplication.java"),
File("app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"),
).firstOrNull { it.isFile } ?: error("cannot find Kst4ContestApplication.java from ${File(".").absolutePath}")
/** A method declaration at class level; the body ends at the matching outdented brace. */
private val declaration = Regex(
"""^\t(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+""" +
"""[\w<>,\[\]. ]+\s+(\w+)\s*\("""
)
/** The name of the method whose body contains [line], or null at class level. */
private fun methodAt(lines: List<String>, line: Int): String? {
var name: String? = null
for (index in 0 until line) {
val text = lines[index]
declaration.find(text)?.let { name = it.groupValues[1] }
if (text.startsWith("\t}")) name = null
}
return name
}
@Test
fun `every feed is constructed where the Compose window state is`() {
val lines = source.readLines()
val live = lines.withIndex().filterNot { (_, text) ->
text.trim().startsWith("//") || text.trim().startsWith("*")
}
val stateAt = live.filter { "composeMainWindowState = new MainWindowState(" in it.value }
assertEquals(1, stateAt.size) {
"expected exactly one construction of the Compose window state, found ${stateAt.size}"
}
val home = methodAt(lines, stateAt.single().index)
assertTrue(home != null) { "the Compose window state is built outside any method body" }
val feeds = live.filter { Regex("""\b\w*[Ff]eed = new\b""").containsMatchIn(it.value) }
assertEquals(3, feeds.size) {
"expected three feed constructions, found ${feeds.size}:\n" +
feeds.joinToString("\n") { " :${it.index + 1} ${it.value.trim()}" }
}
val strays = feeds.filter { methodAt(lines, it.index) != home }
assertTrue(strays.isEmpty()) {
"these feeds are built outside $home, where composeMainWindowState is built, " +
"so removing start() can leave them null:\n" +
strays.joinToString("\n") { feed ->
" :${feed.index + 1} in ${methodAt(lines, feed.index)} — ${feed.value.trim()}"
}
}
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.model.ChatMessage
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.RowKeys
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the "messages of the selected station" table.
*
* Found the hard way: this feed was buried two hundred lines inside a JavaFX table
* builder, so "who calls that builder" answered a different question than "is it safe
* to delete". A feed with its own name and its own test cannot be lost that way.
*/
class SelectedStationMessagesFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
private fun emptyTable() =
DataTableState<ChatMessage>(emptyList(), RowKeys.byReference(), "test")
@Test
fun `the messages it is given reach the table`() {
val target = emptyTable()
SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage()))
assertEquals(1, target.rows.size)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage()))
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 3: no station selected is the state the window opens in. */
@Test
fun `an empty push clears the table rather than failing`() {
val target = emptyTable()
val feed = SelectedStationMessagesFeed(target, CountingDispatcher())
feed.push(listOf(ChatMessage()))
feed.push(emptyList())
assertEquals(0, target.rows.size)
}
/** The caller hands over a live mirror; the table must not follow it. */
@Test
fun `the table keeps its own copy`() {
val target = emptyTable()
val source = mutableListOf(ChatMessage())
SelectedStationMessagesFeed(target, CountingDispatcher()).push(source)
source.clear()
assertEquals(1, target.rows.size, "the table followed the caller's list")
}
}
@@ -0,0 +1,88 @@
package kst4contest.view.feed
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.TimelineState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* What fills the timeline above the send field.
*
* It has its own class because of how it was found: the feed sat in a method guarded by
* a JavaFX field, inside window construction that is never shown, and deleting that
* construction would have emptied the Compose timeline with a green build and no
* compile error. A feed that can be constructed in a test cannot hide like that.
*/
class TimelineFeedTest {
/** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */
private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = uiThread
}
private fun sked(call: String) =
kst4contest.model.ContestSked(call, 90.0, 60_000L, kst4contest.model.Band.B_144)
@Test
fun `it puts what it is given into the timeline`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(listOf(sked("DL1ABC")), emptyList(), 229.0, 60.0)
assertEquals(1, target.skeds.size)
assertEquals(229.0, target.antennaAzimuth, 1e-9)
assertEquals(60.0, target.beamWidthDeg, 1e-9)
}
/** Review Focus 2: two feeds writing one state is invisible until one goes stale. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 4: the controller's listeners fire on network threads. */
@Test
fun `it hands over rather than writing from the calling thread`() {
val dispatcher = CountingDispatcher(uiThread = false)
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0)
assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" }
}
/** Review Focus 3: empty is the normal state before the first score run. */
@Test
fun `an empty push is not an error`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(0, target.skeds.size)
assertEquals(0, target.candidates.size)
}
/** The caller hands over snapshots of state that keeps changing. */
@Test
fun `the timeline keeps its own copy`() {
val target = TimelineState()
val source = mutableListOf(sked("DL1ABC"))
TimelineFeed(target, CountingDispatcher()).push(source, emptyList(), 0.0, 0.0)
source.clear()
assertEquals(1, target.skeds.size, "the timeline followed the caller's list")
}
}
@@ -0,0 +1,140 @@
package kst4contest.view.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The store, and the two ways out of it.
*
* Built over two lambdas rather than over ChatPreferences, so none of this touches the
* operator's home directory: that constructor copies resources into it.
*/
class LanguageStoreTest {
private var stored: String = SYSTEM_LANGUAGE
private fun store(systemDefault: Locale = Locale.UK) = LanguageStore(
storedLanguage = { stored },
storeLanguage = { stored = it },
systemDefault = systemDefault,
)
@Test
fun theStoredLanguageIsInForceFromTheStart() {
stored = "de"
assertEquals("de", store().language)
}
@Test
fun anEmptySettingFollowsTheSystem() {
stored = SYSTEM_LANGUAGE
assertEquals("de", store(Locale.GERMANY).language)
}
@Test
fun choosingALanguageWritesItThroughAndChangesTheTexts() {
val store = store()
val englishSave = store.strings.settingsSave
store.use("de")
assertEquals("de", store.language)
assertEquals("de", stored)
assertNotEquals(englishSave, store.strings.settingsSave)
assertEquals("Einstellungen speichern", store.strings.settingsSave)
}
@Test
fun theStoredChoiceIsReadableAndIsNotTheResolvedCode() {
/*
* The picker shows what was chosen, not what it resolved to: an operator who picked
* "system language" must keep seeing that and not "EN". One source of truth, so a
* caller never has to decide whether to ask the store or the preference.
*/
stored = SYSTEM_LANGUAGE
val store = store(Locale.UK)
assertEquals(SYSTEM_LANGUAGE, store.stored)
assertEquals("en", store.language)
store.use("de")
assertEquals("de", store.stored)
assertEquals("de", store.language)
}
@Test
fun aLanguageThisBuildDoesNotCarryIsRefusedRatherThanStored() {
/*
* The picker cannot offer one, but a hand-edited preferences file can name one, and
* so can a build that dropped a language. Storing it would persist a dead setting.
*/
val store = store()
store.use("klingon")
assertEquals("en", store.language)
assertEquals(SYSTEM_LANGUAGE, stored, "a language with no texts was stored")
}
@Test
fun choosingTheSystemLanguageIsStoredAsTheSystemLanguage() {
// Not as the code it resolves to: the operator asked to follow the system, and a
// system they later change must still be followed.
stored = "de"
val store = store(Locale.UK)
store.use(SYSTEM_LANGUAGE)
assertEquals("en", store.language)
assertEquals(SYSTEM_LANGUAGE, stored)
}
@Test
fun aKeyOnlyTheBaseHasComesBackInTheBaseLanguage() {
/*
* The fallback the whole contribution workflow depends on, and it cannot be tested
* through German: German is a shipped language and the boundary test holds it
* complete, so no key is ever missing from it. Built from maps instead, and read
* through real generated members so the test goes the same way a window does.
*/
val base = mapOf(
"settings.save" to "Save settings",
"menu.file" to "File",
)
val partial = mapOf("menu.file" to "Datei")
val strings = Strings { key -> partial[key] ?: base[key] ?: key }
assertEquals("Save settings", strings.settingsSave, "the base text did not come through")
assertEquals("Datei", strings.menuFile, "the translation did not win")
}
@Test
fun theJavaBridgeSeesWhatTheStoreSees() {
val store = store()
store.use("de")
assertEquals(store.strings.settingsSave, CurrentStrings.get().settingsSave)
}
@Test
fun aPlaceholderTextIsFormattedInTheChosenLanguagesOwnWordOrder() {
/*
* The reason MessageFormat is here rather than concatenation: in German the argument
* comes first, which no amount of "Connect to " + x can produce.
*/
val store = store()
assertEquals("Connect to 144 MHz", store.strings.connectionConnectTo("144 MHz"))
store.use("de")
assertEquals("Mit 144 MHz verbinden", store.strings.connectionConnectTo("144 MHz"))
}
}
@@ -0,0 +1,54 @@
package kst4contest.view.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* Which language a stored setting and a system default add up to.
*
* A pure function on purpose: the alternative is a test that changes the JVM's default locale,
* which is global state and leaks into whatever runs next.
*/
class LocaleChoiceTest {
@Test
fun anEmptySettingFollowsTheSystem() {
assertEquals("de", languageFor(SYSTEM_LANGUAGE, Locale.GERMANY))
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.UK))
}
@Test
fun aSystemLanguageNobodyTranslatedFallsBackToEnglish() {
// The spec's rule: default from the system language, falling back to English.
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.FRANCE))
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.JAPAN))
}
@Test
fun anExplicitChoiceBeatsTheSystem() {
assertEquals("de", languageFor("de", Locale.UK))
assertEquals("en", languageFor("en", Locale.GERMANY))
}
@Test
fun nonsenseInTheSettingFallsBackToEnglishRatherThanFailing() {
/*
* The stored value can be hand-edited in the preferences XML, or left over from a
* build that carried a language this one does not.
*/
assertEquals("en", languageFor("klingon", Locale.GERMANY))
assertEquals("en", languageFor("de_DE_x", Locale.GERMANY))
assertEquals("en", languageFor(null, Locale.UK))
}
@Test
fun theSettingIsCaseInsensitiveBecauseAHandEditedFileMayShout() {
assertEquals("de", languageFor("DE", Locale.UK))
}
@Test
fun surroundingSpaceInAHandEditedSettingIsIgnored() {
assertEquals("de", languageFor(" de ", Locale.UK))
}
}
@@ -0,0 +1,75 @@
package kst4contest.view.i18n
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* A language change reaches a composed window without anything else happening.
*
* The one mechanism the acceptance criterion hangs on, and the one a state-level test cannot
* see: the store is always right, the question is whether the composition asks it again.
* Etappe 8 shipped exactly this fault for the palette, and found it with a test shaped like
* this one.
*
* Headless: no window is opened, only a composition.
*/
class StringsReachTheCompositionTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun aLanguageChosenAfterTheFirstDrawReachesTheComposition() = runComposeUiTest {
var stored = SYSTEM_LANGUAGE
val store = LanguageStore(
storedLanguage = { stored },
storeLanguage = { stored = it },
systemDefault = Locale.UK,
)
val seen = mutableListOf<String>()
setContent {
/*
* store.strings is read INSIDE the composition, which is what makes the state
* read belong to it. Reading it outside and passing the result in would hand the
* composition a value that never changes again -- the remember(darkMode) fault.
*/
CompositionLocalProvider(LocalStrings provides store.strings) {
seen += LocalStrings.current.settingsSave
}
}
waitForIdle()
val firstDraw = seen.last()
store.use("de")
waitForIdle()
assertEquals("Save settings", firstDraw, "the first draw was not the base language")
assertEquals(
"Einstellungen speichern",
seen.last(),
"the composition still holds the texts from before the change",
)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun withoutAStoreTheBaseLanguageIsStillDrawn() {
/*
* Not caution: the operator profile picker composes before a profile, and therefore
* before a store, exists. It must still have texts.
*/
runComposeUiTest {
var seen: String? = null
setContent { seen = LocalStrings.current.settingsSave }
waitForIdle()
assertEquals("Save settings", seen)
}
}
}
@@ -0,0 +1,122 @@
package kst4contest.view.i18n
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.lang.reflect.Modifier
/**
* Nothing that goes to the server or to another radio amateur has got into the texts.
*
* The third of the three boundary checks, and the one that catches the follow-up work: the
* remaining ~380 strings get pulled into these files over time, and a beacon template or a
* `/CQ` sitting among them would be translated by the next contributor in good faith.
*
* German is held complete here because it is a shipped language. A contributed language is
* deliberately not: a partial file is the normal state of a contribution in progress, and a
* check over every language would turn the arrival of one into a red build.
*/
class TranslationBoundaryTest {
private val everyText: List<String> =
Translations.BY_LANGUAGE.values.flatMap { it.values }
@Test
fun thereAreTextsToCheckAtAll() {
/*
* Without this, every assertion below would pass over an empty list -- the shape of a
* boundary check that guards nothing.
*/
assertTrue(everyText.size >= 16, "only ${everyText.size} texts were found")
}
@Test
fun noTextIsAProtocolFrameOrAChatCommand() {
/*
* Deliberately conservative, and the reason is worth stating: a pattern cannot tell a
* protocol command from an interface label that names one. "Update MYQRG from
* RadioInfo messages" is a setting's description, and its German translation even
* begins with that word, because that is where German puts it. A check for "contains"
* or "starts with" fails both and teaches the next person to delete the test.
*
* So only the unambiguous shapes are checked: a text that IS a token, a wire frame
* (an opcode followed by its pipe), and anything beginning with a slash. The real
* guarantees are the other two boundary tests, which measure behaviour rather than
* text -- the frames built under three locales, and every preference default by
* reflection.
*/
val opcodes = listOf("LOGINC", "SDONE", "ACHAT", "DXQ", "SETNAME", "MYQRG")
for (text in everyText) {
assertTrue(
opcodes.none { text == it || text.startsWith("$it|") },
"'$text' is a protocol token or a wire frame; that text goes to the ON4KST "
+ "server and must not be in a translation file",
)
assertTrue(
!text.trimStart().startsWith("/"),
"'$text' looks like a chat command; those go to the server as a message",
)
}
}
@Test
fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() {
val prefs = ChatPreferences()
val defaults = ChatPreferences::class.java.declaredFields
.filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) }
.mapNotNull { field ->
field.isAccessible = true
(field.get(prefs) as String?)?.takeIf { it.isNotBlank() }
}
.toSet()
assertTrue(defaults.size >= 10, "only ${defaults.size} defaults were found to check against")
for (text in everyText) {
assertTrue(
text !in defaults,
"'$text' is a ChatPreferences default; it goes to other radio amateurs and is "
+ "in every operator's stored XML",
)
}
}
@Test
fun germanIsCompleteBecauseItIsAShippedLanguage() {
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys
val german = Translations.BY_LANGUAGE.getValue("de").keys
assertEquals(
emptySet<String>(),
base - german,
"German is shipped, so a gap in it is a defect and not a contribution in progress",
)
}
@Test
fun onlyTheShippedLanguagesAreHeldToCompleteness() {
/*
* The counterpart of the test above, and a real assertion rather than a note: a
* language this build carries beyond the shipped two must be free to be partial. The
* check is on the rule itself -- exactly two languages are held complete -- so adding
* strings_fr.properties with ten keys cannot turn the build red, and removing German
* from the held set cannot pass unnoticed either.
*/
val held = setOf(Translations.BASE_LANGUAGE, "de")
assertEquals(setOf("en", "de"), held, "the set of shipped languages changed")
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys
val partialLanguages = Translations.BY_LANGUAGE
.filterKeys { it !in held }
.filterValues { (base - it.keys).isNotEmpty() }
assertTrue(
partialLanguages.keys.none { it in held },
"a shipped language turned up among the partial ones: ${partialLanguages.keys}",
)
}
}