mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 13:53:01 +02:00
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:
1 parent
3e8457eede
commit
2a5c89b943
600 files changed
+46019
-23704
No files matched your search
@@ -0,0 +1,110 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import kst4contest.controller.MessageVariableResolver;
|
||||
import kst4contest.model.AirPlane;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
class MessageVariableResolverTest {
|
||||
|
||||
private ChatPreferences chatPreferences;
|
||||
private MessageVariableResolver resolver;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chatPreferences = mock(ChatPreferences.class);
|
||||
|
||||
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.388.03"));
|
||||
when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("432.088.00"));
|
||||
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ");
|
||||
when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF");
|
||||
when(chatPreferences.getActualQTF()).thenReturn(new SimpleValue<>(135.0));
|
||||
|
||||
resolver = new MessageVariableResolver(chatPreferences);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAllGlobalVariablesInEveryMessageContext() {
|
||||
String template = "MYCALL MYQRGSHORT MYQRG SECONDQRG MYLOCATORSHORT MYLOCATOR MYQTF";
|
||||
|
||||
assertEquals(
|
||||
"DO5AMF 144.388 144.388.03 432.088.00 JO51 JO51IJ 135",
|
||||
resolver.resolveGlobalVariables(template)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() {
|
||||
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144"));
|
||||
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5");
|
||||
|
||||
assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesSelectedStationNameAndTheFirstTwoAirPlanes() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
selectedStation.setName("Test Operator");
|
||||
|
||||
AirPlane firstAirPlane = new AirPlane();
|
||||
firstAirPlane.setPotential(100);
|
||||
firstAirPlane.setArrivingDurationMinutes(1);
|
||||
|
||||
AirPlane secondAirPlane = new AirPlane();
|
||||
secondAirPlane.setPotential(75);
|
||||
secondAirPlane.setArrivingDurationMinutes(9);
|
||||
|
||||
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
|
||||
reflectionInfo.setRisingAirplanes(java.util.List.of(firstAirPlane, secondAirPlane));
|
||||
selectedStation.setAirPlaneReflectInfo(reflectionInfo);
|
||||
|
||||
assertEquals(
|
||||
"Hi Test Operator, a very big AP in 1 min; Next big AP in 9 min",
|
||||
resolver.resolveForSelectedStation(
|
||||
"Hi QRZNAME, FIRSTAP; SECONDAP",
|
||||
selectedStation
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCallsignWhenTheSelectedStationHasNoName() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
selectedStation.setName(" ");
|
||||
|
||||
assertEquals(
|
||||
"Hi DL0TEST",
|
||||
resolver.resolveForSelectedStation("Hi QRZNAME", selectedStation)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsStationVariablesVisibleWhenNoStationIsSelected() {
|
||||
assertEquals(
|
||||
"QRZNAME FIRSTAP SECONDAP",
|
||||
resolver.resolveForSelectedStation("QRZNAME FIRSTAP SECONDAP", null)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsUsefulFallbacksWhenNoAirPlaneIsAvailable() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
|
||||
assertEquals(
|
||||
"no ap available ",
|
||||
resolver.resolveForSelectedStation("FIRSTAP SECONDAP", selectedStation)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.view.PrivateMessageRowStyleResolver;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class PrivateMessageRowStyleResolverTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, messageHighlight30-column",
|
||||
"30, messageHighlight30-column",
|
||||
"31, messageHighlight60-column",
|
||||
"60, messageHighlight60-column",
|
||||
"61, messageHighlight90-column",
|
||||
"90, messageHighlight90-column",
|
||||
"91, messageHighlight120-column",
|
||||
"120, messageHighlight120-column",
|
||||
"121, messageHighlight180-column",
|
||||
"180, messageHighlight180-column",
|
||||
"181, messageHighlight300-column",
|
||||
"300, messageHighlight300-column"
|
||||
})
|
||||
void selectsAgeStyleClassAtEveryBoundary(
|
||||
long ageSeconds,
|
||||
String expectedStyleClass
|
||||
) {
|
||||
assertEquals(
|
||||
expectedStyleClass,
|
||||
PrivateMessageRowStyleResolver.resolveStyleClass(
|
||||
false,
|
||||
ageSeconds
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNoAgeStyleClassAfterFiveMinutes() {
|
||||
assertNull(
|
||||
PrivateMessageRowStyleResolver.resolveStyleClass(
|
||||
false,
|
||||
301
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsOwnMessageStyleAfterFiveMinutes() {
|
||||
assertEquals(
|
||||
PrivateMessageRowStyleResolver.OWN_STYLE_CLASS,
|
||||
PrivateMessageRowStyleResolver.resolveStyleClass(
|
||||
true,
|
||||
301
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The contract the user interface depends on everywhere.
|
||||
*
|
||||
* <p>{@code core} hands over every user-interface change through this interface, so a
|
||||
* mistake here does not show up as a compile error but as an occasionally stale window.
|
||||
* The four rules below are what the specification names for this stage.</p>
|
||||
*/
|
||||
class AwtUiDispatcherTest {
|
||||
|
||||
private final AwtUiDispatcher dispatcher = new AwtUiDispatcher();
|
||||
|
||||
@Test
|
||||
void deliversWorkFromAForeignThreadToTheEventThread() throws Exception {
|
||||
AtomicBoolean ranOnEventThread = new AtomicBoolean();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
|
||||
dispatcher.runOnUi(() -> {
|
||||
ranOnEventThread.set(EventQueue.isDispatchThread());
|
||||
done.countDown();
|
||||
});
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered");
|
||||
assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsInlineWhenAlreadyOnTheEventThread() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
dispatcher.runOnUi(runs::incrementAndGet);
|
||||
// Inline means: already done when runOnUi returns, not queued behind us.
|
||||
assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once");
|
||||
});
|
||||
|
||||
assertEquals(1, runs.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheEventThreadAndOnlyThat() throws Exception {
|
||||
assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread");
|
||||
|
||||
AtomicBoolean onEventThread = new AtomicBoolean();
|
||||
EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread()));
|
||||
|
||||
assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception {
|
||||
dispatcher.runOnUi(() -> {
|
||||
throw new IllegalStateException("boom");
|
||||
});
|
||||
|
||||
CountDownLatch stillAlive = new CountDownLatch(1);
|
||||
dispatcher.runOnUi(stillAlive::countDown);
|
||||
|
||||
assertTrue(stillAlive.await(5, TimeUnit.SECONDS),
|
||||
"the event thread must survive a failing UI task");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception {
|
||||
AtomicBoolean reachedTheLineAfter = new AtomicBoolean();
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
dispatcher.runOnUi(() -> {
|
||||
throw new IllegalStateException("boom");
|
||||
});
|
||||
reachedTheLineAfter.set(true);
|
||||
});
|
||||
|
||||
assertTrue(reachedTheLineAfter.get(),
|
||||
"an inline failure must not propagate into the caller");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The coalescer behind the station list.
|
||||
*
|
||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
|
||||
* wrong are invisible in a build: too eager and the table redraws on every message under
|
||||
* contest load, never and the table stops updating altogether.</p>
|
||||
*/
|
||||
class CoalescingTriggerTest {
|
||||
|
||||
@Test
|
||||
void aBurstOfTriggersProducesOneRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
|
||||
runs.incrementAndGet();
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
// What a busy chat looks like: many updates, one intended redraw.
|
||||
for (int i = 0; i < 25; i++) {
|
||||
trigger.trigger();
|
||||
}
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
|
||||
Thread.sleep(300);
|
||||
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theActionRunsOnTheEventThread() throws Exception {
|
||||
AtomicBoolean onEventThread = new AtomicBoolean();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
|
||||
onEventThread.set(EventQueue.isDispatchThread());
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
trigger.trigger();
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterTriggerPostponesTheRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
|
||||
|
||||
trigger.trigger();
|
||||
Thread.sleep(150);
|
||||
trigger.trigger();
|
||||
Thread.sleep(150);
|
||||
|
||||
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
|
||||
assertEquals(0, runs.get(),
|
||||
"a new trigger must restart the delay, not let the old one fire");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
|
||||
|
||||
trigger.trigger();
|
||||
trigger.cancel();
|
||||
Thread.sleep(400);
|
||||
|
||||
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
|
||||
}
|
||||
|
||||
@Test
|
||||
void triggeringAgainAfterACancelStillWorks() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
|
||||
runs.incrementAndGet();
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
trigger.trigger();
|
||||
trigger.cancel();
|
||||
trigger.trigger();
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS),
|
||||
"a cancel must not disable the trigger for good");
|
||||
assertEquals(1, runs.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class CommandLineOptionsTest {
|
||||
|
||||
@Test
|
||||
void profileArgumentIsAcceptedInBothSpellings() {
|
||||
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName());
|
||||
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName());
|
||||
assertEquals("DM5M Contest",
|
||||
CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() {
|
||||
assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(null).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedArgumentsAreIgnoredInsteadOfFailing() {
|
||||
assertEquals("OP2",
|
||||
CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi"))
|
||||
.getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rememberedOptionsDefaultToEmptyInsteadOfNull() {
|
||||
CommandLineOptions.remember(null);
|
||||
assertNull(CommandLineOptions.remembered().getRequestedProfileName());
|
||||
|
||||
CommandLineOptions.remember(new CommandLineOptions("OP2"));
|
||||
assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName());
|
||||
|
||||
CommandLineOptions.remember(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The switch that opens the Compose main window beside the JavaFX one.
|
||||
*
|
||||
* <p>Off unless asked for. The two windows are meant to run side by side while the operator
|
||||
* compares them against a live session, and until that comparison is done nothing about an
|
||||
* ordinary start may change.</p>
|
||||
*/
|
||||
class ComposeMainWindowOptionTest {
|
||||
|
||||
@Test
|
||||
void anOrdinaryStartDoesNotOpenTheComposeWindow() {
|
||||
assertFalse(CommandLineOptions.parse(List.of()).isComposeMainWindowRequested());
|
||||
assertFalse(CommandLineOptions.parse(null).isComposeMainWindowRequested());
|
||||
assertFalse(
|
||||
CommandLineOptions.parse(List.of("--profile", "contest"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSwitchOpensIt() {
|
||||
assertTrue(
|
||||
CommandLineOptions.parse(List.of("--compose-main-window"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
|
||||
/** Alongside a profile, because the comparison is run inside one. */
|
||||
@Test
|
||||
void theSwitchCombinesWithAProfile() {
|
||||
CommandLineOptions options =
|
||||
CommandLineOptions.parse(List.of("--profile", "contest", "--compose-main-window"));
|
||||
|
||||
assertTrue(options.isComposeMainWindowRequested());
|
||||
assertTrue("contest".equals(options.getRequestedProfileName()));
|
||||
}
|
||||
|
||||
/** A near miss must not switch it on by accident. */
|
||||
@Test
|
||||
void anUnrelatedArgumentIsNotTheSwitch() {
|
||||
assertFalse(
|
||||
CommandLineOptions.parse(List.of("--compose-main-window-please"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The one decision AWT forces that JavaFX did not.
|
||||
*
|
||||
* <p>{@code HostServices.showDocument} took web and mail addresses alike.
|
||||
* {@code Desktop.browse("mailto:...")} silently opens nothing on Linux, so the scheme has
|
||||
* to be read before the call is chosen.</p>
|
||||
*/
|
||||
class ExternalDocumentsTest {
|
||||
|
||||
@Test
|
||||
void aMailtoAddressGoesToTheMailClient() {
|
||||
assertTrue(ExternalDocuments.isMailAddress("mailto:praktimarc+kst4contest@gmail.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSchemeIsReadWithoutRegardToCase() {
|
||||
assertTrue(ExternalDocuments.isMailAddress("MAILTO:someone@example.org"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void webAddressesGoToTheBrowser() {
|
||||
assertFalse(ExternalDocuments.isMailAddress("https://ko-fi.com/praktimarc"));
|
||||
assertFalse(ExternalDocuments.isMailAddress("http://www.x08.de"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAddressThatMentionsMailtoLaterIsStillAWebAddress() {
|
||||
// Would be a mail client launch for a perfectly ordinary link otherwise.
|
||||
assertFalse(ExternalDocuments.isMailAddress("https://example.org/?to=mailto:x@y.z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingIsNotAMailAddress() {
|
||||
assertFalse(ExternalDocuments.isMailAddress(null));
|
||||
assertFalse(ExternalDocuments.isMailAddress(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The debounce behind the column widths.
|
||||
*
|
||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
|
||||
* silent: a timer that never fires means the operator's column widths are simply not
|
||||
* saved, with nothing to see until the next start.</p>
|
||||
*/
|
||||
class LayoutAutosaveTest {
|
||||
|
||||
private final UiDispatcher dispatcher = new AwtUiDispatcher();
|
||||
|
||||
@Test
|
||||
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
|
||||
CountDownLatch written = new CountDownLatch(1);
|
||||
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
|
||||
|
||||
autosave.requestSave();
|
||||
|
||||
assertTrue(written.await(5, TimeUnit.SECONDS),
|
||||
"a save requested off the user-interface thread must still happen");
|
||||
}
|
||||
|
||||
@Test
|
||||
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
CountDownLatch written = new CountDownLatch(1);
|
||||
LayoutAutosave autosave = new LayoutAutosave(() -> {
|
||||
writes.incrementAndGet();
|
||||
written.countDown();
|
||||
}, dispatcher);
|
||||
|
||||
// What dragging a column divider looks like: many events, one intended save.
|
||||
for (int i = 0; i < 20; i++) {
|
||||
autosave.requestSave();
|
||||
}
|
||||
|
||||
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
|
||||
Thread.sleep(300);
|
||||
assertEquals(1, writes.get(), "a burst must collapse into a single write");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
autosave.requestSave();
|
||||
autosave.cancelPending();
|
||||
});
|
||||
|
||||
Thread.sleep(1_200);
|
||||
assertEquals(0, writes.get(), "a cancelled save must not be written");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
autosave.requestSave();
|
||||
autosave.flushPending();
|
||||
// A second flush has nothing left to write; shutdownRuntime calls flush then cancel.
|
||||
autosave.flushPending();
|
||||
});
|
||||
|
||||
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.On4KstConnectionState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class MacOsConnectionStateMenuTitleTest {
|
||||
|
||||
@Test
|
||||
void everyStateHasTheAgreedTitle() {
|
||||
assertEquals("🟢 LINK: Connected",
|
||||
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.ONLINE));
|
||||
for (On4KstConnectionState state : new On4KstConnectionState[]{
|
||||
On4KstConnectionState.CONNECTING,
|
||||
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
|
||||
On4KstConnectionState.AUTHENTICATING,
|
||||
On4KstConnectionState.SYNCING_MAIN_CHAT,
|
||||
On4KstConnectionState.SYNCING_SECOND_CHAT}) {
|
||||
assertEquals("🟡 LINK: Connecting…", Kst4ContestApplication.macOsConnectionStateMenuTitle(state));
|
||||
}
|
||||
assertEquals("🟡 LINK: Disconnecting…",
|
||||
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.STOPPING));
|
||||
assertEquals("🔴 LINK: Reconnecting…",
|
||||
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.RECONNECT_WAIT));
|
||||
assertEquals("🔴 LINK: Disconnected",
|
||||
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.DISCONNECTED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStateIsShownAsDisconnected() {
|
||||
assertEquals("🔴 LINK: Disconnected", Kst4ContestApplication.macOsConnectionStateMenuTitle(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.controller.OperatorProfileStore;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OperatorProfileBootstrapTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() {
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileStore store = storeAt();
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
|
||||
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
|
||||
// Nothing may be asked, and nothing may be written.
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertTrue(store.loadProfiles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleProfileStartsWithoutAskingAnything() {
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
String[] observedPreselection = new String[1];
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store,
|
||||
new CommandLineOptions(null),
|
||||
(profiles, preselectedProfileId) -> {
|
||||
pickerInvocations.incrementAndGet();
|
||||
observedPreselection[0] = preselectedProfileId;
|
||||
return Optional.of(profiles.get(1));
|
||||
});
|
||||
|
||||
assertEquals(1, pickerInvocations.get());
|
||||
assertEquals("OP2", observedPreselection[0]);
|
||||
assertNotNull(resolved);
|
||||
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidProfileArgumentSkipsThePicker() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
|
||||
|
||||
OperatorProfileSelection byId = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName());
|
||||
assertNull(bootstrap.getStartupWarning());
|
||||
|
||||
OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
|
||||
|
||||
OperatorProfileSelection resolved = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0));
|
||||
|
||||
assertEquals(1, pickerInvocations.get());
|
||||
assertNotNull(resolved);
|
||||
assertNotNull(bootstrap.getStartupWarning());
|
||||
assertTrue(bootstrap.getStartupWarning().contains("NOPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void quittingInThePickerYieldsNoSelection() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty());
|
||||
|
||||
assertNull(resolved);
|
||||
}
|
||||
|
||||
private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter,
|
||||
final Integer profileIndexToChoose) {
|
||||
return (profiles, preselectedProfileId) -> {
|
||||
invocationCounter.incrementAndGet();
|
||||
|
||||
if (profileIndexToChoose == null) {
|
||||
return Optional.of(profiles.get(0));
|
||||
}
|
||||
|
||||
return Optional.of(profiles.get(profileIndexToChoose));
|
||||
};
|
||||
}
|
||||
|
||||
private OperatorProfileStore storeAt() {
|
||||
return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleRoster;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RosterListBindingTest {
|
||||
|
||||
@Test
|
||||
void theMirrorStartsWithTheCurrentContent() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMirrorFollowsLaterChanges() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
roster.add("DN9APW");
|
||||
roster.add("DO5AMF");
|
||||
|
||||
assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMirrorIsTheSameInstanceThroughout() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
List<String> list = binding.list();
|
||||
|
||||
roster.add("DN9APW");
|
||||
|
||||
assertTrue(list == binding.list(),
|
||||
"a caller holds on to the instance it was given; it must not be replaced");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disposeStopsFollowingAndReleasesTheListener() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
binding.dispose();
|
||||
roster.add("DN9APW");
|
||||
|
||||
assertTrue(binding.list().isEmpty(),
|
||||
"a disposed binding must not keep the old runtime alive across a profile switch");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnchangedResultNotifiesNobody() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
AtomicInteger changes = new AtomicInteger();
|
||||
binding.onChanged(rows -> changes.incrementAndGet());
|
||||
|
||||
binding.refresh();
|
||||
binding.refresh();
|
||||
|
||||
assertEquals(0, changes.get(),
|
||||
"an unchanged station list must not make the message pane drop its selection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRealChangeNotifiesOnceWithTheNewContent() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
List<List<String>> seen = new java.util.ArrayList<>();
|
||||
binding.onChanged(seen::add);
|
||||
|
||||
roster.add("DO5AMF");
|
||||
|
||||
// Nothing covered this before: a mirror that never notifies leaves the pane blank.
|
||||
assertEquals(1, seen.size(), "a real change must notify exactly once");
|
||||
assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDerivedBindingAppliesTheDerivationAndFollowsItsSources() {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
SimpleRoster<String> filters = new SimpleRoster<>();
|
||||
members.add("DO5AMF");
|
||||
members.add("DN9APW");
|
||||
|
||||
RosterListBinding<String> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> members.snapshot().stream()
|
||||
.filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call))
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.toList(),
|
||||
members, filters);
|
||||
|
||||
assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list()));
|
||||
|
||||
filters.add("DO5AMF");
|
||||
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()),
|
||||
"a change of a source roster must recompute the derived content");
|
||||
|
||||
members.add("DL1ABC");
|
||||
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRecomputesWithoutASourceChange() {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
members.add("DN9APW");
|
||||
members.add("DO5AMF");
|
||||
String[] wanted = { "DN9APW" };
|
||||
|
||||
RosterListBinding<String> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(),
|
||||
members);
|
||||
|
||||
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
|
||||
|
||||
wanted[0] = "DO5AMF";
|
||||
binding.refresh();
|
||||
|
||||
assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()),
|
||||
"a newly set filter must be visible without waiting for the next roster change");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDisposedDerivedBindingDetachesFromEverySource() {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
SimpleRoster<String> filters = new SimpleRoster<>();
|
||||
|
||||
RosterListBinding<String> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(), members::snapshot, members, filters);
|
||||
|
||||
binding.dispose();
|
||||
members.add("DN9APW");
|
||||
filters.add("DN9APW");
|
||||
|
||||
assertTrue(binding.list().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeFalse;
|
||||
|
||||
/**
|
||||
* The startup size correction has to survive the move off {@code javafx.stage.Screen}.
|
||||
*
|
||||
* <p>A stored size from a larger monitor is reduced to what the current screen offers;
|
||||
* that rule lives in {@code MainWindowFrame.startupSize}, and this class only has to hand
|
||||
* it honest numbers. Returning zero would give Compose a 0x0 window.</p>
|
||||
*/
|
||||
class ScreenBoundsTest {
|
||||
|
||||
@Test
|
||||
void theAvailableAreaIsPositive() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display");
|
||||
|
||||
assertTrue(ScreenBounds.availableWidth() > 0);
|
||||
assertTrue(ScreenBounds.availableHeight() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headlessFallsBackToASizeTheWindowCanActuallyUse() {
|
||||
// Packaging and CI run headless. Returning zero would hand Compose a 0x0 window.
|
||||
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null));
|
||||
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownBoundIsPassedThrough() {
|
||||
Rectangle bounds = new Rectangle(0, 0, 2560, 1400);
|
||||
|
||||
assertEquals(2560.0, ScreenBounds.widthOf(bounds));
|
||||
assertEquals(1400.0, ScreenBounds.heightOf(bounds));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDegenerateBoundFallsBackRatherThanReportingZero() {
|
||||
Rectangle nothing = new Rectangle(0, 0, 0, 0);
|
||||
|
||||
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(nothing));
|
||||
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(nothing));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class TableLayoutManagerTest {
|
||||
|
||||
@Test
|
||||
void contentWidthUsesCompactPadding() {
|
||||
assertEquals(116.0, TableLayoutManager.calculateInitialContentWidth(100.0, 24.0, 200.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentWidthStillHonorsMinimumAndMaximum() {
|
||||
assertEquals(24.0, TableLayoutManager.calculateInitialContentWidth(0.0, 24.0, 200.0));
|
||||
assertEquals(200.0, TableLayoutManager.calculateInitialContentWidth(250.0, 24.0, 200.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TruncatedTextTableCellTest {
|
||||
|
||||
@Test
|
||||
void plainFullTextTooltipIsShownOnlyForClippedText() {
|
||||
assertNull(TruncatedTextTooltipSupport.buildTooltipText("complete", false, null));
|
||||
assertEquals("complete",
|
||||
TruncatedTextTooltipSupport.buildTooltipText("complete", true, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void functionalTooltipRemainsAndCombinesWithClippedValue() {
|
||||
assertEquals("Worked status",
|
||||
TruncatedTextTooltipSupport.buildTooltipText("X", false, "Worked status"));
|
||||
assertEquals("Long value\n\nWorked status",
|
||||
TruncatedTextTooltipSupport.buildTooltipText("Long value", true, "Worked status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clippingComparisonUsesAvailableRenderedWidth() {
|
||||
assertFalse(TruncatedTextTooltipSupport.isTextClipped(100, 100));
|
||||
assertTrue(TruncatedTextTooltipSupport.isTextClipped(102, 100));
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Guards the property that made the terrain cache useless with several operator
|
||||
* profiles: it used to drop every cached profile whenever the configured callsign or
|
||||
* locator changed.
|
||||
*/
|
||||
class TerrainProfileCacheRepositoryTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
private static final String PROVIDER_ID = "test-provider";
|
||||
private static final int SAMPLE_COUNT = 3;
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreUserHome() {
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
// Another operator profile with a different callsign and locator.
|
||||
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
|
||||
|
||||
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
|
||||
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
|
||||
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||
|
||||
assertTrue(firstOwnerEntry.isPresent(),
|
||||
"Working under a second operator identity must not discard the first one's cache");
|
||||
assertTrue(secondOwnerEntry.isPresent());
|
||||
assertEquals("station-a", firstOwnerEntry.get().sourceName());
|
||||
assertEquals("station-b", secondOwnerEntry.get().sourceName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
|
||||
|
||||
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
|
||||
"A cache miss of one owner must not remove the entries of another");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
|
||||
|
||||
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
|
||||
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
|
||||
"The terrain cache must not pull in the worked station database");
|
||||
}
|
||||
|
||||
private static TerrainProfileData profileData(final String sourceName) {
|
||||
return new TerrainProfileData(
|
||||
List.of(
|
||||
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
|
||||
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
|
||||
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
|
||||
sourceName,
|
||||
false);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+69
@@ -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")))
|
||||
}
|
||||
}
|
||||
+88
@@ -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" })
|
||||
}
|
||||
}
|
||||
+50
@@ -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,
|
||||
)
|
||||
}
|
||||
+140
@@ -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)
|
||||
}
|
||||
}
|
||||
+278
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
+65
@@ -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"))
|
||||
}
|
||||
}
|
||||
+83
@@ -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)
|
||||
}
|
||||
}
|
||||
+46
@@ -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())
|
||||
}
|
||||
}
|
||||
+65
@@ -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")
|
||||
}
|
||||
}
|
||||
+272
@@ -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)
|
||||
}
|
||||
}
|
||||
+80
@@ -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()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# Generated from com.sun.javafx.util.Utils.deriveColor by
|
||||
# JavaFxStylesheetTest.`generate the JavaFX reference data`.
|
||||
# Format: <rgbHex>;<percent>;<expectedRgbHex>
|
||||
#ffffff;-80.0;#333333
|
||||
#ffffff;-50.0;#808080
|
||||
#ffffff;-35.0;#A6A6A6
|
||||
#ffffff;-30.0;#B3B3B3
|
||||
#ffffff;-10.0;#E6E6E6
|
||||
#ffffff;10.0;#FFFFFF
|
||||
#ffffff;26.4;#FFFFFF
|
||||
#ffffff;35.0;#FFFFFF
|
||||
#ffffff;50.0;#FFFFFF
|
||||
#ffffff;80.0;#FFFFFF
|
||||
#000000;-80.0;#000000
|
||||
#000000;-50.0;#000000
|
||||
#000000;-35.0;#000000
|
||||
#000000;-30.0;#000000
|
||||
#000000;-10.0;#000000
|
||||
#000000;10.0;#0F0F0F
|
||||
#000000;26.4;#282828
|
||||
#000000;35.0;#363636
|
||||
#000000;50.0;#4D4D4D
|
||||
#000000;80.0;#7A7A7A
|
||||
#ececec;-80.0;#2F2F2F
|
||||
#ececec;-50.0;#767676
|
||||
#ececec;-35.0;#999999
|
||||
#ececec;-30.0;#A5A5A5
|
||||
#ececec;-10.0;#D4D4D4
|
||||
#ececec;10.0;#EFEFEF
|
||||
#ececec;26.4;#F4F4F4
|
||||
#ececec;35.0;#F7F7F7
|
||||
#ececec;50.0;#FBFBFB
|
||||
#ececec;80.0;#FFFFFF
|
||||
#373e43;-80.0;#0B0C0D
|
||||
#373e43;-50.0;#1C1F22
|
||||
#373e43;-35.0;#24282C
|
||||
#373e43;-30.0;#272B2F
|
||||
#373e43;-10.0;#32383C
|
||||
#373e43;10.0;#41494E
|
||||
#373e43;26.4;#525B61
|
||||
#373e43;35.0;#5B646A
|
||||
#373e43;50.0;#6C757B
|
||||
#373e43;80.0;#8F979D
|
||||
#1d1d1d;-80.0;#0F0F0F
|
||||
#1d1d1d;-50.0;#141414
|
||||
#1d1d1d;-35.0;#171717
|
||||
#1d1d1d;-30.0;#181818
|
||||
#1d1d1d;-10.0;#1B1B1B
|
||||
#1d1d1d;10.0;#2B2B2B
|
||||
#1d1d1d;26.4;#414141
|
||||
#1d1d1d;35.0;#4C4C4C
|
||||
#1d1d1d;50.0;#616161
|
||||
#1d1d1d;80.0;#898989
|
||||
#4da6ff;-80.0;#0F2233
|
||||
#4da6ff;-50.0;#275480
|
||||
#4da6ff;-35.0;#326EA6
|
||||
#4da6ff;-30.0;#3676B3
|
||||
#4da6ff;-10.0;#4598E6
|
||||
#4da6ff;10.0;#5DB1FF
|
||||
#4da6ff;26.4;#77BDFF
|
||||
#4da6ff;35.0;#85C4FF
|
||||
#4da6ff;50.0;#9DD0FF
|
||||
#4da6ff;80.0;#CDE7FF
|
||||
#2b2b2b;-80.0;#161616
|
||||
#2b2b2b;-50.0;#1E1E1E
|
||||
#2b2b2b;-35.0;#222222
|
||||
#2b2b2b;-30.0;#232323
|
||||
#2b2b2b;-10.0;#282828
|
||||
#2b2b2b;10.0;#383838
|
||||
#2b2b2b;26.4;#4D4D4D
|
||||
#2b2b2b;35.0;#585858
|
||||
#2b2b2b;50.0;#6B6B6B
|
||||
#2b2b2b;80.0;#919191
|
||||
#808080;-80.0;#1A1A1A
|
||||
#808080;-50.0;#404040
|
||||
#808080;-35.0;#535353
|
||||
#808080;-30.0;#5A5A5A
|
||||
#808080;-10.0;#737373
|
||||
#808080;10.0;#8B8B8B
|
||||
#808080;26.4;#9E9E9E
|
||||
#808080;35.0;#A8A8A8
|
||||
#808080;50.0;#B9B9B9
|
||||
#808080;80.0;#DBDBDB
|
||||
#333333;-80.0;#0A0A0A
|
||||
#333333;-50.0;#1A1A1A
|
||||
#333333;-35.0;#212121
|
||||
#333333;-30.0;#242424
|
||||
#333333;-10.0;#2E2E2E
|
||||
#333333;10.0;#3F3F3F
|
||||
#333333;26.4;#535353
|
||||
#333333;35.0;#5E5E5E
|
||||
#333333;50.0;#707070
|
||||
#333333;80.0;#959595
|
||||
#4d4d4d;-80.0;#0F0F0F
|
||||
#4d4d4d;-50.0;#272727
|
||||
#4d4d4d;-35.0;#323232
|
||||
#4d4d4d;-30.0;#363636
|
||||
#4d4d4d;-10.0;#454545
|
||||
#4d4d4d;10.0;#595959
|
||||
#4d4d4d;26.4;#6E6E6E
|
||||
#4d4d4d;35.0;#797979
|
||||
#4d4d4d;50.0;#8B8B8B
|
||||
#4d4d4d;80.0;#B1B1B1
|
||||
#666666;-80.0;#141414
|
||||
#666666;-50.0;#333333
|
||||
#666666;-35.0;#424242
|
||||
#666666;-30.0;#474747
|
||||
#666666;-10.0;#5C5C5C
|
||||
#666666;10.0;#727272
|
||||
#666666;26.4;#868686
|
||||
#666666;35.0;#919191
|
||||
#666666;50.0;#A3A3A3
|
||||
#666666;80.0;#C8C8C8
|
||||
#999999;-80.0;#1F1F1F
|
||||
#999999;-50.0;#4D4D4D
|
||||
#999999;-35.0;#636363
|
||||
#999999;-30.0;#6B6B6B
|
||||
#999999;-10.0;#8A8A8A
|
||||
#999999;10.0;#A3A3A3
|
||||
#999999;26.4;#B4B4B4
|
||||
#999999;35.0;#BDBDBD
|
||||
#999999;50.0;#CCCCCC
|
||||
#999999;80.0;#EBEBEB
|
||||
#d9d9d9;-80.0;#2B2B2B
|
||||
#d9d9d9;-50.0;#6D6D6D
|
||||
#d9d9d9;-35.0;#8D8D8D
|
||||
#d9d9d9;-30.0;#989898
|
||||
#d9d9d9;-10.0;#C3C3C3
|
||||
#d9d9d9;10.0;#DFDFDF
|
||||
#d9d9d9;26.4;#E9E9E9
|
||||
#d9d9d9;35.0;#EEEEEE
|
||||
#d9d9d9;50.0;#F7F7F7
|
||||
#d9d9d9;80.0;#FFFFFF
|
||||
#f4f4f4;-80.0;#313131
|
||||
#f4f4f4;-50.0;#7A7A7A
|
||||
#f4f4f4;-35.0;#9F9F9F
|
||||
#f4f4f4;-30.0;#ABABAB
|
||||
#f4f4f4;-10.0;#DCDCDC
|
||||
#f4f4f4;10.0;#F6F6F6
|
||||
#f4f4f4;26.4;#F9F9F9
|
||||
#f4f4f4;35.0;#FAFAFA
|
||||
#f4f4f4;50.0;#FDFDFD
|
||||
#f4f4f4;80.0;#FFFFFF
|
||||
#63067a;-80.0;#33033F
|
||||
#63067a;-50.0;#450455
|
||||
#63067a;-35.0;#4E0560
|
||||
#63067a;-30.0;#510564
|
||||
#63067a;-10.0;#5D0673
|
||||
#63067a;10.0;#6B0E82
|
||||
#63067a;26.4;#781D8F
|
||||
#63067a;35.0;#7F2596
|
||||
#63067a;50.0;#8C36A2
|
||||
#63067a;80.0;#A75EBA
|
||||
#ff9900;-80.0;#331F00
|
||||
#ff9900;-50.0;#804D00
|
||||
#ff9900;-35.0;#A66300
|
||||
#ff9900;-30.0;#B36B00
|
||||
#ff9900;-10.0;#E68A00
|
||||
#ff9900;10.0;#FFA31A
|
||||
#ff9900;26.4;#FFB443
|
||||
#ff9900;35.0;#FFBD59
|
||||
#ff9900;50.0;#FFCC80
|
||||
#ff9900;80.0;#FFEBCC
|
||||
#e0e0e0;-80.0;#2D2D2D
|
||||
#e0e0e0;-50.0;#707070
|
||||
#e0e0e0;-35.0;#929292
|
||||
#e0e0e0;-30.0;#9D9D9D
|
||||
#e0e0e0;-10.0;#CACACA
|
||||
#e0e0e0;10.0;#E5E5E5
|
||||
#e0e0e0;26.4;#EDEDED
|
||||
#e0e0e0;35.0;#F1F1F1
|
||||
#e0e0e0;50.0;#F9F9F9
|
||||
#e0e0e0;80.0;#FFFFFF
|
||||
#eeeeee;-80.0;#303030
|
||||
#eeeeee;-50.0;#777777
|
||||
#eeeeee;-35.0;#9B9B9B
|
||||
#eeeeee;-30.0;#A7A7A7
|
||||
#eeeeee;-10.0;#D6D6D6
|
||||
#eeeeee;10.0;#F1F1F1
|
||||
#eeeeee;26.4;#F5F5F5
|
||||
#eeeeee;35.0;#F8F8F8
|
||||
#eeeeee;50.0;#FCFCFC
|
||||
#eeeeee;80.0;#FFFFFF
|
||||
#606060;-80.0;#131313
|
||||
#606060;-50.0;#303030
|
||||
#606060;-35.0;#3E3E3E
|
||||
#606060;-30.0;#434343
|
||||
#606060;-10.0;#565656
|
||||
#606060;10.0;#6B6B6B
|
||||
#606060;26.4;#7D7D7D
|
||||
#606060;35.0;#878787
|
||||
#606060;50.0;#989898
|
||||
#606060;80.0;#B9B9B9
|
||||
#5a5a5a;-80.0;#121212
|
||||
#5a5a5a;-50.0;#2D2D2D
|
||||
#5a5a5a;-35.0;#3B3B3B
|
||||
#5a5a5a;-30.0;#3F3F3F
|
||||
#5a5a5a;-10.0;#515151
|
||||
#5a5a5a;10.0;#666666
|
||||
#5a5a5a;26.4;#787878
|
||||
#5a5a5a;35.0;#828282
|
||||
#5a5a5a;50.0;#949494
|
||||
#5a5a5a;80.0;#B6B6B6
|
||||
#707070;-80.0;#161616
|
||||
#707070;-50.0;#383838
|
||||
#707070;-35.0;#494949
|
||||
#707070;-30.0;#4E4E4E
|
||||
#707070;-10.0;#656565
|
||||
#707070;10.0;#7B7B7B
|
||||
#707070;26.4;#8E8E8E
|
||||
#707070;35.0;#989898
|
||||
#707070;50.0;#A9A9A9
|
||||
#707070;80.0;#CCCCCC
|
||||
#454545;-80.0;#0E0E0E
|
||||
#454545;-50.0;#232323
|
||||
#454545;-35.0;#2D2D2D
|
||||
#454545;-30.0;#303030
|
||||
#454545;-10.0;#3E3E3E
|
||||
#454545;10.0;#505050
|
||||
#454545;26.4;#626262
|
||||
#454545;35.0;#6C6C6C
|
||||
#454545;50.0;#7D7D7D
|
||||
#454545;80.0;#9E9E9E
|
||||
@@ -0,0 +1,72 @@
|
||||
setAirScout_asBandString
|
||||
setAirScout_asClientNameString
|
||||
setAirScout_asCommunicationPort
|
||||
setAirScout_asServerNameString
|
||||
setAirScout_asUDPListenerEnabled
|
||||
setAirScout_autoBandSelectionEnabled
|
||||
setBcn_beaconsEnabledMainCat
|
||||
setBcn_beaconsEnabledSecondCat
|
||||
setGUI_darkModeActiveByDefault
|
||||
setGuiOptions_defaultFilterNothing
|
||||
setGuiOptions_defaultFilterPmToMe
|
||||
setGuiOptions_defaultFilterPmToOther
|
||||
setGuiOptions_defaultFilterPublicMsgs
|
||||
setGuiOptions_showFreshCallHintInBandColumns
|
||||
setGuiOptions_showGrossFieldWorkedHintInBandColumns
|
||||
setLoginChatCategoryMain
|
||||
setLoginChatCategorySecond
|
||||
setLoginToSecondChatEnabled
|
||||
setLogsynch_fileBasedWkdCallInterpreterEnabled
|
||||
setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly
|
||||
setLogsynch_ucxUDPWkdCallListenerEnabled
|
||||
setLogsynch_ucxUDPWkdCallListenerPort
|
||||
setLogsynch_wintestNetworkBroadcastAddress
|
||||
setLogsynch_wintestNetworkListenerEnabled
|
||||
setLogsynch_wintestNetworkPort
|
||||
setLogsynch_wintestNetworkStationNameOfKST
|
||||
setLogsynch_wintestNetworkStationNameOfWintestClient1
|
||||
setLogsynch_wintestQrgSyncEnabled
|
||||
setLogsynch_wintestUsePassQrg
|
||||
setMessageHandling_autoAnswerEnabled
|
||||
setMessageHandling_autoAnswerEnabledSecondCat
|
||||
setMessageHandling_autoAnswerTextMainCat
|
||||
setMessageHandling_autoAnswerTextSecondCat
|
||||
setMessageHandling_autoAnswerToQRGRequestEnabled
|
||||
setNotify_bandUpgradeHintOnLogEnabled
|
||||
setNotify_bandUpgradePriorityBoostEnabled
|
||||
setNotify_dxClusterServerEnabled
|
||||
setNotify_dxclusterServerPort
|
||||
setNotify_DXCSrv_SpottersCallSign
|
||||
setNotify_optionalFrequencyPrefix
|
||||
setNotify_playCWCallsignsOnRxedPMs
|
||||
setNotify_playSimpleSounds
|
||||
setNotify_playVoiceCallsignsOnRxedPMs
|
||||
setStn_antennaBeamWidthDeg
|
||||
setStn_bandActive10G
|
||||
setStn_bandActive1240
|
||||
setStn_bandActive144
|
||||
setStn_bandActive2300
|
||||
setStn_bandActive3400
|
||||
setStn_bandActive432
|
||||
setStn_bandActive50
|
||||
setStn_bandActive5600
|
||||
setStn_bandActive70
|
||||
setStn_loginCallSign
|
||||
setStn_loginLocatorMainCat
|
||||
setStn_loginNameMainCat
|
||||
setStn_loginNameSecondCat
|
||||
setStn_loginPassword
|
||||
setStn_maxQRBDefault
|
||||
setStn_on4kstServersDns
|
||||
setStn_on4kstServersPort
|
||||
setStn_pathAnalysisDefaultTargetAntennaGainDbi
|
||||
setStn_pathAnalysisDefaultTargetTxPowerWatts
|
||||
setStn_pathAnalysisDemRootDirectory
|
||||
setStn_pathAnalysisOwnAntennaGainDbi
|
||||
setStn_pathAnalysisOwnAntennaHeightMeters
|
||||
setStn_pathAnalysisOwnTxPowerWatts
|
||||
setStn_pstRotatorEnabled
|
||||
setStn_pstRotatorHost
|
||||
setStn_pstRotatorPort
|
||||
setStn_qtfDefault
|
||||
setTrxSynch_ucxLogUDPListenerEnabled
|
||||
@@ -0,0 +1,5 @@
|
||||
setCallSign
|
||||
setChatCategoryMain
|
||||
setChatCategorySecondChat
|
||||
setFrequency
|
||||
setQra
|
||||
Reference in new issue
Block a user