Compare commits

..
Author SHA1 Message Date
Rsclub2_2andClaude Opus 5.5 e0c1c6b6d4 One chat-frame flow for live and history messages (#105) (#139)
Second slice of making MessageBusManagementThread testable. The CH (live)
and CR (history) chat-frame branches of processRXMessage23001 were near
copies; they are now one processChatFrame(fields, live), with behaviour
unchanged.

- InboundChatFrame, ChatRoute and classifyRoute: where a message belongs
  (public, to me, from me, between others), pure.
- ChatMessageEffects and decideChatFrameEffects: what a message triggers
  (sounds, //BELL, //VER reply, auto-answer, directional tick, in-angle
  flag, DX cluster spot), pure. History frames trigger nothing but the flag.
- The four live/history differences are kept on purpose: frequency
  detection before routing (live) or after publishing (history), effects
  for messages to me, tick and spot between others, AirScout info on the
  SERVER sender.
- MessageBusManagementThreadChatFrameTest pins both branches through the
  real thread, including the frequency order on directed frames and the
  spot entry; ChatFrameDecisionTest covers the pure functions.
- Spec: docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:45:21 +02:00
Rsclub2_2andClaude Opus 5.5 797b01c748 Changelog 1.50: new interface, German and English, adjustable colours (#138)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:25:47 +02:00
Rsclub2_2andClaude Opus 5.5 5b7c774f5f SO2R: two radios in one instance (#46) (#137)
* Spec: SO2R with two radios in one instance (#46)

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

* Plan: SO2R implementation (#46)

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

* Move the rotator command rules into RotatorControl (#46)

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

* Store the SO2R switch, the second Win-Test station and rotator 2 (#46)

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

* Route frequency reports to radio 1 or 2 (#46)

Add the RadioSlot enum (FIRST, SECOND) with its QRG and QTF targets, and
the pure RadioSlotRouter that decides per RadioInfo packet and per
Win-Test station which radio a report belongs to.

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

* Send RadioInfo of radio 2 to SECONDQRG when SO2R is on (#46)

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

* Send Win-Test STATUS of the second station to SECONDQRG when SO2R is on (#46)

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

* Run a second PSTRotator for radio 2 with SO2R (#46)

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

* Start rotator 2 only while an ON4KST session runs (#46)

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

* Settings for SO2R, the second Win-Test station and rotator 2 (#46)

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

* Turn antenna 2 and show QTF2 with SO2R (#46)

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

* Make the SO2R switches observable so Apply shows them (#46)

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

* Document SO2R (#46)

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

* Refuse clashing PSTRotator ports in both directions (#46)

Writing either port now checks same-or-adjacent against the drafted value of
the other; rotor 1 is only checked while SO2R and rotor 2 are on. Also fixes
a misplaced Javadoc, stale WinTest parseStatus Javadoc, docs wording and
removes an unused import and dead comment.

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

* Remove the executed SO2R plan (#46)

The plan's content lives on in the commits, the spec and PROJECT_CONTEXT.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:14:27 +02:00
Rsclub2_2andClaude Opus 5.5 feb4b4e2c9 Open secondary windows on the main window's screen (#59) (#135)
* Open secondary windows on the main window's screen (#59)

Every Compose window but the main one went to the platform's default
position, which on a multi-monitor desk is the primary screen - the
station map could open on a monitor that was switched off. They now open
centred on the screen the main window is on, with their size clamped to
that screen's usable area (insets considered). A window the operator
moved during the session reopens there, as long as its title bar can
still be grabbed on a connected screen; otherwise it falls back to the
main window's screen. The position lives only in the window's host and
is never persisted. ComposeAlert dialogs and the profile picker are
centred the same way once the main window exists.

The rule is a pure function (WindowPlacement) with 18 unit tests; the
AWT side (WindowPlacementOnScreen) only reads the screens and the main
window's bounds on the event thread. The clamped opening size of a
secondary window is not written back, so the stored size survives a
smaller screen.

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

* Track window moves reliably and keep remembered windows on screen (#59)

Review fixes for the window placement:

- Once a window has left the tolerance around its opening position,
  every later position is remembered. Before, the anchor was never
  re-set, so dragging away and back to near the opening spot left the
  stale far position remembered.
- A window the host did not place (opened before the main window
  exists) only trusts its anchor once two consecutive reports lie
  within the tolerance, so a provisional position followed by the
  window manager's placement jump is no longer taken as the operator's
  move.
- A remembered position is shifted the least distance that puts the
  (clamped) window wholly inside the usable area of the screen holding
  its title bar, as the KDoc promised.

The move rule moves into a pure MoveTracker with 5 tests; WindowPlacement
gains keptWithin and 2 more tests.

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

* Keep window positions across restarts as an opt-in; fit windows to small screens (#59)

A new GUI tab option, off by default, keeps the position the operator last
moved each window to, the main window included. It is stored per window
with the bounds of the screen it was on, written by the layout autosave,
and restored only when a screen with exactly those bounds is connected
again; otherwise the session rule applies. Only operator moves are
stored, never window manager nudges, so a restored window cannot drift.

An open window that rests on a screen too small for it is now cut down
to that screen's usable area, and the station map's 900x600 minimum is
capped to the smallest connected screen, so 640x480 monitors stay usable.

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

* Never store a size or position the host fitted a window to (#59)

The cut-down to a small screen was written back as the stored size
(main window via onResized, others via the tracker) and counted as an
operator move. The host now records its own fit: that size is not
written back, and that position re-anchors the move tracker instead of
being stored. A main window opened at a stored position skips its
opening size like the other windows, since it may be clamped too.
The opt-in gate moves into PreferencesWindowPositionMemory, with tests.

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

* Match the fit echo with tolerance and consume it once (#59)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:58:28 +02:00
Rsclub2_2andClaude Opus 5.5 bb0e9eb327 Restore the connection line in the main window title (#136)
* Restore the connection line in the main window title

While online, the title showed the connection manager's diagnostic
"ON4KST session is authenticated and synchronized" instead of the line
the JavaFX window carried: categories, call, name, locator, users online,
stations shown and messages total. The title now builds that line from
the login preferences and the live station and message tables, so it
follows every new station, filter change and message. The diagnostic
stays where it belongs, in the status bar.

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

* Title: typed categories, no empty category, review fixes

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:50:12 +02:00
Rsclub2_2andClaude Opus 5.5 92c2576a81 Path analysis: own ground height ASL and highest point of the remote locator square (#133)
* Path analysis: own ground height ASL and highest point of the remote square

Two new settings in the path-analysis section of the Station tab:

- Own ground height ASL (stn_pathAnalysisOwnHeightAslMeters). Empty keeps
  the elevation model value at the own locator centre; a value from -500 to
  9000 m replaces the elevation of the first profile sample. The antenna
  height AGL is still added on top. The field commits on Enter or focus
  loss into the settings draft and reports a refused entry; it keeps the
  operator's spelling while that names the drafted number, because the
  draft is Compose state and would otherwise swap "25" for "25.0" under
  the cursor right after Enter.
- Target station on the highest point of its locator square
  (stn_pathAnalysisDxHighestPointInSquare, on by default). The remote square
  is sampled on a 5 x 5 grid of cell centres in one batched request against
  the Open-Meteo provider, the highest sample becomes the path end with its
  position and elevation. LocatorHighestPointResolver caches the result per
  locator for the session and backs off five minutes after a failure
  (failure list pruned and bounded); the analysis then falls back to the
  square centre and that result is not kept.

Both settings are drafted like the other path-analysis settings and take
effect with Apply/Close or Save; the reachability service reads the stored
values only.

The lookup runs on the reachability thread only. Building a request peeks
the resolver cache without blocking, so a cached square moves the endpoint
right away. The calculation key now includes the own height and the remote
ground elevation at full precision, and the switch, so changing either
recalculates.

Also assign the result of sanitizeEffectiveEarthRadiusFactor in the
PathAnalysisRequest constructor; it was called and dropped. No behaviour
change: the sanitizer is the identity for valid factors, and the analysis
service sanitizes again.

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

* Document own ground height and highest-point setting of the path analysis

Changelog v1.50 (New), configuration and features pages in English and
German, and the terrain section of PROJECT_CONTEXT.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:18:56 +02:00
Rsclub2_2andClaude Opus 5.5 ee4f1fe02e Minimum QRB filter; the station map follows the station filters (#21) (#132)
* Add a minimum QRB filter to the station filter bar (#21)

Next to "Show only QRB [km] <=" the bar gets "Show only QRB [km] >": it
hides every station up to the entered distance. With both switched on the
list keeps a range, min < QRB <= max. The minimum is exclusive, as the
issue asks, so the two filters split the list without overlap at the same
value. A station without a known QRB is hidden while either is on, as the
maximum already did. The value lives in the bar for the running session
only, like the maximum, and the station map follows through activeFilters.

The distance check moves from the controller predicate into
StationFilterState.acceptsQrb so it can be tested; the maximum keeps its
behaviour (null or > max is hidden).

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

* Document the minimum QRB filter (#21)

Changelog v1.50 under New, the filter tables of the user interface pages,
the distance filter section of the feature pages, and the station filter
page of the website.

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

* Let the first filter bar row wrap instead of clipping (#21)

With both QRB filters the first row of the station filter bar no longer
fits a narrow station pane, and as a plain Row it cut the bearing buttons
off at the right edge. It is a FlowRow now. Reset, the QRB pair, the QTF
controls and the bearings each move to the next line as one group; the
QRB pair and the bearings only break up inside their group when even the
group is wider than the pane. Separators in the wrapping row have a fixed
height, because a flow row line has no intrinsic height to fill.

A screen test at 300 dp checks that every bearing button, both QRB labels
and fields and the QTF label are displayed and end inside the bar.

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

* Refuse NaN, Infinity and negatives in the QRB filter fields (#21)

toDoubleOrNull also reads "NaN" and "Infinity": a pasted "Infinity" as
the minimum hid every station, "NaN" let every one through, and the
maximum field had the same hole before. Both QRB fields now go through
parseQrbKm, which takes only finite, non-negative distances; anything
else leaves the field at its last valid value.

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

* Let the station map follow the station filters (#21)

The Compose map built its markers from all station rows, so no filter
reached it, although its status line reported "filtered view active".
The JavaFX map, up to beta-v1.50.0-beta-001, took the sorted, filtered
list (StationMapBridge.refreshNow), so this is a regression of the
Compose port that never shipped. The markers now come from the rows the
list shows, and the filter is a key of the remembered markers so a
filter change redraws them. A selected station that a filter hides gets
no marker, as in the JavaFX map.

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

* Filter again on redraw; no path analysis for a hidden station (#21)

DataTableState.visibleRows caches the filtered rows, and forceRedraw only
bumped contentRevision. The filter reads fields that change in place
(QRB, worked and QRV flags, last activity), so a redraw could hand back
rows filtered before the change -- in the list and, since the map draws
the same rows, on the map. forceRedraw now drops the cache, and
visibleRows reads contentRevision so a reader recomputes after it. In the
running application the user-list refresh also sets a new row filter,
which dropped the cache before; this makes the redraw itself sufficient.

A selected station without a marker -- hidden by a filter, or without a
locator -- no longer starts a path analysis: the map used to set
"Loading" with an empty target and send the request. The analysis is
cleared instead, and the effect re-runs when the station's marker
appears or disappears.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:17:33 +02:00
Rsclub2_2andClaude Opus 5.5 f17ae0905f Settings take effect with Apply/Close or Save (#134)
* Add a settings draft that holds edits until they are applied

The settings window is to stage its edits and apply them with Apply/Close
or Save instead of writing each one at once. SettingsDraft holds one
pending change per setting, reads through to the stored value, and on
apply writes everything first and then runs each effect key once, in the
order the settings were first changed. Form.committed registers its typed
entry with the draft so a click on Apply cannot lose it.

The spec explains the order and the settings that stay immediate.

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

* Stage the settings tabs' edits and apply them with Apply/Close or Save

Every tab state now reads through the window's draft and stages its edits
there instead of writing ChatPreferences: Station (all but the login
credentials), Log synch, TRX synch, Airscout, Notification, Shortcuts,
Beacon, Message handling and GUI (all but the language). The lists stage
their roster, the committed fields stage what their rule accepts.

Effects stay attached to their settings. The Win-Test listener and the DX
cluster server share one effect for switch and port that brings the running
service in line with the applied state, so applying both restarts it once,
on the new port. The own-QRG follower runs once for both sources.

Apply/Close applies and closes, Save applies and writes, Connect applies
before it connects, so the session uses what the tabs show. A field that
refuses its entry on the way stops the button with the reason shown.

Immediate as before: the login credentials, the language, the colours, the
file chooser, the test spot and the profile and worked-database actions.

Number fields keep the typed text while the staged value comes back as
Compose state; otherwise "1" became "1.0" before the next key.

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

* Ask about unapplied settings when the window is closed

Closing the settings window with its X, or through the Windows menu, while
edits wait in the draft now asks: apply them, discard them, or go back to
the window. Without pending edits the window closes as before. Fields and
list rows still holding a typed entry are committed first, so an entry
typed just before the X counts.

The request only sets Compose state; the window decides on its next frame,
so nothing waits on the event thread. A close from outside -- a profile
switch, the shutdown -- discards the draft: the preferences it belongs to
are about to be replaced.

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

* Document that settings take effect with Apply

PROJECT_CONTEXT records the draft and what stays immediate, the changelog
gets one entry under v1.50, and the configuration manual no longer says
that settings take effect while they are edited.

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

* Repair stored settings once before the settings window is built

The fallback band of the DX cluster was repaired to 144 inside a getter
that runs during composition, and the Win-Test broadcast address was
auto-detected in a tab state's constructor. Both wrote ChatPreferences
past the draft at a moment nobody could see. repairStoredSettings now does
both once, before any tab is built; the tab states only read.

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

* Ask before a profile switch or exit, and check fields before applying

A profile switch and the exit dropped pending settings edits silently. They
now go through SettingsWindow.settleThen: with edits pending, the window is
raised and asks Apply / Discard / Back; Apply applies them to the profile
still in force, Back cancels the switch or the exit. The question is Compose
state and the continuation is posted from the dialog's callback, so the
event thread never waits. Without pending edits both go on at once.

Fields are checked before anything is applied: a field still showing text
that does not parse, or that its rule refuses, refuses the apply and is
named in the notice, so the valid edits stay pending and Discard is still
possible. This covers number fields and the guarded external-interface
fields, which used to let Apply go ahead with the last valid value.

Every write and effect of an apply now runs on its own; failures are
collected and reported, a failed write stays pending and skips its effect,
and Save still writes the file when only an effect failed.

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

* Check every drafted setting with a real draft, and rename write-through tests

DraftedSettingsTableTest walks every drafted setter of every tab with a
real SettingsDraft: unchanged after the edit, changed after apply,
unchanged after discard. The tab tests run with a draft that applies at
once and cannot tell a drafted setting from one that bypasses the draft.

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

* Document the question on switch and exit, the field checks and the repairs

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

* Decide the pending-settings question on the event thread, never trap

The question is now a SettingsQuestion decided in settle() on the calling
event thread instead of in the settings window's next frame, so an exit
from the main window does not depend on frames of a minimised settings
window; the window is raised and restored before the dialog is needed.

While a question is open, further requests are ignored: an X no longer
replaces the continuation of an exit or switch with a plain close. Each
continuation of settleThen runs at most once.

A field holding text it refuses no longer traps the operator: the X, the
switch and the exit show the question anyway, with the reason, Apply
disabled and Discard and Back available.

The apply-failure notice keeps the technical detail out of the sentence,
in parentheses after a translated one.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:32:31 +02:00
Rsclub2_2andClaude Opus 5.5 c64e9a9014 Open the release notes with the message of an annotated tag (#130)
The release body was built from direct commits on main and GitHub's
generated PR list only; whatever was written into the tag was lost. An
annotated tag's subject and body now open the notes, ahead of "What's
Changed", and so reach the in-app update feed too. The tag object is
fetched explicitly because actions/checkout can leave the triggering tag
as a lightweight ref; contents:subject/body leave the SSH signature out.
Lightweight tags contribute nothing, as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:22:57 +02:00
Rsclub2_2andClaude Opus 5.5 88d10259d4 Retry stapler when Apple's ticket service drops the connection (#131)
Three nightly macOS DMG builds on the Intel runner failed in a row after a
successful notarization: `xcrun stapler validate` lost its connection to
api.apple-cloudkit.com ("The network connection was lost.", exit 68). The
ticket was fine; the runner's connection to Apple was not. staple and
validate now get up to five attempts with growing pauses (20, 40, 80,
160 s) before the build fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:19:26 +02:00
Rsclub2_2andClaude Opus 5.5 c9b838e946 Translate the remaining hard-coded interface texts (#129)
* Translate the interface texts that stage 9 left in English

The GUI tab checkboxes, the file chooser and dropdown placeholders, the
profile dialog's Apply, the settings notice's OK, the chat clear button,
the selected-station map button and "all", six column titles, the monitor
window's title and heading, and the refusal and hint texts of the profiles,
beacon and notification tab states now come from the translation files.

Column titles go through CurrentStrings in a lambda like the existing ones,
so the id stays untranslated and the stored layout keeps its keys. The tab
states read CurrentStrings as StatusIndicatorState already does. The
English values are unchanged, so the screen tests still find every control
by its old label; a new test switches to German and checks one sample per
path: a composed label, a column title and a tab-state refusal.

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

* Translate the settings notices, profile labels and alert button too

Wires the settings.spot* and settings.write* keys that existed but were
never used, translates the settings window title and its notice titles,
the profile tab's "unknown", "Copy of" and database-mode labels, and lets
the shared alert use alert.ok instead of a literal. The German for
messages.qsoOfTheOther becomes "Fremd-QSOs", the word the menu already uses.

"Copy of" is only the suggested display name; the profile id is derived
from the entered name once at creation and stored, so a later language
switch does not touch it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:11:39 +02:00
Rsclub2_2andClaude Opus 5.5 a9d8c013ef Settings tabs no longer commit on open; lock login fields while connected (#128)
* Commit settings fields only on a real edit, not when a tab opens

Form.committed treated every onFocusChanged callback with "not focused"
as the operator leaving the field. Compose also fires that callback once
when the modifier is attached, so drawing a tab committed each of its
committed fields: opening Colours wrote all six roles as overrides of
themselves (shown as "changed", and after "Save settings" they hid the
profile's stylesheet file for good), opening Beacon validated both
templates and restarted the beacon timer, opening Notification
re-committed the port and the spotter callsign.

The field now commits only on a transition from focused to unfocused,
or on Enter, and only when the draft differs from the stored value.
A real edit still commits on leaving the field or on Enter.

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

* Lock the Station tab's login fields while a session exists

StationTabState.loginFieldsEnabled was a plain field that nothing ever
wrote and that Compose could not observe, so callsign, names, locator,
password and both chat categories stayed editable while connected,
although the running session does not pick up a change.

The flag is now derived from the ON4KST connection state, read through
a lambda over the main window's Compose state (MainWindowSurroundings),
so the tab follows the session while it is open: the fields are locked
for every state but DISCONNECTED and unlocked again after disconnecting.

The screen-test helper fieldBeside now matches text fields by their
editable text, because a disabled field offers no set-text action.

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

* Drop changelog entries for defects that never shipped

The committed-field and login-field defects exist only in the Compose
settings tabs, which are in neither v1.44.0 nor beta-v1.50.0-beta-001,
so there is nothing for an operator to read about.

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

* Compare committed fields with their last commit, not a stale value

Form.committed skipped a commit when the draft equalled `stored`, the
value the caller passed at its last composition. Most callers read it
from plain preference getters, so nothing recomposes them after a
commit: after Enter, `stored` still named the old value, and leaving the
field committed the same entry again, restarting the beacon timer twice
for one edit. The field now keeps the value it last committed in its own
holder, compares the draft with that, and falls back to it on a refusal.

The comparison is pluggable. The Colours tab compares the way
PaletteOverrides normalises a colour (trimmed, case ignored), so retyping
the shown colour in lower case no longer becomes an override of itself.

Behaviour kept on purpose: Enter on an unchanged value commits nothing,
so it neither restarts the beacon timer nor reports a stored value the
rule refuses. A field removed while it holds the focus (tab switched,
window closed) still commits a typed entry once, because Compose reports
the cleared focus as a focus loss; a test now pins this, as it was before.

Also: a test drives the Station tab's lock from ConnectionStateFeed
through MainWindowSurroundings, the chain the application wires, and the
Javadoc of openSettingsWindow is back above its own method.

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

* Judge a committed field against the live stored value

be6862c8 kept the value a field last committed in a holder keyed on
`stored`. That holder could outlive the truth: the field commits 9 over
5, nothing recomposes the tab, a profile switch puts 5 back and the tab
recomposes with 5 -- the same key -- so the holder still said 9. The
field showed 9, the preference held 5, and leaving the field committed
nothing, where main would have committed 9.

Form.committed now takes `current`, the value stored right now, read
when the field commits; the draft is judged against it and falls back to
it on a refusal. The holder is gone. Right after a commit the getter
already returns the new value, so Enter followed by leaving the field
still commits once. Every caller passes its preference getter: both
beacon templates, the beacon interval, the DX cluster port, the spotter
callsign and the colour roles.

`isUnchanged` must agree with what commit stores, which the KDoc now
says. The interval and the port compare as the number stored, the
spotter callsign ignoring case and surrounding blanks, the colours as
PaletteOverrides normalises them.

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

* Require the live value in committed fields and compare the spotter call as stored

Review follow-up. `current` has no default any more, so a future caller
cannot silently bring back the stale comparison. The spotter call is
compared after the same uppercase(ROOT) the commit applies, so a typed
"X" against a stored "x" still commits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:08:31 +02:00
Rsclub2_2andClaude Opus 5.5 81b16ad4c5 Keep tests out of the user profile; small socket fixes (#127)
* Keep tests out of the user profile

The no-argument ChatPreferences constructor seeds preferences.xml in
~/.praktiKST when it is missing, so 21 older test files could create a
profile file for whoever ran the build. They now use ChatPreferences(Path)
with a @TempDir. None of them reads the file: their assertions are on the
field initialisers, which both constructors share, so no assertion changes
meaning. ChatPreferencesStationMapVisibilityTest passes its file to the
constructor instead of redirecting it with the setter afterwards.

The two opt-in ON4KST practice tests keep the no-argument constructor: they
deliberately read the operator's real profile for live credentials.

As a net, the test tasks run with user.home pointed at build/test-user-home,
emptied before each run, and fail if a preferences.xml appears there. This
also keeps the sound files, the map tile cache and the Skiko native library
that tests unpacked into the real home directory out of it.

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

* Make DXClusterThreadPooledServer.serverSocket volatile

run() assigns the server socket on the server thread, and stop() closes it
from another thread. closeServerSocket() is synchronized, but the write in
run() is not, so nothing guaranteed that stop() saw the socket run() had
opened; it could find null and leave the socket listening.

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

* Drop the ineffective SO_REUSEADDR from the Win-Test sked socket

WinTestSkedSender.sendUdp() set reuse address on a socket the constructor
had already bound, where it has no effect, so it was never in effect in any
shipped version. The socket binds an ephemeral port and only sends, which
gains nothing from it, and enabling it for real would change UDP multi-bind
semantics on Windows. Removing the call keeps the behaviour exactly as
shipped and stops the code from claiming an option it does not have.

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

* Check the test user.home in a finalizer task

The stray-preferences check ran as a doLast of the test task, which Gradle
skips once a test fails, so a failing run could hide the file. It is now a
separate task, checkTestUserHome, that every test task is finalized by;
finalizers run when the test task fails too. It captures only a File, so it
stays configuration-cache safe.

Each test task now gets its own scratch home, build/test-user-home/<task
name>, so a second test task cannot wipe another's. Emptying it checks the
result of deleteRecursively(), which reports failure instead of throwing,
and stops the task, so a leftover file is never blamed on the current run.

The check stays limited to preferences.xml. Other files land in the scratch
.praktiKST by design, such as the sound files PlayAudioUtils seeds from the
bundled resources; absorbing those is the redirection's job, and a wider
check would need an exclusion list that grows with every seeded file.

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

* Keep generateStrings configuration-cache safe

The argument provider of generateStrings referenced the script-level
generatedI18nDirectory, which captures the build script object, so storing
the configuration cache failed for every build that included app-desktop.
The task now works on a local copy of the provider.

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

* Declare the test user.home as a test task output

Test tasks are cacheable, and a cache hit skips doFirst, so the scratch home
was neither emptied nor rewritten and checkTestUserHome judged whatever an
earlier run had left there: a false alarm after a violating run, or a hidden
violation when a passing result for violating inputs came from the cache.
The scratch home is now a declared output, restored and replaced together
with the cached result, so the check always sees the files belonging to the
inputs. The doFirst wipe stays for real executions.

Skiko unpacked its native library, about 30 MB, into the scratch home, which
would now land in every app-desktop cache entry. skiko.data.path points it
at build/skiko-data instead, outside the output and kept between runs.

user.home and skiko.data.path are passed through an argument provider: as
system properties they would be task inputs, and their absolute paths would
make the cache key differ between checkouts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:48:33 +02:00
Rsclub2_2andClaude Opus 5.5 2898c530d9 Restore the turn-antenna button and send the PSTRotator azimuth with one decimal (#126)
* Send the PSTRotator azimuth with one decimal instead of truncating it

setAzimuth cast the azimuth to int, so 145.7 degrees turned the antenna
to 145. It is now rounded to one decimal and formatted with Locale.ROOT,
so a German default locale cannot turn the dot into a comma; a value that
rounds up to 360.0 is sent as 0.0. The loopback test pinned the old
truncation and now expects the decimal.

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

* Bring back the turn-antenna button lost in the Compose migration

The JavaFX station panel had "Turn ant1 to <call>", the only caller of
ChatController.rotateTo; removing JavaFX (fe4215bc) dropped it, so the
antenna could no longer be turned from KST4Contest at all. The Compose
panel gets the button again, enabled when a rotator callback is wired and
the selected station has a finite heading.

Review follow-ups for the decimal azimuth: formatAzimuth refuses NaN and
infinite values and wraps any finite value into [0, 360). The "reached"
check before the SPID retry uses a 1.0 degree tolerance, because the
target now carries a decimal while many controllers report whole degrees.

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

* Keep rotateTo's Javadoc on the method and word the German label as a bearing

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:47:53 +02:00
Rsclub2_2andClaude Opus 5.5 e1e6fe7297 Make MessageBusManagementThread testable, first slice (#105) (#123)
* test: restore the frequency-context and Band.fromPrefix tests (#105)

The test file MessageBusManagementThreadFrequencyContextTest sat under
src/main, fully commented out, and was deleted with #62 without ever
having run. Its two concerns come back as real tests: the bare
three-digit context rule of hasExplicitBareFrequencyContext in the
controller test package, and Band.fromPrefix as BandFromPrefixTest in
the model package. The original expectations still match the current
behaviour; a few boundary cases (context window, invalid indices,
non-exact prefixes) are added.

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

* test: characterise the autoanswer path of MessageBusManagementThread (#105)

Runs real CH frames through a started thread (receive queue plus poison
pill, real transmit queue, mocked controller collaborators, a real
ChatPreferences in a temporary directory) and pins the current
behaviour of the private-message branch before it is refactored:

- a QRG question addressed to the login callsign yields exactly one
  reply with the fixed marker and the QRG of the incoming category;
- a second question from the same station and category within the
  cooldown yields none, while another station or category is answered;
- a frame carrying the autoanswer marker, a public frame, a frame to
  another station and a disabled setting yield none;
- an empty QRG and a blank generic text are rejected before the
  cooldown is recorded, so a later valid reply still goes out;
- //VER, the generic reply and its precedence below the QRG reply.

One oddity is pinned, not fixed: a lower-case login callsign defeats
the "directed to me" comparison because the receiver callsign is
upper-cased by ChatMember.setCallSign but compared with equals.

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

* refactor: extract the autoanswer decisions into pure functions (#105)

The QRG-question texts become a constant set; matchesQrgQuestion,
isAutoMessage and buildAutoAnswerMessageText are static package-private
functions with explicit parameters instead of reaching into the
preferences, and the cooldown reads an injectable millisecond clock
(default System::currentTimeMillis) through a new constructor overload.
Behaviour is unchanged: the characterisation test from the previous
commit passes untouched, the new decision and cooldown tests cover the
extracted functions without mocks.

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

* refactor: extract frequency detection into a pure function (#105)

smartFrequencyExtraction now copies the sender's band timestamps, reads
the fallback prefix and the injectable clock, and hands the text to the
static package-private detectFrequencies, which returns the detections
in message order as DetectedChatFrequency records. The thread still
stores each one through applyDetectedFrequencyToActiveMembers and logs
it as before.

Behaviour is unchanged. In production the stored detection lands in
the same map the next match of the message reads, so detectFrequencies
feeds every detection back as context with the current time; a test
pins that "10368.100 then .150" yields 10368.150. The unreachable
NumberFormatException catches around parseExplicitFrequency are gone,
since that parser already returns null instead of throwing. Also pinned:
a plain decimal between 50 and 54 (e.g. "qrb 52,3 km") is read as a
6 m frequency.

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

* refactor: extract the directional-opportunity and spot decisions (#105)

classifyDirectionalOpportunity returns MISSING_LOCATOR, IN_ANGLE_AND_RANGE
or OUT_OF_ANGLE_OR_RANGE from the locators, the maximum QRB and the beam
width, and replaces the identical locator check plus isInAngleAndRange
call in both the live (CH) and the history (CR) branch.
shouldPlayDirectionalTick, decideDxClusterSpot (NO_FREQUENCY,
ALREADY_WORKED, SPOT) and buildDxClusterSpotEntry take over the tick
condition and the automatic DX Cluster spot of the CH branch. All are
static package-private and covered without mocks.

Behaviour is unchanged. Pinned: the spot decision only checks that the
frequency text is non-blank, so a value outside every supported band is
spotted even for a station flagged as worked.

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

* refactor: remove dead code from MessageBusManagementThread (#105)

processRXMessage23000 belonged to the long-retired raw port-23000
interface and had no caller. With it go the two helpers only it
reached, checkIfItsUserListEntry and checkIfMessageInhibitsFrequency,
plus the caller-less checkIfItsUserListEntry23001 and
checkListForChatMemberIndexByCallSign, the patterns PTRN_USERLISTENTRY,
PTRN_QRG_CAT2 and PTRN_QRG_CAT3, the unused chatMemberTable field and
the serverReady flag with its never-called accessors. A grep across core
and app-desktop finds no remaining reference.

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

* refactor: keep the sender-category fallback in the QRG auto-answer warning (#105)

Review follow-up. The extracted buildAutoAnswerMessageText logged only
the message category, while the old warning used autoAnswerCooldownKey,
which falls back to the sender's category. The caller now passes the
same fallback, so the log line is unchanged from main.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 10:21:37 +02:00
Rsclub2_2andClaude Opus 5.5 4b10d96091 Compose screen tests with runComposeUiTest (#107) (#125)
* test: draw and click the in-window menu row (#107)

Pins what MainMenuModelTest cannot see: every item picked through the
drawn row runs its own action, the enabled states and the away and
window-toggle labels follow their state while the menu is down, and a
palette or language change reaches the row on screen.

Named regressions: fe4215bc (menu actions hung on deleted JavaFX
MenuItems) and 83e23f55 (unthemed Swing menu bar), both in main as
2a5c89b9.

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

* test: draw the station list with its real row key (#107)

The station row key moves from an inline Java lambda into
StationColumns.rowKey, unchanged in its text, so the list can be tested
with the key the application really uses.

The screen test pins three regressions on the drawn table: the same
callsign in two categories collided under one key and sorting dropped
the filters (both 21251377), and the AP column sorted by its text
(ab282162, #87). It also pins that rows replaced after the first draw
reach the screen and that the selection follows a station by key.

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

* test: draw the monitor window's live tables (#107)

MonitorContent becomes internal so its body can be drawn headless.

The screen test pins the defects the port shipped in this window
(cc8c9020, in main as 2a5c89b9): rows arriving while the window is
open, also after a redraw that was not about the rows, must reach both
tables, and two messages that read alike stay two rows.

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

* test: draw and click the station map window's body (#107)

StationMapWindow.show now hands its body to an internal Content
composable that takes the tile fetcher, the member roster and the map
state as parameters, with the old inline values as defaults, and the
map canvas carries a test tag.

The screen test pins the map selection fixed in 21251377: a marker
click and Reset view go through the central station selector, a click
keeps the category variant already selected and prepares "/cq CALL",
and neither changes the zoom. The header switches write the profile.
Tiles never arrive: an empty temporary cache and a failing source.

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

* test: draw the Station, Log synch, TRX synch and Airscout tabs (#107)

Each tab is composed headless and checked for its sections and key
controls, and editing a control through the drawn form must write
through to ChatPreferences and redraw. Refused entries stay on screen
as typed and leave the stored value alone. A small helper finds a
control by the label beside it, the way the operator finds it.

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

* test: draw the Message handling, GUI, Beacon and Notification tabs (#107)

Each tab is composed headless with its sections and key controls, and
edits through the drawn controls reach ChatPreferences and the
application callbacks: the auto answer for both categories, the
debug-to-file switch c8ce0e98 restored, the startup design and the
language, the beacon templates and interval, the cluster server, the
fallback band, the test spot and the monitored callsign dialog.

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

* test: draw the Shortcuts and Worked database tabs (#107)

The shortcut and snippet lists show what the profile holds, and an edit
or an added entry made in the drawn list reaches the roster and the
refresh hook the button row depends on (the hook that was called into
thin air before fe4215bc). The worked database lists its rows, re-reads
them on refresh, asks before a reset and leaves the lists alone when
the database reset fails.

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

* test: draw the Profiles and Colours tabs (#107)

Profiles: the default profile and every action are on screen, deleting
it is refused after the confirmation, a cancelled New dialog creates
nothing, and switching hands the selected profile to the application.
Only a temporary registry is touched.

Colours: the six roles show the resolved colour, a typed colour is
committed into the store, a mistyped one is refused and put back, the
shipped reset undoes a change, and the stylesheet export writes once
into a temporary profile directory and never over an existing file.

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

* refactor: give the map's path analysis a seam and tag the monitoring field (#107)

StationMapWindow.Content takes a MapPathAnalysis supplier. The default
looks the running application's reachability service up on every
selection, exactly as the inline chat-controller lookup did, and null
still means: no pan, no analysis. This lets a test run the zoom rule
on selection that cc8c9020 fixed.

The callsign field of the add-to-monitoring dialog in NotificationTab
carries a test tag, so a test types into it by name rather than by
position.

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

* test: isolate and harden the screen tests after review (#107)

- No screen test builds ChatPreferences() any more: that constructor
  resolves the real user profile and seeds preferences.xml there. Each
  class uses ChatPreferences(Path) under a @TempDir.
- Compose state changed from the test thread (roster refresh, language
  reset) goes through runOnIdle.
- Dialogs and dropdowns are waited for with waitUntil and found inside
  their overlay (isDialog / isPopup) instead of by tree order; the
  monitored-callsign field is found by its test tag.
- The menu test now asserts Disconnect when online and Connect when
  offline instead of skipping one.
- The map test runs the zoom-on-selection rule through the new seam,
  closes its tile fetcher after each test and keeps its cache in the
  @TempDir.
- StationColumns.rowKey is pinned directly: two categories, two keys.
- The menu colour is sampled further inside the row, and the station
  list click picks its row by position.

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

* test: prove the map's selection effect ran when no analysis is available (#107)

Review follow-up. The no-analysis test only asserted that the map stayed
put, which would also hold if the selection effect never ran; it now
counts the analysis lookup. The effect reads the lookup through
rememberUpdatedState, so a caller passing a new lambda is not served a
stale one.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:21:14 +02:00
Rsclub2_2andClaude Opus 5.5 2db71ee864 Loopback tests for the network threads (#106) (#124)
* test: pin the Win-Test sked frames over a loopback socket (#106)

WinTestSkedSender is pointed at the loopback address instead of a
broadcast address, and a socket on an ephemeral port receives the
LOCKSKED/ADDSKED/UNLOCKSKED sequence. The frames and their checksum
byte are written out literally, including the \260 degree escape and
the SSB fallback for an unexpected mode ID.

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

* test: cover the PSTRotator UDP client over loopback (#106)

A socket on an ephemeral loopback port plays PSTRotator. The tests pin
the poll queries, the <PST> command frames, the AZ:/EL:/MODE: replies
reaching the listener, an unparsable reply leaving the loop alive, and
the explicit-port constructor reporting its status after start() (#115).
The reply format is derived from the parser and the PSTRotator UDP
documentation, not from a capture.

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

* test: cover the AirScout UDP listener over loopback (#106)

ReadUDPbyAirScoutMessageThread gains a package-private constructor that
takes an already bound socket, as ReadUDPbyUCXMessageThread has; the
public constructor still binds the configured port in run(). The tests
pin applying an ASNEAREST response to every active variant of the
receiver, the case-sensitive server/client match with a UTF-8 client
name, the disabled listener, the poison pill, recovery from a malformed
response, and the date losing its space because the parser strips all
spaces.

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

* test: cover UCXLog/DXLog QSO packets over loopback (#106)

The UCX loopback test now sends contactinfo and contactreplace packets:
transport bytes before the XML are dropped, the QSO reaches the
controller with band, locator and source, the gross field is
registered, an unknown station is stored in the database and the UDP
backup file, a known one is only updated, and nothing happens while log
sync is disabled. A RadioInfo packet sent afterwards serves as an
ordering marker instead of a sleep.

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

* test: cover the local DX Cluster server over loopback (#106)

DXClusterThreadPooledServer gains a package-private constructor that
takes an already bound server socket; the public constructor still binds
the configured port in run(). Plain sockets on the loopback address act
as logging programs. The tests pin the listening status, the "login: "
prompt, one DXSpider-style line with BEL BEL CR LF per spot to every
client, the rejection of spots that cannot be formatted, and stop()
closing the clients and ending the server thread.

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

* fix: keep the UCXLog/DXLog listener alive on bad input (#106)

An unwritable or empty backup path left the FileWriter null, and the
following BufferedWriter threw a NullPointerException; a RadioInfo
without <Freq> or <Mode> threw one as well. Neither was caught, so the
listener thread ended for good. The backup line is now written with
try-with-resources and skipped with a log line when the file cannot be
opened, an incomplete RadioInfo element is skipped, and run() drops a
packet whose processing throws any RuntimeException, reports a
non-critical status and keeps receiving.

The loopback tests cover all three paths. The interrupt test now waits
for a processed marker packet instead of sleeping and no longer claims
more than it can observe, and the marker helper states its in-order
assumption for UDP on loopback.

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

* fix: let a second DX Cluster server run() bind again (#106)

The pre-bound-socket constructor made run() skip binding whenever a
server socket existed. A run() after the first one ended would then
accept on a closed socket. Bind whenever the socket is missing or
closed, which restores the behaviour of the public constructor.

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

* test: make the ignored AirScout responses observable (#106)

The tests for responses to another client or server and for the
disabled listener only checked that nothing happened before the poison
pill, which a listener that never read the datagrams would also pass.
Each now sends a valid sentinel afterwards and waits for it, and the
ignored responses name a different receiver, so a wrongly processed
copy is detectable; the disabled case also shows that the switch was
asked once per response. The date test is renamed and documented as a
pinned known defect, so a parser fix knows to flip it.

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

* test: wait for the PSTRotator listener thread after stop() (#106)

The client keeps its listener thread private, so the test finds it by
the name start() gives it and joins it after stop(), asserting that
exactly one such thread existed and that it ended.

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

* fix: keep the UCX listener alive when the status callback throws (#106)

Review follow-up. The safety net reported a dropped packet through the
status callback without guarding it, so a throwing callback still ended
the listener. The test for an unwritable backup file is renamed to what
it proves, and the changelog mentions that any unexpected error now
drops only the affected packet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:19:22 +02:00
Rsclub2_2andClaude Opus 5.5 4679c708bc Fix UDP listener defects (#115) (#122)
* fix: let ReadUDPbyUCXMessageThread own its DatagramSocket so interrupt() closes it (#115)

The field `socket` was a java.net.Socket that was never assigned; the real
DatagramSocket lived in a local variable of run(). interrupt() therefore
closed nothing and terminateConnection() would have thrown a
NullPointerException. The receive loop kept running in two-second timeout
steps until the user-disconnect flag was set.

The field is now the DatagramSocket, run() binds it when no pre-bound socket
was handed in, the loop ends on the interrupt flag or a closed socket, and
both interrupt() and terminateConnection() are null-safe. A package-private
constructor accepts a pre-bound socket so loopback tests can listen on an
ephemeral port; the public constructor used by ChatController is unchanged.

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

* fix: stop ReadUDPbyUCXMessageThread truncating datagrams after a short packet (#115)

A DatagramPacket keeps the length of the last datagram it received, so a
long RadioInfo packet following a short one was cut to the short one's
length and failed to parse. The length is reset to the buffer size before
every receive, the same way ReadUDPByWintestThread does it. The loopback
test sends a short and then a long packet on port 0 and checks that both
frequencies arrive.

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

* fix: give PstRotatorClient's port constructor a callback, decode AirScout as UTF-8 (#115)

The explicit-port constructor left the thread-status callback null, so
start() and the first received packet threw an NPE; the default
constructor now delegates to it. The AirScout listener decoded replies
with the platform charset although the inquirer sends UTF-8.
PROJECT_CONTEXT listed PSTRotator as TCP; the client speaks UDP.

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

* fix: decode UCX datagrams as UTF-8 and tighten the interrupt test (#115)

Review follow-up. UCXLog sends UTF-8 XML, but the listener decoded with
the platform charset before handing a String to the parser, so the XML
header's encoding was ignored. The port-in-use fallback now sets its
receive timeout before the first receive(). The interrupt test waits
1 s, below the 2 s receive timeout, so it fails if interrupt() stops
closing the socket; the QRG latches now count one value each.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 10:12:46 +02:00
166 changed files with 15930 additions and 2116 deletions

No files matched your search

+19 -1
View File
@@ -867,7 +867,9 @@ jobs:
fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section:
# pushed directly to main are added here as their own section, and the
# message of an annotated tag opens the body:
# <tag message> (annotated tags only; signature dropped)
# ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list)
@@ -892,6 +894,19 @@ jobs:
RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# actions/checkout can leave the triggering tag as a lightweight ref, so
# fetch the tag object itself before reading its message.
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
# A lightweight tag has no message of its own (its "message" would be the
# commit's); only an annotated tag contributes. contents:subject and
# contents:body leave out an SSH or GPG signature.
: > tag-message.md
if [ "$(git cat-file -t "refs/tags/${TAG}")" = "tag" ]; then
git for-each-ref "refs/tags/${TAG}" \
--format='%(contents:subject)%0a%0a%(contents:body)' > tag-message.md
fi
echo "Tag message:"; cat tag-message.md
# --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well.
@@ -913,6 +928,7 @@ jobs:
import re
commits = open("direct-commits.md", encoding="utf-8").read().strip()
tag_message = open("tag-message.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally
@@ -932,6 +948,8 @@ jobs:
rest.append(line)
parts = []
if tag_message:
parts.append(tag_message)
if commits or prs:
parts.append("## What's Changed")
if commits:
+5 -2
View File
@@ -66,11 +66,14 @@ val generateStrings by tasks.registering(JavaExec::class) {
mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n")
// A local copy: the provider below must not capture the script-level val, which
// drags the build script object into the configuration cache and fails to serialize.
val output = generatedI18nDirectory
inputs.dir(input)
outputs.dir(generatedI18nDirectory)
outputs.dir(output)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath)
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
})
}
@@ -1,5 +1,5 @@
# German. Held complete by a test, because this is a shipped language.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
@@ -26,6 +26,7 @@ gui.language=Sprache
gui.language.system=Systemsprache
gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.windows=Fenster
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
logSynch.fileLabel=Zu überwachende Logdatei:
@@ -88,6 +89,13 @@ notification.soundsSection=Töne
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
notification.spotterCallsign=Spotter-Rufzeichen
settings.applyAndClose=Übernehmen/Schließen
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
settings.pendingApply=Übernehmen
settings.pendingBack=Zurück zu den Einstellungen
settings.pendingDiscard=Verwerfen
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
settings.pendingTitle=Nicht übernommene Änderungen
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
settings.save=Einstellungen speichern
shortcuts.addButton=Kurztaste hinzufügen
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
@@ -99,6 +107,7 @@ station.callsign=Rufzeichen
station.chatSection=Chat-Kategorie
station.defaultsSection=Vorgaben
station.demDirectory=DEM-Stammverzeichnis
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
station.locator=Locator
station.mainCategory=Hauptkategorie
station.maxQrb=Maximaler QRB
@@ -106,6 +115,7 @@ station.nameMain=Name, Hauptkategorie
station.nameSecond=Name, zweite Kategorie
station.ownAntennaGain=Eigener Antennengewinn, dBi
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
station.ownSection=Eigene Station
station.ownTxPower=Eigene Sendeleistung, Watt
station.password=Passwort
@@ -115,21 +125,31 @@ station.rotatorEnabled=Rotorabgleich aktiv
station.rotatorHost=Rotor-Host
station.rotatorPort=Rotor-Port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotor-2-Abgleich aktiv
station.rotator2Host=Rotor-2-Host
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Zweite Kategorie
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
station.serverHost=Servername
station.serverPort=Serverport
station.serverSection=ON4KST-Server
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
station.targetTxPower=Sendeleistung der Gegenstation, Watt
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
trxSynch.winTestSection=Win-Test-TRX-Abgleich
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
worked.empty=Die Datenbank enthält keine Rufzeichen.
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
@@ -228,6 +248,7 @@ column.lastQrgTx=Letzte QRG TX
filter.find=Suchen...
filter.reset=Filter zurücksetzen
filter.showOnlyQrb=Nur QRB [km] <=
filter.showOnlyQrbAbove=Nur QRB [km] >
filter.showOnlyQtf=Nur QTF:
filter.hideWorked=Gearbeitete ausblenden:
filter.inactiveStations=Inaktive Stationen
@@ -253,6 +274,8 @@ selected.lookUpAndShow=Nachschlagen und zeigen
selected.lookupQrz=Auf qrz.com nachschlagen
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
selected.showPathInAs=Strecke in AS zeigen
selected.turnAntenna=Antenne 1 Richtung {0} drehen
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
candidates.title=Stationen mit höchster Priorität
candidates.none=Noch keine Kandidaten.
@@ -322,6 +345,8 @@ title.connecting=Verbindung zu ON4KST wird aufgebaut…
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
title.disconnected=NICHT VERBUNDEN!
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
title.bothCategories={0} und {1}
status.noConnection=Keine ON4KST-Verbindung
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
status.linkConnected=🟢 LINK: Verbunden
@@ -361,10 +386,12 @@ tooltip.unknownGrid=unbekanntes Feld
tooltip.grid=Feld {0}
messages.public=Öffentliche Nachrichten
messages.dxCluster=DXCluster-Nachrichten
messages.qsoOfTheOther=QSO der Gegenstation
messages.qsoOfTheOther=Fremd-QSOs
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
list.moveDown=Auswahl nach unten
list.moveUp=Auswahl nach oben
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
@@ -378,3 +405,50 @@ contrast.textOnWindowSurface=Text auf der Fensterfläche
contrast.textInsideField=Text im Feld
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
contrast.textOnMenuStrip=Text auf der Menüzeile
gui.startInEveningDesign=Im Abenddesign starten
gui.filterPublic=Öffentliche Nachrichten
gui.filterPmToMe=Privatnachrichten an mich
gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
form.choose=Wählen...
form.notSelected=nicht gewählt
dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%]
column.score=Wertung
monitor.title=Cluster & Fremd-QSOs
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
settings.windowTitle=Client-Einstellungen ändern
settings.noticeInfo=Info
settings.noticeProblem=Einstellungen
profiles.unknown=unbekannt
profiles.copyOf=Kopie von {0}
profiles.sharedDatabase=gemeinsame Stationsdatenbank
profiles.ownDatabase=eigene Datenbank
@@ -8,7 +8,7 @@
#
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
# none of it is here, and three tests keep it that way.
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
airscout.automaticFrequency=Select AirScout frequency automatically per station
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
airscout.clientIdentifier=KST4Contest client identifier [KST]:
@@ -35,6 +35,7 @@ gui.language=Language
gui.language.system=System language
gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup
gui.windows=Windows
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
logSynch.fileLabel=Log file to be monitored:
@@ -97,6 +98,13 @@ notification.soundsSection=Sounds
notification.speakCallsign=Speak the callsign on a received private message
notification.spotterCallsign=Spotter callsign
settings.applyAndClose=Apply/Close prefs
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
settings.pendingApply=Apply
settings.pendingBack=Back to the settings
settings.pendingDiscard=Discard
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
settings.pendingTitle=Changes not applied
settings.invalidEntry={0}: "{1}" is not a valid entry.
settings.save=Save settings
shortcuts.addButton=Add shortcut
shortcuts.addSnippet=Add new snippet
@@ -108,6 +116,7 @@ station.callsign=Callsign
station.chatSection=Chat category
station.defaultsSection=Defaults
station.demDirectory=DEM root directory
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
station.locator=Locator
station.mainCategory=Main category
station.maxQrb=Maximum QRB
@@ -115,6 +124,7 @@ station.nameMain=Name, main category
station.nameSecond=Name, second category
station.ownAntennaGain=Own antenna gain, dBi
station.ownAntennaHeight=Own antenna height, metres
station.ownHeightAsl=Own ground height ASL, metres
station.ownSection=Own station
station.ownTxPower=Own TX power, watts
station.password=Password
@@ -124,21 +134,31 @@ station.rotatorEnabled=Rotator sync enabled
station.rotatorHost=Rotator host
station.rotatorPort=Rotator port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotator 2 sync enabled
station.rotator2Host=Rotator 2 host
station.rotator2Port=Rotator 2 port (answers on port + 1)
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Second category
station.secondChatEnabled=2nd chat: log in to a second category
station.serverHost=Server host name
station.serverPort=Server port
station.serverSection=ON4KST server
station.targetAntennaGain=Target antenna gain, dBi
station.targetHighestPoint=Target station on the highest point of its locator square
station.targetTxPower=Target TX power, watts
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
trxSynch.hintSection=What an enabled source does
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
trxSynch.ucxSection=Receive UCXLog TRX info
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
trxSynch.winTestSection=Win-Test TRX sync
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
worked.empty=The worked database holds no callsigns.
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
worked.lastResetAffected=Last reset affected {0} rows.
@@ -237,6 +257,7 @@ column.lastQrgTx=Last QRG TX
filter.find=Find...
filter.reset=Reset filters
filter.showOnlyQrb=Show only QRB [km] <=
filter.showOnlyQrbAbove=Show only QRB [km] >
filter.showOnlyQtf=Show only QTF:
filter.hideWorked=Hide worked:
filter.inactiveStations=Inactive stations
@@ -262,6 +283,8 @@ selected.lookUpAndShow=Look up and show
selected.lookupQrz=Lookup on qrz.com
selected.lookupQrzcq=Lookup on qrzcq.com
selected.showPathInAs=Show path in AS
selected.turnAntenna=Turn ant1 to {0}
selected.turnAntenna2=Turn ant2 to {0}
selected.lastActivity=Last activity: {0} ({1} min ago)
candidates.title=Top priority candidates
candidates.none=No candidates yet.
@@ -331,6 +354,8 @@ title.connecting=Connecting to ON4KST…
title.authenticating=Connected – authenticating with ON4KST…
title.synchronizing=Connected – synchronizing ON4KST chat data…
title.disconnected=DISCONNECTED!
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
title.bothCategories={0} and {1}
status.noConnection=No ON4KST connection
status.linkTooltip=ON4KST link: {0}\n{1}
status.linkConnected=🟢 LINK: Connected
@@ -374,6 +399,8 @@ messages.qsoOfTheOther=QSO of the other
chat.ownQrgTooltip=Your own frequency in the main category
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
list.moveDown=Move selected down
list.moveUp=Move selected up
list.clearToRemove=Clear the text of an entry to remove it.
@@ -387,3 +414,50 @@ contrast.textOnWindowSurface=Text on the window surface
contrast.textInsideField=Text inside a field
contrast.accentOnWindowSurface=Accent on the window surface
contrast.textOnMenuStrip=Text on the menu strip
gui.startInEveningDesign=Start in the evening design
gui.filterPublic=Public messages
gui.filterPmToMe=Private messages to me
gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
form.choose=Choose...
form.notSelected=not selected
dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
column.category=Category
column.sender=Sender
column.receiver=Receiver
column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%]
column.score=Score
monitor.title=Cluster & QSO of the other
profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated.
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
profiles.deleteFailed=The profile could not be deleted.
profiles.deletionFolder=The following folder is removed permanently:\n{0}
profiles.deletionKeepsShared=The common station worked stations are not touched.
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
notification.alreadyMonitored=This base callsign is already in the monitoring list.
settings.windowTitle=Change Client Settings
settings.noticeInfo=Info
settings.noticeProblem=Settings
profiles.unknown=unknown
profiles.copyOf=Copy of {0}
profiles.sharedDatabase=common station database
profiles.ownDatabase=own database
@@ -1,4 +1,5 @@
package kst4contest.view;
import kst4contest.view.compose.SettingsDraft;
import kst4contest.view.compose.SettingsHost;
import kst4contest.view.compose.SettingsNotices;
import kst4contest.view.compose.SettingsTabsKt;
@@ -1985,11 +1986,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
if (targetProfile == null) {
return;
}
ApplicationRuntimeLauncher.switchProfile(targetProfile);
/*
* Settings edits not applied yet are asked about first: applied, they land in the
* profile being left, which is the one they were made in; "back" cancels the switch.
* Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(() -> {
if (confirmOperatorProfileSwitch(targetProfile)) {
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
});
}
/**
@@ -2110,6 +2120,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
/**
* Hands the window hosts the active profile's stored window positions. Set on every
* startup, so a profile switch replaces the previous profile's positions. The opt-in is
* read on each call, so applying the setting takes effect without a restart.
*/
private void installWindowPositionMemory(ChatPreferences prefs) {
kst4contest.view.compose.WindowPlacementOnScreen.setMemory(
new kst4contest.view.compose.PreferencesWindowPositionMemory(prefs, this::requestLayoutSave));
}
private void requestLayoutSave() {
if (layoutAutosave != null) {
layoutAutosave.requestSave();
@@ -2153,6 +2173,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave(
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
installWindowPositionMemory(chatcontroller.getChatPreferences());
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
chatcontroller.getChatPreferences().setGUI_darkModeActive(
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
@@ -2421,6 +2442,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private void closeWindowEvent() {
System.out.println("Window close request ...");
/*
* Settings edits not applied yet are asked about before the quit question; "back"
* cancels the exit. Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(this::confirmAndExit);
}
/** Asks whether to quit, and quits on yes. */
private void confirmAndExit() {
/*
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
@@ -2609,10 +2639,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
Double qrb = member.getQrb();
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
}
if (!state.acceptsQrb(member.getQrb())) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
Double qtf = member.getQTFdirection();
@@ -3034,8 +3061,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
member -> parseTableDouble(formatPriorityScore(member)),
(member, band, worked) -> formatBandCellStatus(member, band, worked),
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|"
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
"compose-stations",
widths);
@@ -3073,6 +3099,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
MainMenuState menu = new MainMenuState();
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
surroundings.setSo2rEnabled(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled());
surroundings.setSecondAntennaOffered(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled()
&& chatcontroller.getChatPreferences().isStn_pstRotator2Enabled());
SelectedStationState selectedStation = new SelectedStationState(
member -> {
@@ -3136,7 +3165,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (member == null) return null;
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
return resolveDefaultSkedBand(member, enabledBands);
});
},
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.FIRST, azimuth);
return kotlin.Unit.INSTANCE;
},
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
return kotlin.Unit.INSTANCE;
},
surroundings::getSecondAntennaOffered);
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
@@ -3727,6 +3765,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/**
* The session state the settings window shows, read from the main window's Compose state
* so the Station tab's login fields follow it while the window is open. Before that state
* exists the controller's value is the best there is.
*/
private On4KstConnectionState settingsConnectionState() {
if (composeMainWindowState != null) {
return composeMainWindowState.getSurroundings().getConnectionState();
}
On4KstConnectionState current = chatcontroller.getOn4KstConnectionState();
return current == null ? On4KstConnectionState.DISCONNECTED : current;
}
/**
* Opens the settings window, or does nothing when it is already open.
*
@@ -3735,9 +3786,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void openSettingsWindow() {
/*
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
* asks about it when closed with edits pending.
*/
SettingsDraft draft = new SettingsDraft();
SettingsWindow.show(
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore),
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
this::settingsConnectionState, draft),
settingsNotices,
draft,
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore,
@@ -3750,7 +3809,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave();
return kotlin.Unit.INSTANCE;
},
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
}
/*
@@ -3822,10 +3881,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getMessageTXBus().add(sendMe);
}
/** Options/Windows -> open or close the settings window. */
/**
* Options/Windows -> open or close the settings window. Closing goes the way of the
* window's own X, so edits not applied yet are asked about rather than dropped.
*/
void menuActionToggleSettingsWindow() {
if (SettingsWindow.isOpen()) {
SettingsWindow.close();
SettingsWindow.requestClose();
} else {
openSettingsWindow();
}
@@ -3939,6 +4001,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
@Override
public void refreshSo2rState() {
runOnUi(() -> {
if (composeMainWindowState == null) {
return;
}
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
});
}
/**
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
*
@@ -158,6 +158,8 @@ fun ChatInputRow(
ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>,
antennaQtf2: ObservedValue<Double>,
so2rEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@@ -181,7 +183,7 @@ fun ChatInputRow(
)
Form.button(text = "TX", onClick = { state.send() })
Form.button(text = "clear", onClick = { state.clear() })
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
VerticalRule()
@@ -195,13 +197,26 @@ fun ChatInputRow(
InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip,
tooltip = if (so2rEnabled) {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
} else {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
},
)
InlineCommittedField(
stored = antennaQtf.value?.toString().orEmpty(),
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
)
if (so2rEnabled) {
// Display only: rotator 2 writes it, the operator does not.
InlineCommittedField(
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
onCommit = { },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
editable = false,
)
}
}
}
@@ -218,6 +233,7 @@ private fun InlineCommittedField(
stored: String,
onCommit: (String) -> Unit,
tooltip: String,
editable: Boolean = true,
) {
var draft by remember(stored) { mutableStateOf(stored) }
@@ -225,6 +241,7 @@ private fun InlineCommittedField(
CompactTextField(
value = draft,
onValueChange = { draft = it },
enabled = editable,
modifier = Modifier
.width(QRG_FIELD_WIDTH)
.onFocusChanged { focus ->
@@ -254,7 +254,7 @@ object ComposeAlert {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true
@@ -274,7 +274,7 @@ object ComposeAlert {
AlertContent(
header = header,
body = body,
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
@@ -322,7 +322,7 @@ object ComposeAlert {
)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
* window and its Skia layer alive behind the operator's back — one per dismissal,
@@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue
import java.awt.Frame
import java.awt.Point
import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
/**
* A Compose window that lives beside the JavaFX application.
@@ -45,10 +54,20 @@ import java.util.concurrent.atomic.AtomicBoolean
* - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open.
*
* Every window but the main one also opens on the screen the operator is working on
* (issue #59): centred on the main window's screen, or where the operator moved it to
* earlier in the session — see [WindowPlacement] for the rule.
*
* @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close
* window will not close. It is also the key of the window's stored position, so
* renaming it forgets where the operator put the window.
* @param isMainWindow true for the main window only. It keeps the platform's placement
* and publishes its frame, which is what every other window is placed against.
*/
class ComposeWindowHost(private val threadName: String) {
class ComposeWindowHost(
private val threadName: String,
private val isMainWindow: Boolean = false,
) {
private val open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false)
@@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) {
@Volatile
private var frame: ComposeWindow? = null
/**
* Where the operator moved this window to, for this session. Not persisted here: a
* stored position outlives the monitor layout it was made on, which is the very problem
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
* singletons, so it survives closing and reopening the window and a profile switch.
*/
@Volatile
private var movedTo: Point? = null
/** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean
get() = open.get()
@@ -127,7 +156,7 @@ class ComposeWindowHost(private val threadName: String) {
* [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`.
*/
private fun raise() {
fun raise() {
val frame = this.frame ?: return
EventQueue.invokeLater {
@@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) {
return@application
}
/*
* Decided once per opening, on the event thread this composition runs
* on — which is where the screens and the main window may be read.
* Null keeps the platform's placement: always for the main window, and
* for any window that opens before the main window exists.
*/
val placed = remember {
val width = widthDp.roundToInt()
val height = heightDp.roundToInt()
when {
/*
* The main window only ever uses a stored position; without
* one the platform places it, as before.
*/
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
/* This session's move is newer than anything stored. */
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
else -> WindowPlacementOnScreen.restore(threadName, width, height)
?: WindowPlacementOnScreen.place(null, width, height)
}
}
val windowState = rememberWindowState(
width = widthDp.dp,
height = heightDp.dp,
position = placed
?.let { WindowPosition(it.x.dp, it.y.dp) }
?: WindowPosition.PlatformDefault,
width = placed?.width?.dp ?: widthDp.dp,
height = placed?.height?.dp ?: heightDp.dp,
)
/*
@@ -227,11 +281,103 @@ class ComposeWindowHost(private val threadName: String) {
* comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again.
*/
/*
* The last size and position this host cut the window down to. Neither
* is the operator's choice: the size must not be written back as the
* stored size, and the position must not count as a move.
*/
val fit = remember { FitRecord() }
if (onResized != null) {
LaunchedEffect(windowState) {
snapshotFlow { windowState.size }.collect { size ->
if (size.width.isSpecified && size.height.isSpecified) {
onResized(size.width.value, size.height.value)
/*
* A secondary window skips the opening size: it may be the
* screen-clamped one, and writing that back would shrink the
* stored size for good because the window once opened on a
* smaller screen. The main window keeps writing it, as before,
* unless it was opened at a stored position, where the size may
* be clamped the same way. A size this host fitted the window to
* is skipped for the same reason.
*/
snapshotFlow { windowState.size }
.drop(if (isMainWindow && placed == null) 0 else 1)
.collect { size ->
if (size.width.isSpecified && size.height.isSpecified
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
) {
onResized(size.width.value, size.height.value)
}
}
}
}
LaunchedEffect(windowState) {
/*
* One tracker per opening; the rule is in MoveTracker. An operator
* move is remembered for the session (secondary windows) and handed
* to the stored positions, which keep it only when the operator
* opted in. Only moves are stored, never the window manager's
* nudges, so a restored window cannot drift a little on every start.
*/
var tracker = MoveTracker(placed?.location)
snapshotFlow { windowState.position }.collect { position ->
if (position is WindowPosition.Absolute
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
val now = Point(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
)
val size = windowState.size
/*
* The host's own fit is a new anchor, not a move; the window
* manager may still nudge it within the tolerance.
*/
if (fit.takePosition(now)) {
tracker = MoveTracker(now)
return@collect
}
tracker.observe(now)?.let { moved ->
if (!isMainWindow) {
movedTo = moved
}
WindowPlacementOnScreen.store(
threadName,
moved,
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
)
}
}
}
}
LaunchedEffect(windowState) {
/*
* A window moved onto a smaller screen -- a laptop panel, a 640x480
* field monitor -- is cut down to it, so no edge or button ends up out
* of reach. Only once it has rested: resizing in the middle of a drag
* would fight the window manager for the window.
*/
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
delay(FIT_SETTLE_MILLIS)
if (position is WindowPosition.Absolute
&& size.width.isSpecified && size.height.isSpecified
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
WindowPlacementOnScreen.fitted(
java.awt.Rectangle(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
size.width.value.roundToInt(),
size.height.value.roundToInt(),
),
)?.let { fitted ->
fit.record(fitted)
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
}
}
}
@@ -251,6 +397,9 @@ class ComposeWindowHost(private val threadName: String) {
*/
DisposableEffect(window) {
frame = window
if (isMainWindow) {
WindowPlacementOnScreen.mainWindow = window
}
/*
* The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace,
@@ -259,7 +408,12 @@ class ComposeWindowHost(private val threadName: String) {
* the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove.
*/
onDispose { frame = null }
onDispose {
frame = null
if (WindowPlacementOnScreen.mainWindow === window) {
WindowPlacementOnScreen.mainWindow = null
}
}
}
CompositionLocalProvider(
@@ -297,6 +451,126 @@ class ComposeWindowHost(private val threadName: String) {
}
}
/**
* How far a window must have gone from where it was opened to count as moved by the
* operator.
*
* Not zero, because window managers nudge a new window on their own — by the decoration,
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
* operator's choice. The window would then stop following the main window to another
* screen, and on X11 reopening it would drift by the decoration each time.
*/
internal const val MOVE_TOLERANCE_PX = 48
/** How long a window must rest after a move before it is cut down to a smaller screen. */
private const val FIT_SETTLE_MILLIS = 500L
/**
* What the host last cut a window down to, so the size and position it set itself are not
* taken for the operator's. Each is consumed by the first report that follows: a matching
* report is the fit echoing back, any other report is the operator's and clears it, so a
* stale record cannot swallow a later resize or move. Only touched on the window's event
* thread.
*/
internal class FitRecord {
private var pendingPosition: Point? = null
private var pendingWidth = 0
private var pendingHeight = 0
private var sizePending = false
fun record(bounds: java.awt.Rectangle) {
pendingPosition = bounds.location
pendingWidth = bounds.width
pendingHeight = bounds.height
sizePending = true
}
/**
* Whether [now] is the fitted position reported back. Compared with the move
* tolerance, because dp rounding under display scaling and a window manager's nudge
* both shift it slightly.
*/
fun takePosition(now: Point): Boolean {
val pending = pendingPosition ?: return false
pendingPosition = null
return !movedAway(pending, now)
}
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
fun takeSize(width: Int, height: Int): Boolean {
if (!sizePending) {
return false
}
sizePending = false
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
}
private companion object {
const val SIZE_ROUNDING_PX = 2
}
}
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
internal fun movedAway(opened: Point, now: Point): Boolean =
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
/**
* Tells the operator's moves of one opened window from the window manager's, fed with every
* floating position the window reports.
*
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
* also holds when the window was reopened at its remembered spot, so a decoration nudge
* cannot make it drift a little further on every opening.
* - **Once a report has left the tolerance**, the operator is moving the window, and from
* then on every report is taken — including a move back to near where it opened, which
* would otherwise leave the stale far-away spot remembered.
* - **Not placed by the host** (opened before the main window existed): the platform picks
* the spot, and it may report a provisional position before its real placement. So the
* anchor is not trusted until it has settled: every report replaces it until one lands
* within the tolerance of the previous one. A window manager's placement jump is
* therefore a new anchor, not a move. The price: a single far jump by the operator
* before any second report — a keyboard "move to other monitor" right after opening —
* is taken as the anchor too, and the window then simply opens on the main window's
* screen next time, which is the default anyway. A time-based settle was the
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
* produces a stream of close reports that settles the anchor on its own.
*
* @param placedAt where the host placed the window, or null when the platform did
*/
internal class MoveTracker(placedAt: Point?) {
private var anchor: Point? = placedAt
private var settled = placedAt != null
private var moving = false
/**
* @param now the position the window reports
* @return the position to remember as the operator's choice, or null to remember nothing
*/
fun observe(now: Point): Point? {
if (moving) {
return now
}
val current = anchor
if (!settled || current == null) {
if (current != null && !movedAway(current, now)) {
settled = true
} else {
anchor = now
}
return null
}
if (movedAway(current, now)) {
moving = true
return now
}
return null
}
}
/**
* The application icon, so these windows are not the only ones in the taskbar showing
* the default Java cup. Null when the resource is missing rather than failing: a window
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -240,7 +242,7 @@ class EditableListState(
* reject it and put the stored value back.
*
* @param onChanged called after every committed change — add, edit, removal, move.
* The lists write through, so this is where the roster is filled.
* The settings tabs stage the list here; applying the draft fills the roster.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour.
@@ -300,6 +302,23 @@ fun DataTable(
}
}
/*
* Inside the settings window a row still being edited is committed before the
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
* untouched one would report a change that was never made. A refusal is reported
* by commit() through onRefused, so the registration answers null.
*/
val settingsDraft = LocalSettingsDraft.current
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
DisposableEffect(settingsDraft, entry.key) {
val registration = settingsDraft?.registerTypedEntry {
pendingEdit.value()
null
}
onDispose { registration?.close() }
}
val isSelected = state.selectedIndex == index
/*
@@ -275,7 +275,14 @@ class DataTableState<T>(
var contentRevision by mutableStateOf(0)
private set
/**
* Redraws the rows and filters them again. The filter reads mutable row fields (a
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
* from before the change could keep a row the filter now excludes, or hide one it
* now lets through -- in the list and in the station map, which draws the same rows.
*/
fun forceRedraw() {
invalidateOrder()
contentRevision++
}
@@ -299,6 +306,7 @@ class DataTableState<T>(
*/
val currentRows = rows
val currentFilter = rowFilter
contentRevision // read for the subscription: forceRedraw drops the cache
val currentColumn = sortedColumnId
val ascending = sortAscending
@@ -54,7 +54,7 @@ object DirectedMessageColumns {
),
DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
@@ -62,7 +62,7 @@ object DirectedMessageColumns {
),
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -33,10 +33,12 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -111,10 +113,25 @@ object Form {
* every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter.
*
* @param stored the value currently stored; the draft falls back to it when an
* entry is refused, which is what those fields did with setText
* Only an edited draft is committed: drawing the field, or entering and leaving it
* without typing, commits nothing, and neither does Enter on an unchanged value.
* A field removed while it holds the focus -- its tab switched away, its window
* closed -- commits a typed entry once: Compose reports the cleared focus to the
* field as a focus loss.
*
* @param stored the value stored when the caller composed; the draft starts from it
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
* @param isUnchanged whether the draft names the stored value. Textual by default,
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value in force right now, read when the field commits: inside the
* settings window the value staged in the draft, otherwise the stored one. The
* typed text is judged against it, and falls back to it on a refusal (what the
* JavaFX fields did with setText). Required, because [stored] alone goes stale
* when it comes from a plain getter: nothing need recompose the caller after a
* commit, so after Enter it can still name the old value.
*/
@Composable
fun committed(
@@ -122,24 +139,67 @@ object Form {
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
current: () -> String,
) {
var draft by remember(stored) { mutableStateOf(stored) }
/*
* Whether the field has held the focus since it was last committed. onFocusChanged
* also fires when the modifier is first attached, with "not focused"; reading that
* as a focus loss committed every such field the moment its tab was opened. Only a
* change from focused to unfocused is the operator leaving the field. A plain holder,
* not state: nothing is drawn from it.
*/
val hadFocus = remember { booleanArrayOf(false) }
fun commitDraft() {
/*
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
* into an override of itself, restart the beacon timer, or report a stored value
* the rule refuses although the operator only passed through the field.
*/
if (isUnchanged(draft, current())) return
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = stored
draft = current()
}
}
/*
* Inside the settings window the draft can commit this field without a focus change:
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
* otherwise reach the draft only when the closing window removed the field. Through
* an updated state, because commitDraft closes over this composition's parameters.
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
* the registration answers null and the caller sees the refusal as the notice.
*/
val settingsDraft = LocalSettingsDraft.current
val latestCommit = rememberUpdatedState(::commitDraft)
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry {
latestCommit.value()
null
}
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
.onFocusChanged { focus ->
if (focus.isFocused) {
hadFocus[0] = true
} else if (hadFocus[0]) {
hadFocus[0] = false
commitDraft()
}
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft()
@@ -183,7 +243,7 @@ object Form {
.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
@@ -227,7 +287,7 @@ object Form {
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -264,34 +324,53 @@ object Form {
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().toIntOrNull()?.let(onChange)
},
isError = draft.trim().toIntOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
/**
* The number field behind [int] and [decimal].
*
* The text follows the stored value only when that value is not what the operator's own
* text already says. A settings draft is Compose state, so the value an entry stages comes
* straight back as [value]; resetting the text to `value.toString()` then would turn a
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
*/
@Composable
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
val typed = remember { arrayOf(value.toString()) }
var draft by remember(value) {
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
}
/*
* Text that does not parse stages nothing, so the draft still holds the last valid
* value while the field shows something else in red. Applying then would put a value
* into effect the operator no longer sees; the field refuses instead, naming itself.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
onValueChange = { entered ->
typed[0] = entered
draft = entered
parse(entered)?.let(onChange)
},
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
isError = parse(draft) == null,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -205,6 +205,8 @@ private fun MessageColumn(
ownQrgMain = state.ownQrgMain,
ownQrgSecond = state.ownQrgSecond,
antennaQtf = state.antennaQtf,
antennaQtf2 = state.antennaQtf2,
so2rEnabled = state.surroundings.so2rEnabled,
modifier = Modifier.fillMaxWidth(),
)
},
@@ -1,6 +1,7 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
/** How big the main window opens. */
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
@@ -56,30 +57,75 @@ object MainWindowFrame {
fun title(chatState: String, profileName: String?): String =
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
/**
* What the title's first half says.
*
* The live connection line while the chat is usable -- exactly what the JavaFX title
* carried -- and a wording about the attempt otherwise. The connection manager's own
* detail ("session is authenticated and synchronized") is a diagnostic for the status
* bar, not a title: it says nothing about category, call or traffic.
*
* @param state the connection state, null before the first push
* @param onlineLine the line built by [onlineChatState], asked for only while online
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
/**
* The connection line while the chat is usable, as the JavaFX title had it:
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
* 106 shown), 14 messages total."
*
* Counts are passed as text so MessageFormat does not group them by locale. Without a
* main category there is nothing to name, so the line falls back to the offline wording
* for ONLINE instead of reading "Connected to: as ...".
*
* @param mainCategory the main chat category, null when none is configured
* @param secondCategory the second category, null when it is not logged in
* @param callSign the login callsign
* @param name the login name of the main category
* @param locator the login locator
* @param usersOnline every station in the chat
* @param shown the stations the filters let through
* @param messagesTotal every chat message held, the global list behind the public tab
*/
fun onlineChatState(
mainCategory: ChatCategory?,
secondCategory: ChatCategory?,
callSign: String?,
name: String?,
locator: String?,
usersOnline: Int,
shown: Int,
messagesTotal: Int,
): String {
if (mainCategory == null) {
return offlineChatState(On4KstConnectionState.ONLINE)
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val categories = if (secondCategory == null) {
mainCategory.toString()
} else {
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
}
return strings.titleOnline(
categories,
callSign.orEmpty(),
name.orEmpty(),
locator.orEmpty(),
usersOnline.toString(),
shown.toString(),
messagesTotal.toString(),
)
}
/**
* What the title says while the chat is not usable.
*
* The wording separates a scheduled reconnect from a dead session, which is the
* difference between waiting and doing something about it.
*/
/**
* What the title's first half says.
*
* The live connection detail while the chat is usable -- which is the whole status line,
* exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A
* blank detail falls through to the offline wording rather than leaving the title empty.
*
* @param state the connection state, null before the first push
* @param detail the status line the controller pushes
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, detail: String?): String =
if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) {
detail
} else {
offlineChatState(state)
}
fun offlineChatState(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
@@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
*/
object MainWindowHost {
private val host = ComposeWindowHost("main-window")
private val host = ComposeWindowHost("main-window", isMainWindow = true)
@JvmStatic
val isOpen: Boolean
@@ -111,14 +111,29 @@ object MainWindowHost {
* Read inside the composition, so the title follows the connection the way
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
* (73 users online, 73 shown), 29 messages total." A captured string would
* say "disconnected" for the life of the session.
* say "disconnected" for the life of the session. The counts are Compose
* state, so the title moves with every station and message. visibleRows
* costs nothing extra: it is cached per change, and the station table
* reads the same list anyway.
*/
val surroundings = currentState?.surroundings
val state = currentState
MainWindowFrame.title(
chatState = MainWindowFrame.chatState(
surroundings?.connectionState,
surroundings?.connectionDetail,
),
chatState = if (state == null) {
MainWindowFrame.offlineChatState(null)
} else MainWindowFrame.chatState(state.surroundings.connectionState) {
val prefs = state.prefs
MainWindowFrame.onlineChatState(
mainCategory = prefs.getLoginChatCategoryMain(),
secondCategory = prefs.getLoginChatCategorySecond()
.takeIf { prefs.isLoginToSecondChatEnabled() },
callSign = prefs.getStn_loginCallSign(),
name = prefs.getStn_loginNameMainCat(),
locator = prefs.getStn_loginLocatorMainCat(),
usersOnline = state.stations.rows.size,
shown = state.stations.visibleRows.size,
messagesTotal = state.publicMessages.rows.size,
)
},
profileName = profileName,
)
},
@@ -75,13 +75,14 @@ class MainWindowState(
val snippets = RosterMirror(prefs.getLst_txtSnipList())
/**
* The three small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all three move without the operator touching them: the
* The four small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all of them move without the operator touching them: the
* rotator writes the heading, and the frequency follower writes the own QRG.
*/
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
val antennaQtf = ObservedValue(prefs.getActualQTF())
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
/**
* Stops following the preferences. Called when this state is dropped — a profile switch
@@ -92,6 +93,7 @@ class MainWindowState(
ownQrgMain.release()
ownQrgSecond.release()
antennaQtf.release()
antennaQtf2.release()
}
companion object {
@@ -161,4 +163,13 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
var settingsWindowOpen: Boolean by mutableStateOf(false)
var monitorWindowOpen: Boolean by mutableStateOf(false)
/**
* Whether SO2R is switched on. Compose state, because the preference is not: the
* application pushes the stored value in at start-up and after every settings Apply.
*/
var so2rEnabled: Boolean by mutableStateOf(false)
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
var secondAntennaOffered: Boolean by mutableStateOf(false)
}
@@ -51,7 +51,7 @@ object MonitorColumns {
*/
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -61,7 +61,7 @@ object MonitorWindow {
onResized: (Float, Float) -> Unit,
) {
host.show(
title = { "Cluster & QSO of the other" },
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -75,8 +75,9 @@ object MonitorWindow {
}
}
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable
private fun MonitorContent(
internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
) {
@@ -96,7 +97,7 @@ private fun MonitorContent(
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
"QSO of the other",
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
@@ -0,0 +1,39 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import kst4contest.model.ChatPreferences
/**
* The stored window positions of one profile, gated by the operator's opt-in.
*
* The opt-in is read on every call, so applying the setting takes effect without a
* restart: off means nothing is restored and nothing is written.
*
* @param requestSave asks the layout autosave to write the preferences file
*/
class PreferencesWindowPositionMemory(
private val prefs: ChatPreferences,
private val requestSave: Runnable,
) : WindowPositionMemory {
override fun load(windowId: String): StoredWindowPosition? {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return null
}
val p = prefs.getWindowPosition(windowId) ?: return null
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
}
override fun save(windowId: String, stored: StoredWindowPosition) {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return
}
prefs.setWindowPosition(
windowId,
stored.position.x, stored.position.y,
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
)
requestSave.run()
}
}
@@ -26,7 +26,7 @@ object PublicMessageColumns {
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn(
id = "last-qrg",
title = "Last QRG",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f,
),
@@ -38,7 +38,7 @@ object PublicMessageColumns {
),
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -16,13 +16,13 @@ object SelectedStationMessageColumns {
),
DataColumn(
id = "call-tx",
title = "Sender",
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = "Receiver",
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f,
),
@@ -127,7 +127,7 @@ fun SelectedStationPanel(
}
}
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
@@ -183,7 +183,17 @@ fun SelectedStationPanel(
Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button("🗺 Show on map", onClick = onShowOnMap)
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
Form.button(
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnAntenna,
) { state.turnAntenna() }
if (state.showsSecondAntenna) {
Form.button(
strings.selectedTurnAntenna2(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnSecondAntenna,
) { state.turnSecondAntenna() }
}
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl())
}
@@ -54,6 +54,12 @@ enum class SelectedMessageFilter(val label: String) {
* `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
* `ChatController.rotateTo` does; null where no rotator can be driven
* @param turnSecondAntennaTo the same for antenna 2 (rotator 2); null where it cannot be driven
* @param secondAntennaOffered whether SO2R with rotator 2 is configured right now; called on
* every draw, so the supplied lambda has to read Compose state for the button to
* follow a settings Apply
*/
class SelectedStationState(
private val propagateNotQrv: (ChatMember) -> Unit,
@@ -63,6 +69,9 @@ class SelectedStationState(
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
private val turnAntennaTo: ((Double) -> Unit)? = null,
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
private val secondAntennaOffered: () -> Boolean = { false },
) {
var selected: ChatMember? by mutableStateOf(null)
@@ -103,6 +112,30 @@ class SelectedStationState(
val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank()
/** Needs a rotator to drive and a finite heading to the selected station. */
val canTurnAntenna: Boolean
get() = turnAntennaTo != null && selected?.getQTFdirection()?.isFinite() == true
/** Turns the antenna to the selected station's heading; does nothing when it cannot. */
fun turnAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
}
/** SO2R with rotator 2 is configured, so the second turn button is offered at all. */
val showsSecondAntenna: Boolean
get() = turnSecondAntennaTo != null && secondAntennaOffered()
/** Needs the button to be offered and a finite heading to the selected station. */
val canTurnSecondAntenna: Boolean
get() = showsSecondAntenna && selected?.getQTFdirection()?.isFinite() == true
/** Turns antenna 2 to the selected station's heading; does nothing when it cannot. */
fun turnSecondAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
}
fun select(member: ChatMember?) {
if (selected?.callSignRaw != member?.callSignRaw) {
skedBand = member?.let { resolveDefaultSkedBand(it) }
@@ -0,0 +1,247 @@
package kst4contest.view.compose
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* The edits made in the settings window that have not been applied yet.
*
* One draft exists per opened settings window and is handed to every tab state. A tab
* state stages a change here instead of writing ChatPreferences, and reads its values
* back through [valueOf], so the window shows what the operator entered while the running
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
* closing the window and choosing to discard calls [discard].
*
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
* than its value. Several settings carry an effect on the running session -- the beacon
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
* immediate edit ran, once per changed setting.
*
* The order [apply] keeps is part of the contract, see there. The design and the list of
* settings that deliberately bypass the draft are in
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
*
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
* thread, and every caller -- a control, a button, the close request -- runs there.
* Open for the tests, which use a draft that applies every change at once to pin the
* rules of a tab state without the draft in between.
*/
open class SettingsDraft {
private class Change(
val value: Any?,
val write: () -> Unit,
val effectKey: String?,
val effect: (() -> Unit)?,
)
/*
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
* value is staged. The tab states are plain facades; this is what makes them observable.
*/
private val changes = mutableStateMapOf<String, Change>()
/*
* The order in which settings were first changed. The state map keeps no order, and
* apply() must not run effects in hash order.
*/
private val order = mutableListOf<String>()
private val typedEntries = mutableListOf<() -> String?>()
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
val hasPendingChanges: Boolean
get() = changes.isNotEmpty()
/** The staged value of [key], or the stored one read from [live]. */
@Suppress("UNCHECKED_CAST")
fun <T> valueOf(key: String, live: () -> T): T {
val change = changes[key] ?: return live()
return change.value as T
}
/**
* Records a change of the setting [key]. A later stage of the same key replaces the value
* and keeps the position. Staging the stored value removes the change: a box ticked and
* unticked again leaves nothing to apply and nothing to ask about on closing.
*
* Only valid values may be staged. The fields refuse an invalid entry before it gets
* here, as they always did.
*
* @param live reads the stored value
* @param write stores the value; called by [apply]
* @param effectKey settings with the same key share one effect, which [apply] runs once.
* Defaults to [key].
* @param effect what the running session needs after the write, run by [apply] after all
* writes
*/
open fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String? = null,
effect: (() -> Unit)? = null,
) {
if (value == live()) {
changes.remove(key)
order.remove(key)
return
}
if (!changes.containsKey(key)) {
order += key
}
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
}
/**
* Writes every change, then runs every effect, then forgets what was written.
*
* Writes come first, in the order the settings were first changed, so that each effect
* sees the finished state: enabling the Win-Test listener and moving its port in one go
* would otherwise restart the listener on the old port. Effects follow in the same order,
* each effect key once: two restarts of one socket in quick succession can fail on the
* port the first one has not released yet.
*
* Every write and every effect runs on its own: one that throws is recorded and the rest
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
* next setting unapplied. A change whose write failed stays in the draft, and its effect
* does not run -- it would act on a value that was never stored. A failed effect's write
* did happen, so that change is gone from the draft like every other.
*
* Typed entries still held by a field are not committed here; the caller does that first
* and decides on a refusal before anything is applied, see [commitTypedEntries].
*
* @return what could not be done; empty when everything went through
*/
fun apply(): ApplyOutcome {
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
val failedWrites = mutableListOf<String>()
val failedKeys = HashSet<String>()
staged.forEach { (key, change) ->
try {
change.write()
} catch (failure: Exception) {
failedWrites += describe(key, failure)
failedKeys += key
}
}
val failedEffects = mutableListOf<String>()
val ran = HashSet<String?>()
staged.forEach { (key, change) ->
val effect = change.effect ?: return@forEach
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
try {
effect()
} catch (failure: Exception) {
failedEffects += describe(change.effectKey ?: key, failure)
}
}
staged.forEach { (key, _) ->
if (key !in failedKeys) {
changes.remove(key)
order.remove(key)
}
}
return ApplyOutcome(failedWrites, failedEffects)
}
/** Forgets every change without writing anything. */
fun discard() {
clear()
}
/**
* Registers a field that holds typed text the draft has not seen yet: a field that commits
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
* parse and therefore staged nothing.
*
* Clicking a button does not reliably take the focus from a text field, so without this an
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
* committed only when the closing window removed the field -- into a draft nobody applies
* any more. And a field showing invalid text in red would let Apply go ahead with the last
* valid value, which the operator no longer sees.
*
* @param commit commits what the field holds and returns null, or returns the reason it
* cannot be accepted -- worded for the operator, naming the field
* @return closing it unregisters the field
*/
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
typedEntries += commit
return AutoCloseable { typedEntries.remove(commit) }
}
/**
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
*
* @return the refusals, empty when every field was accepted
*/
fun commitTypedEntries(): List<String> =
typedEntries.toList().mapNotNull { it() }
private fun describe(key: String, failure: Exception): String =
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
private fun clear() {
changes.clear()
order.clear()
}
}
/**
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
*
* @param failedWrites settings that could not be stored; they stay in the draft
* @param failedEffects effects that failed after their settings were stored
*/
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
val failures: List<String>
get() = failedWrites + failedEffects
val isClean: Boolean
get() = failures.isEmpty()
}
/**
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
*
* The key is the owning class and the property name, so two tabs cannot collide and a
* property needs no key spelled out beside it.
*
* @param read reads the stored value
* @param write stores a value; runs on apply
* @param effectKey see [SettingsDraft.stage]
* @param effect see [SettingsDraft.stage]
*/
fun <T> SettingsDraft.setting(
read: () -> T,
effectKey: String? = null,
effect: (() -> Unit)? = null,
write: (T) -> Unit,
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
private fun keyOf(owner: Any, property: KProperty<*>) =
(owner::class.simpleName ?: "settings") + "." + property.name
override fun getValue(thisRef: Any, property: KProperty<*>): T =
valueOf(keyOf(thisRef, property), read)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
}
}
/**
* The draft of the settings window being drawn, or null outside it. Form.committed registers
* its typed entry here; the tab screen tests draw a tab alone and need none.
*/
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
@@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.On4KstConnectionState
import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile
@@ -36,6 +37,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
import kst4contest.view.compose.tabs.repairStoredSettings
/**
* The work the settings window needs from the main window.
@@ -56,6 +58,13 @@ interface SettingsHost {
*/
fun refreshShortcutButtonsFromSettings()
/**
* Pushes the stored SO2R switch and the rotator 2 switch into the main window's Compose
* state, so the QTF2 field and the "Turn ant2" button follow an Apply. The
* implementation has to hop onto the UI thread; the settings window runs on its own.
*/
fun refreshSo2rState()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings()
@@ -113,6 +122,12 @@ interface SettingsHost {
* place, and the foreign-write coverage test can see it. A tab that only wrote a
* preference and skipped its session call would look correct in the file and do
* nothing until the next restart.
*
* @param connectionState the session state as Compose state reads it; the Station tab
* locks the login fields from it. The controller's own getter is not observable,
* so a tab reading it would stay on whatever the link was when the window opened.
* @param draft the edits of this opening of the window; every tab state stages into it,
* and the button bar applies it
*/
fun buildSettingsTabs(
controller: ChatController,
@@ -120,32 +135,42 @@ fun buildSettingsTabs(
notices: SettingsNotices,
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
connectionState: () -> On4KstConnectionState,
draft: SettingsDraft,
): List<SettingsTab> {
val prefs = controller.chatPreferences
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
val stationState = StationTabState(
prefs,
draft,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
)
val notificationState = NotificationTabState(
prefs,
draft,
controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServerEnabled = { enabled ->
applyDxClusterServer = {
/*
* Starting a server that nothing can reach yet is pointless, so the JavaFX
* checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login.
* flag alone decides what happens at the next login. Restarted rather than
* started when enabled: the same call then binds a changed port, and a server
* that was not running is simply started.
*/
if (controller.isConnectedAndLoggedIn) {
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
if (prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
} else {
controller.stopDxClusterServer()
}
}
},
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
@@ -153,12 +178,14 @@ fun buildSettingsTabs(
val shortcutsState = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
)
val beaconState = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
@@ -167,9 +194,9 @@ fun buildSettingsTabs(
val logSynchState = LogSynchTabState(
prefs,
draft,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
@@ -204,14 +231,14 @@ fun buildSettingsTabs(
* so a tidier order or a shorter title would be a regression, not an improvement.
*/
return listOf(
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() }, applyOwnQrgFollower = host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
/*
* Selecting this tab re-read the database in the JavaFX window; the list is a
* snapshot and would otherwise show what was true when the window opened.
@@ -220,7 +247,7 @@ fun buildSettingsTabs(
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
/*
* Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement.
@@ -257,18 +284,18 @@ fun buildSettingsTabs(
*/
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return "Connect KST4Contest and enable the local DX Cluster server first."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
}
val server = controller.dxClusterServer
?: return "Connect KST4Contest and enable the local DX Cluster server first."
?: return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
if (!server.hasConnectedClients()) {
return "No DX Cluster client is connected to KST4Contest."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNoClient
}
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return "The test spot could not be delivered."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNotDelivered
}
return null
@@ -284,11 +311,19 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
* Built here because it needs the controller for its state and the host for its
* actions, and because it belongs with the tabs it sits under. The `close` action comes
* from the window itself: only the window can end its own Compose application.
*
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
* "Save settings" applies and writes the file, and Connect applies before it connects: the
* session would otherwise use the stored server or second-chat switch rather than the one
* the tab shows. A field that refuses what it holds stops the button before anything is
* applied, so the operator sees why instead of a closed window or a saved file; the X still
* offers Discard. A write or effect that fails is reported in a problem notice.
*/
fun settingsButtons(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
draft: SettingsDraft,
): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller)
@@ -304,24 +339,14 @@ fun settingsButtons(
ConnectionBar(
state = barState,
onConnect = {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
if (applyChecked(draft, notices)) {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
}
revision++
},
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(
"The configuration file could not be written. The settings stay " +
"active for this session."
)
} else {
notices.info(
"Settings are stored as default to the xml config file:\n" + target
)
}
},
onApplyAndClose = close,
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
@@ -330,3 +355,74 @@ fun settingsButtons(
)
}
}
/**
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
* the field, or discards everything through the window's X, whose question offers Discard
* even while a field refuses (see SettingsQuestion).
*
* A problem notice already standing counts as a refusal too. The click on a button can
* itself take the focus from a field, which then commits -- and is refused, reporting it --
* before the button's action runs. A problem notice from earlier cannot be standing here:
* its dialog covers the window, so nothing in it could have been clicked.
*/
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
val refusals = draft.commitTypedEntries()
if (refusals.isNotEmpty()) {
notices.problem(refusals.joinToString("\n"))
return false
}
return notices.current !is SettingsNotice.Problem
}
/**
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
* applied, the failures named in a problem notice).
*/
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
if (!typedEntriesAccepted(draft, notices)) return false
val outcome = draft.apply()
if (!outcome.isClean) {
notices.problem(applyFailedText(outcome))
return false
}
return true
}
/**
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
* where it went. The window stays open.
*
* A failed effect does not stop the file: its setting was stored, and the file should hold
* it. A failed write does, because the file would then miss what the window shows.
*
* @param save writes the preferences and returns the file, or null when it could not
*/
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
if (!typedEntriesAccepted(draft, notices)) return
val outcome = draft.apply()
if (outcome.failedWrites.isNotEmpty()) {
notices.problem(applyFailedText(outcome))
return
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val target = save()
when {
target == null -> notices.problem(
listOfNotNull(
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
strings.settingsWriteFailed,
).joinToString("\n\n")
)
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
else -> notices.info(strings.settingsWrittenTo(target))
}
}
private fun applyFailedText(outcome: ApplyOutcome): String =
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.awt.EventQueue
import kst4contest.view.i18n.LocalStrings
/**
* One tab of the settings window: a title and the content to show.
@@ -104,6 +109,90 @@ class SettingsNotices {
}
}
/**
* The question about edits not applied yet, asked when the window closes, the profile is
* switched or the client exits.
*
* The decision is taken in [settle], on the calling event thread, not in the window's next
* frame: a minimised window may draw no frames, and an exit from the main window would then
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
* window is raised before it is needed. Compose state, so the open window shows the question.
*/
class SettingsQuestion {
/** What a shown question goes on with. */
class Asked(
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
val then: (() -> Unit)?,
/**
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
* work, so the operator is never trapped behind a field he only wants to get rid of.
*/
val refusal: String?,
)
/** The question being shown, or null. */
var asking: Asked? by mutableStateOf(null)
private set
/** Counts the closes asked for with nothing to settle; the window closes on each. */
var closeRequests by mutableStateOf(0)
private set
/**
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
* and no field refuses what it holds, and shows the question otherwise.
*
* While a question is shown a further request is ignored. Replacing its continuation would
* turn an exit the operator is being asked about into a mere close of the window -- or the
* other way round.
*
* @return true when a question is shown now
*/
fun settle(
draft: SettingsDraft,
notices: SettingsNotices,
then: (() -> Unit)?,
nothingPending: () -> Unit,
): Boolean {
if (asking != null) return true
val refusals = draft.commitTypedEntries()
// A refusal a field reported on its own focus loss is standing as the notice.
val standing = notices.current as? SettingsNotice.Problem
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
if (refusal == null && !draft.hasPendingChanges) {
nothingPending()
return false
}
// The reason moves into the question, so two dialogs do not stand on top of each other.
if (standing != null) notices.dismiss()
asking = Asked(then, refusal)
return true
}
/** The X of the window: close when nothing waits, ask otherwise. */
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
}
/** The question was answered; called before acting on the answer. */
fun answered() {
asking = null
}
}
/**
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
* profile switch or an exit must not run twice because a dialog was clicked twice.
*/
internal fun runOnce(action: Runnable): Runnable {
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
}
/**
* The settings window.
*
@@ -112,9 +201,11 @@ class SettingsNotices {
* most once — a second instance would let two windows write the same preferences
* against each other.
*
* Saving does not collect values. Every control writes into ChatPreferences as it
* changes; the button bar only persists, which is what the JavaFX "Save settings"
* button did.
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
* the window, switching the operator profile or leaving the client while edits are waiting
* first asks whether to apply or discard them, or to go back, which cancels the close, the
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
* follows -- and a JVM shutdown hook drop them without asking.
*/
object SettingsWindow {
@@ -135,11 +226,67 @@ object SettingsWindow {
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
/** What the open window was built with, if one is open. */
private class OpenWindow(
val draft: SettingsDraft,
val notices: SettingsNotices,
val question: SettingsQuestion,
)
@Volatile
private var openWith: OpenWindow? = null
/**
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
* operator's close; that one goes through [requestClose].
*/
@JvmStatic
fun close() = host.close()
/**
* Closes the window the way its X does: at once when nothing waits to be applied,
* otherwise after asking.
*/
@JvmStatic
fun requestClose() {
val open = openWith
if (open == null || !host.isOpen) return
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
host.raise()
}
}
/**
* Runs [action] once the window's pending edits are settled: at once when the window is
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
* that neither drops edits the operator has not decided about. While a question is
* already shown the request is ignored, see [SettingsQuestion.settle].
*
* Must be called on the event thread, and never waits there. The decision is taken here
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
* draws again -- and the question set as Compose state. The action then runs from the
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
* confirmation, the window teardown -- does not run inside the click handler. It runs at
* most once.
*/
@JvmStatic
fun settleThen(action: Runnable) {
val once = runOnce(action)
val open = openWith
if (open == null || !host.isOpen) {
once.run()
return
}
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
host.raise()
}
}
/**
* @param draft the edits of this opening of the window; the tabs and the buttons were
* built with the same one
* @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application
*/
@@ -147,6 +294,7 @@ object SettingsWindow {
fun show(
tabs: List<SettingsTab>,
notices: SettingsNotices,
draft: SettingsDraft,
darkMode: Boolean,
baseFontSizeSp: Float,
/**
@@ -164,8 +312,17 @@ object SettingsWindow {
onResized: (Float, Float) -> Unit,
buttons: @Composable (close: () -> Unit) -> Unit,
) {
if (host.isOpen) {
// Raises the open window; the new draft belongs to no window and is dropped.
host.raise()
return
}
val question = SettingsQuestion()
openWith = OpenWindow(draft, notices, question)
host.show(
title = { "Change Client Settings" },
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -173,85 +330,158 @@ object SettingsWindow {
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
// The X is clicked in a window that is showing, so its frames run.
onCloseRequest = { question.settleClose(draft, notices) },
) { close ->
SettingsContent(tabs, notices, buttons, close)
SettingsContent(tabs, notices, buttons, close, draft, question)
}
}
}
/**
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
* a typed entry can be committed into it before an apply.
* @param question the question about the pending edits, and the closes asked for without one
*/
@Composable
internal fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit,
draft: SettingsDraft = SettingsDraft(),
question: SettingsQuestion = SettingsQuestion(),
) {
var selected by remember { mutableStateOf(0) }
val closes = question.closeRequests
LaunchedEffect(closes) {
if (closes > 0) close()
}
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
CompositionLocalProvider(LocalSettingsDraft provides draft) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
buttons(close)
}
buttons(close)
}
}
question.asking?.let { asked ->
val proceed = asked.then ?: close
PendingChangesDialog(
refusal = asked.refusal,
onApply = {
question.answered()
// A refusal or a failure stops here, with the reason shown; nothing goes on.
if (applyChecked(draft, notices)) proceed()
},
onDiscard = {
question.answered()
draft.discard()
proceed()
},
onBack = { question.answered() },
)
}
notices.current?.let { notice ->
AlertDialog(
onDismissRequest = notices::dismiss,
title = {
Text(
when (notice) {
is SettingsNotice.Info -> "Info"
is SettingsNotice.Problem -> "Settings"
is SettingsNotice.Info -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeInfo
is SettingsNotice.Problem -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeProblem
}
)
},
text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
confirmButton = { TextButton(onClick = notices::dismiss) { Text(kst4contest.view.i18n.LocalStrings.current.dialogOk) } },
)
}
}
/**
* Asks what becomes of the edits not applied yet when the operator closes the window,
* switches the profile or exits: apply them, discard them, or go back to the window.
* Dismissing it goes back -- nothing is lost by a stray click beside it.
*
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
* disabled. Discard and Back stay available, so a field the operator only wants to get
* rid of never blocks the close, the switch or the exit.
*/
@Composable
internal fun PendingChangesDialog(
refusal: String?,
onApply: () -> Unit,
onDiscard: () -> Unit,
onBack: () -> Unit,
) {
val strings = LocalStrings.current
AlertDialog(
onDismissRequest = onBack,
title = { Text(strings.settingsPendingTitle) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(strings.settingsPendingQuestion)
// Why Apply is not offered; Discard and Back still are.
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
},
dismissButton = {
Row {
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
}
},
)
}
/**
* The row of tab buttons.
*
@@ -26,6 +26,19 @@ object StationColumns {
/** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked"
/**
* The identity a station row is drawn and selected under: the callsign and the chat
* category. The category is part of it because the same callsign can be in two chats at
* once, and AGENTS.md makes the category part of the chat identity; keyed by callsign
* alone, the two rows collided (fixed in 21251377).
*
* Kept here rather than inline at the one call site so the station list can be tested
* with the key it really uses. The text is exactly what the inline Java expression
* produced, including "null" for a missing callsign.
*/
fun rowKey(member: ChatMember): String =
"${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}"
fun all(
tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double,
@@ -52,7 +65,7 @@ object StationColumns {
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
@@ -61,7 +74,7 @@ object StationColumns {
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn(
id = "score",
title = "Score",
title = { kst4contest.view.i18n.CurrentStrings.get().columnScore },
value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f,
@@ -100,7 +113,7 @@ object StationColumns {
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
},
@@ -48,11 +48,19 @@ fun StationFilterBar(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
// ROW 1: Reset | QRB | QTF + bearings
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
/*
* ROW 1: Reset | QRB max + min | QTF | bearings
*
* It wraps (#21): with both QRB filters the line no longer fits a narrow station
* pane, and a plain row cut the bearing buttons off at the right edge. Each group
* stays together on its line; only the QRB pair and the bearings may break up
* inside their group when even that group is wider than the pane.
*/
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
// Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
@@ -60,65 +68,105 @@ fun StationFilterBar(
onChanged()
}
VerticalSeparator()
// QRB checkbox + field
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
// QRB maximum + minimum (#21): together they make a range, so they stay one group
FilterGroup {
GroupSeparator()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
},
modifier = Modifier.width(50.dp)
)
VerticalSeparator()
// QTF checkbox + field + bearings
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
FilterGroup {
CompactCheckbox(
checked = filters.minQrbEnabled,
onCheckedChange = {
filters.minQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.minQrbKm = parsed
if (filters.minQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.minQrbKm = 0.0
if (filters.minQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
}
}
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
// QTF checkbox + field
FilterGroup {
GroupSeparator()
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
}
// Bearings
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
}
@@ -235,6 +283,31 @@ private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false,
}
}
/** Controls that belong together and move to the next line of a wrapping row as one. */
@Composable
private fun FilterGroup(content: @Composable () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
/**
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills
* the row's intrinsic height, which a line of a flow row does not offer.
*/
@Composable
private fun GroupSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.height(18.dp)
.background(MaterialTheme.colorScheme.outline),
)
}
/** Thin vertical separator matching JavaFX's group dividers. */
@Composable
private fun VerticalSeparator() {
@@ -18,6 +18,7 @@ enum class StationFilter {
NEW_BANDS,
AIRSCOUT_NEXT_5_MIN,
MAX_QRB,
MIN_QRB,
QTF,
SEARCH_TEXT,
HIDE_INACTIVE,
@@ -33,6 +34,17 @@ enum class StationFilter {
HIDE_WORKED_3,
}
/**
* What a QRB field of the filter bar makes of the text typed into it: a distance in km, or
* `null` when the text is none.
*
* `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum
* hid every station while "NaN" let every one through. A negative distance means nothing
* either. All of these are refused, so the field keeps its last valid value (#21).
*/
internal fun parseQrbKm(text: String): Double? =
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
/** The bands the "hide worked" row offers, under the labels the bar shows. */
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
@@ -99,6 +111,16 @@ class StationFilterState {
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
var maxQrbKm: Double by mutableStateOf(0.0)
var minQrbEnabled: Boolean
get() = isOn(StationFilter.MIN_QRB)
set(value) = switch(StationFilter.MIN_QRB, value)
/**
* Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is
* hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21).
*/
var minQrbKm: Double by mutableStateOf(0.0)
var qtfEnabled: Boolean
get() = isOn(StationFilter.QTF)
set(value) = switch(StationFilter.QTF, value)
@@ -130,6 +152,23 @@ class StationFilterState {
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
/**
* Whether a station [qrb] km away passes the distance filters that are switched on.
*
* Both on make a range: `min < qrb <= max`. A station without a known distance is
* hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance
* can be in neither range.
*/
fun acceptsQrb(qrb: Double?): Boolean {
val max = isOn(StationFilter.MAX_QRB)
val min = isOn(StationFilter.MIN_QRB)
if (!max && !min) return true
if (qrb == null) return false
if (max && qrb > maxQrbKm) return false
if (min && qrb <= minQrbKm) return false
return true
}
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
@@ -0,0 +1,353 @@
package kst4contest.view.compose
import java.awt.GraphicsEnvironment
import java.awt.Point
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.Window
/**
* One connected screen: its whole area and the part a window may use.
*
* @param bounds the screen in the shared desktop coordinate space. A screen left of or
* above the primary one has negative coordinates.
* @param usable [bounds] minus task bars, docks and panels
*/
data class ScreenArea(val bounds: Rectangle, val usable: Rectangle)
/**
* A window position kept across restarts, together with the screen it was on.
*
* @param position the window's top-left corner in desktop coordinates
* @param screen the bounds of the screen the window was on when it was stored
*/
data class StoredWindowPosition(val position: Point, val screen: Rectangle)
/**
* Where window positions are kept across restarts, when the operator opted in.
*
* The application sets it per profile; [load] answers null and [save] does nothing while
* the opt-in is off, so the hosts need not know about the setting.
*/
interface WindowPositionMemory {
fun load(windowId: String): StoredWindowPosition?
fun save(windowId: String, stored: StoredWindowPosition)
}
/**
* Where a secondary window opens, decided without touching AWT (issue #59).
*
* The rule: a window the operator moved during this session reopens where it was moved to,
* as long as its title bar can still be reached on a connected screen — shifted just far
* enough to lie wholly within that screen's usable area. Otherwise it opens centred on the
* screen the main window is on. Either way its size is cut down to the usable area of the
* screen it lands on, so no button sits past the edge.
*
* The cut-down size is only the opening size. The host does not write it back, so the
* stored size survives a stay on a smaller screen; a resize the operator makes there is
* an ordinary resize and is saved.
*
* Before this, every secondary window got the platform's default position, which on a
* multi-monitor desk is the primary screen — the station map opened on a screen the
* operator was not looking at, or on one that was switched off.
*
* Pure on purpose: the rule is the part that can be wrong, and it needs screens that no
* test machine has. [WindowPlacementOnScreen] feeds it the real screens.
*/
object WindowPlacement {
/**
* How much of the window's top edge counts as the title bar. A remembered position
* is only kept when this strip can be grabbed, because a window whose title bar is
* off-screen cannot be moved back by the operator.
*/
const val TITLE_BAR_HEIGHT = 32
/**
* How wide a piece of the title bar must be visible to count as reachable. Narrower
* windows need their whole width.
*/
const val MIN_VISIBLE_TITLE_WIDTH = 100
/**
* Where the window opens and how big it is.
*
* @param screens the connected screens; empty when there is no display
* @param mainWindow the main window's frame bounds, null while it does not exist
* @param remembered where the operator moved this window to during the session, or null
* @param width the size the window asks for
* @param height the size the window asks for
* @return the frame bounds to open with, or null to leave placement to the platform —
* which is the behaviour before the main window exists
*/
fun place(
screens: List<ScreenArea>,
mainWindow: Rectangle?,
remembered: Point?,
width: Int,
height: Int,
): Rectangle? {
if (screens.isEmpty()) {
return null
}
if (remembered != null) {
val screen = screenShowingTitleBar(screens, remembered, width)
if (screen != null) {
return keptWithin(usableArea(screen), remembered, width, height)
}
}
if (mainWindow == null) {
return null
}
return centredOn(usableArea(screenOf(screens, mainWindow)), width, height)
}
/**
* Where a window opens from a position stored in an earlier run.
*
* Only on the very screen it was stored on: same place in the desktop, same size. A
* changed layout -- a monitor unplugged, rearranged or switched to another resolution --
* makes the stored position meaningless, and the caller falls back to the session rule.
* On that screen the window is kept within the usable area, like a session position.
*
* @return the frame bounds to open with, or null when the stored screen is not connected
*/
fun restore(screens: List<ScreenArea>, stored: StoredWindowPosition, width: Int, height: Int): Rectangle? {
val screen = screens.firstOrNull { it.bounds == stored.screen } ?: return null
return keptWithin(usableArea(screen), stored.position, width, height)
}
/**
* The window cut down to the usable area of the screen it is on, or null when it fits.
*
* For a window that is already open and was moved onto a smaller screen -- a laptop
* panel, a 640x480 field monitor. Opening sizes are cut by [place] and [restore]; this
* covers the move. Shifted the least distance into the usable area, like [keptWithin].
*/
fun fitted(screens: List<ScreenArea>, window: Rectangle): Rectangle? {
if (screens.isEmpty()) {
return null
}
val usable = usableArea(screenOf(screens, window))
if (window.width <= usable.width && window.height <= usable.height) {
return null
}
return keptWithin(usable, window.location, window.width, window.height)
}
/**
* A minimum size no larger than the smallest usable area: AWT enforces the minimum even when it does
* not fit the screen, which leaves the window's edges unreachable.
*/
fun minimumWithin(screens: List<ScreenArea>, width: Int, height: Int): java.awt.Dimension {
if (screens.isEmpty()) {
return java.awt.Dimension(width, height)
}
val areas = screens.map(::usableArea)
return java.awt.Dimension(
clamp(width, areas.minOf { it.width }),
clamp(height, areas.minOf { it.height }),
)
}
/**
* The window centred on [usable], cut down to fit it.
*/
fun centredOn(usable: Rectangle, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
usable.x + (usable.width - w) / 2,
usable.y + (usable.height - h) / 2,
w,
h,
)
}
/**
* The window at [position], cut down to fit [usable] and shifted the least distance
* that puts it wholly inside. The cut comes first, so the shift always has room.
*/
fun keptWithin(usable: Rectangle, position: Point, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
position.x.coerceIn(usable.x, usable.x + usable.width - w),
position.y.coerceIn(usable.y, usable.y + usable.height - h),
w,
h,
)
}
/**
* The screen a window is on: the one holding its centre, else the one it overlaps
* most, else the first. The centre decides first because a window straddling two
* screens is "on" the one the operator sees most of it on, and the centre says so
* without an area comparison in the common case.
*/
fun screenOf(screens: List<ScreenArea>, window: Rectangle): ScreenArea {
val centre = Point(window.x + window.width / 2, window.y + window.height / 2)
screens.firstOrNull { it.bounds.contains(centre) }?.let { return it }
return screens.maxByOrNull { overlapArea(it.bounds, window) }
?.takeIf { overlapArea(it.bounds, window) > 0 }
?: screens.first()
}
/**
* The screen on which the title bar of a window at [position] can be grabbed, or null
* when it cannot be grabbed on any — the screen was unplugged, or the window was
* pushed past an edge.
*/
private fun screenShowingTitleBar(
screens: List<ScreenArea>,
position: Point,
width: Int,
): ScreenArea? {
val titleBar = Rectangle(position.x, position.y, width.coerceAtLeast(1), TITLE_BAR_HEIGHT)
val neededWidth = minOf(MIN_VISIBLE_TITLE_WIDTH, titleBar.width)
val neededHeight = TITLE_BAR_HEIGHT / 2
return screens
.filter {
val visible = usableArea(it).intersection(titleBar)
!visible.isEmpty && visible.width >= neededWidth && visible.height >= neededHeight
}
.maxByOrNull { overlapArea(usableArea(it), titleBar) }
}
/**
* The usable area, or the whole screen when the insets leave nothing — a broken inset
* report must not shrink every window to a single pixel.
*/
private fun usableArea(screen: ScreenArea): Rectangle =
if (screen.usable.isEmpty) screen.bounds else screen.usable
private fun clamp(wanted: Int, available: Int): Int =
wanted.coerceAtMost(available).coerceAtLeast(1)
private fun overlapArea(a: Rectangle, b: Rectangle): Long {
val overlap = a.intersection(b)
return if (overlap.isEmpty) 0L else overlap.width.toLong() * overlap.height
}
}
/**
* The AWT side of [WindowPlacement]: the real screens and the real main window.
*
* Kept thin so that what no headless test can see is only reading the screens and
* handing them over. Call it on the AWT event thread, like everything that touches a
* window.
*/
object WindowPlacementOnScreen {
/**
* The main window's frame, published by its host while it is open. Read only for its
* bounds; nothing here moves it.
*/
@Volatile
var mainWindow: Window? = null
/**
* The positions kept across restarts, set by the application for the active profile.
* Null in tests and before startup, which means nothing is restored or stored.
*/
@JvmStatic
@Volatile
var memory: WindowPositionMemory? = null
/**
* Where a window opens from its stored position, or null when none applies.
*/
fun restore(windowId: String, width: Int, height: Int): Rectangle? {
val stored = memory?.load(windowId) ?: return null
return WindowPlacement.restore(connectedScreens(), stored, width, height)
}
/**
* The minimum size a window may enforce, capped to the smallest connected screen. The
* smallest rather than the current one, because the window may be moved there later
* and must then still be cut down to fit.
*/
fun minimumSizeOnScreens(width: Int, height: Int): java.awt.Dimension =
WindowPlacement.minimumWithin(connectedScreens(), width, height)
/**
* The window cut down to the screen it is now on, or null when it fits.
*/
fun fitted(window: Rectangle): Rectangle? = WindowPlacement.fitted(connectedScreens(), window)
/**
* Stores the position the operator moved a window to, with the screen it is now on.
*/
fun store(windowId: String, position: Point, width: Int, height: Int) {
val target = memory ?: return
val screens = connectedScreens()
if (screens.isEmpty()) {
return
}
val screen = WindowPlacement.screenOf(screens, Rectangle(position.x, position.y, width, height))
target.save(windowId, StoredWindowPosition(position, screen.bounds))
}
/** The connected screens, the default one first. Empty without a display. */
fun connectedScreens(): List<ScreenArea> = runCatching {
if (GraphicsEnvironment.isHeadless()) {
return emptyList()
}
val environment = GraphicsEnvironment.getLocalGraphicsEnvironment()
val toolkit = Toolkit.getDefaultToolkit()
val default = environment.defaultScreenDevice
environment.screenDevices
.sortedByDescending { it == default }
.map { device ->
val configuration = device.defaultConfiguration
val bounds = configuration.bounds
val insets = toolkit.getScreenInsets(configuration)
ScreenArea(
bounds = bounds,
usable = Rectangle(
bounds.x + insets.left,
bounds.y + insets.top,
bounds.width - insets.left - insets.right,
bounds.height - insets.top - insets.bottom,
),
)
}
}.getOrDefault(emptyList())
/**
* The main window's bounds, or null while there is no main window on screen.
*/
fun mainWindowBounds(): Rectangle? =
mainWindow?.takeIf { it.isDisplayable && it.isShowing }?.bounds
/**
* Where a secondary window opens, from the live screens and main window.
*
* @return null to leave placement to the platform
*/
fun place(remembered: Point?, width: Int, height: Int): Rectangle? =
WindowPlacement.place(connectedScreens(), mainWindowBounds(), remembered, width, height)
/**
* Centres a dialog on the main window's screen, cut down to fit.
*
* Before the main window exists — the profile picker at startup — the dialog is
* centred the way it always was, on the primary screen.
*/
@JvmStatic
fun centreOnMainWindowScreen(dialog: Window) {
val placed = place(null, dialog.width, dialog.height)
if (placed == null) {
dialog.setLocationRelativeTo(null)
} else {
dialog.bounds = placed
}
}
}
@@ -1,5 +1,6 @@
package kst4contest.view.compose.map
import kst4contest.view.compose.WindowPlacementOnScreen
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -10,6 +11,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -59,10 +61,14 @@ object StationMapWindow {
heightDp = heightDp,
onResized = onResized,
) { _ ->
val strings = LocalStrings.current
val window = this.window
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
/*
* Never larger than the smallest screen: AWT enforces a minimum size even past
* the edge, and some operators work on 640x480.
*/
androidx.compose.runtime.LaunchedEffect(window) {
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
}
val tileFetcher = remember { TileFetcher() }
/*
@@ -74,6 +80,37 @@ object StationMapWindow {
onDispose { tileFetcher.close() }
}
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
} // host.show content
}
/**
* The window's body, without the window.
*
* Split out of [show] so it can be drawn headless in a test, with a tile fetcher that
* stays off the network and the user's tile cache, a roster that is not the running
* application's, and a [MapState] the test can read. The defaults are exactly what
* [show] used inline before the split.
*
* @param chatMembers the roster a marker click looks its station up in; a marker stands
* for a raw callsign, and this is where its category variants come from
* @param mapState where the map is looking
* @param pathAnalysis asked each time the selection changes; null means there is no
* running application, and then the map neither pans nor analyses, exactly as
* when no chat controller existed before the split
*/
@Composable
internal fun Content(
mainWindowState: MainWindowState,
tileFetcher: TileFetcher,
chatMembers: () -> List<kst4contest.model.ChatMember> = {
ApplicationRuntimeLauncher.getCurrent()?.chatController?.lst_chatMemberList?.snapshot().orEmpty()
},
mapState: MapState = remember { MapState() },
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
) {
val strings = LocalStrings.current
/*
* The fetcher is plain Java state, so nothing about it recomposes on its
* own. Polled rather than wired through a callback: this drives one line of
@@ -99,7 +136,19 @@ object StationMapWindow {
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
/*
* The markers are the stations the list shows, not all of them (#21): the
* JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and
* the status line below already says "filtered view active". A selected
* station that a filter hides gets no marker either, as it did there.
*/
val markers by remember(
mainWindowState.stations.rows,
mainWindowState.stations.rowFilter,
mainWindowState.stations.contentRevision,
selectedMember,
mainWindowState.activeBands,
) {
derivedStateOf {
val builder = MapCallsignRawSnapshotBuilder()
val activeBands = mainWindowState.activeBands
@@ -110,7 +159,7 @@ object StationMapWindow {
}
builder.buildSnapshots(
mainWindowState.stations.rows,
mainWindowState.stations.visibleRows,
selectedMember,
bandSet
)
@@ -118,19 +167,33 @@ object StationMapWindow {
}
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
val mapState = remember { MapState() }
LaunchedEffect(selectedMember) {
// Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
/*
* Whether the selected station has a marker. A filter can hide it (#21), and a
* station without a locator never has one; then there is nothing to analyse.
*/
val selectedHasMarker = selectedMember != null &&
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) {
if (selectedMember == null) {
pathAnalysisResult = null
return@LaunchedEffect
}
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
if (chatController != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot == null) {
// No marker: no request, and no "Loading" for a station the map does not show.
pathAnalysisResult = null
return@LaunchedEffect
}
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
val analysis = currentPathAnalysis()
if (analysis != null) {
if (selectedSnapshot.hasUsablePosition()) {
/*
* Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only
@@ -151,17 +214,11 @@ object StationMapWindow {
pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat,
selectedSnapshot?.locator6() ?: "",
selectedSnapshot?.callSignRaw() ?: ""
selectedSnapshot.locator6(),
selectedSnapshot.callSignRaw()
)
val overrideBand: kst4contest.model.Band? = null
chatController.reachabilityService.requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
overrideBand
) { result ->
analysis.request(selectedMember, selectedSnapshot) { result ->
pathAnalysisResult = result
}
}
@@ -277,10 +334,8 @@ object StationMapWindow {
maxQrbKm = maxQrbKm,
darkMode = isDarkTheme,
onMarkerClicked = { snapshot ->
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
val variants = chatController?.lst_chatMemberList?.snapshot()
?.filter { it.callSignRaw == snapshot.callSignRaw() }
.orEmpty()
val variants = chatMembers()
.filter { it.callSignRaw == snapshot.callSignRaw() }
/*
* A marker stands for a raw callsign, not a category. Keep the
* category already selected when it is one of the variants.
@@ -298,7 +353,7 @@ object StationMapWindow {
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
}
},
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
)
MapOverlayControls(
@@ -361,8 +416,10 @@ object StationMapWindow {
} // main layout column
} // window column
} // Surface
} // host.show content
}
} // Content
/** Marks the map canvas so a test can click a marker on it. */
internal const val MAP_CANVAS_TAG = "station-map-canvas"
@JvmStatic
fun applyDarkMode(darkMode: Boolean) {
@@ -397,3 +454,34 @@ object StationMapWindow {
}
}
}
/**
* Asks for the path analysis of the station selected on the map.
*
* The seam between the map window and the running application: the window pans to the
* station and starts the analysis only when there is one to ask, which a test supplies
* without building a chat controller.
*/
internal fun interface MapPathAnalysis {
fun request(
member: kst4contest.model.ChatMember,
snapshot: kst4contest.view.map.MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
)
}
/**
* The running application's reachability service, or null before there is one.
*
* Looked up on every call, as the window did inline: a profile switch replaces the runtime.
* No band override -- the map always analyses on the automatically resolved band.
*/
private fun runtimePathAnalysis(): MapPathAnalysis? {
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null
return MapPathAnalysis { member, snapshot, onResult ->
chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result ->
onResult(result)
}
}
}
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
/**
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
*
* Labels, defaults and the closing note are the JavaFX ones, including that the server
* identifier, the client identifier and the frequency mode take effect at once while a
* Labels and defaults are the JavaFX ones. The closing note says that the server
* identifier, the client identifier and the frequency mode take effect when applied while a
* changed UDP port needs a reconnect.
*
* The identifier, port and band fields keep what the operator typed and mark it as an
@@ -1,14 +1,16 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Airscout tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*
* Three properties are guarded before the write, and the guard is not decoration. The
* Three properties are guarded before they are staged, and the guard is not decoration. The
* ChatPreferences setters do not keep the previous value on bad input, they substitute
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
* becomes 9872, a band value that is not a positive number becomes "1440000". The
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
* value are protocol values and neither the defaults nor the bounds are touched.
*/
class AirscoutTabState(private val prefs: ChatPreferences) {
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
var airScout_asUDPListenerEnabled: Boolean
get() = prefs.isAirScout_asUDPListenerEnabled()
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isAirScout_asUDPListenerEnabled() },
) { prefs.setAirScout_asUDPListenerEnabled(it) }
/*
* The drafted values behind the guarded properties below. Only a valid, normalised value
* is staged, so neither the draft nor apply can hand the setters what they would replace
* with the factory default.
*/
private var serverName: String by draft.setting(
{ prefs.getAirScout_asServerNameString() },
) { prefs.setAirScout_asServerNameString(it) }
private var clientName: String by draft.setting(
{ prefs.getAirScout_asClientNameString() },
) { prefs.setAirScout_asClientNameString(it) }
private var communicationPort: Int by draft.setting(
{ prefs.getAirScout_asCommunicationPort() },
) { prefs.setAirScout_asCommunicationPort(it) }
private var bandString: String by draft.setting(
{ prefs.getAirScout_asBandString() },
) { prefs.setAirScout_asBandString(it) }
/** Logical identifier of the target AirScout server; invalid input is ignored. */
var airScout_asServerNameString: String
get() = prefs.getAirScout_asServerNameString()
get() = serverName
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asServerNameString(value.trim())
serverName = value.trim()
}
}
/** Identifier of this KST4Contest instance; invalid input is ignored. */
var airScout_asClientNameString: String
get() = prefs.getAirScout_asClientNameString()
get() = clientName
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asClientNameString(value.trim())
clientName = value.trim()
}
}
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* that changes here.
*/
var airScout_asCommunicationPort: Int
get() = prefs.getAirScout_asCommunicationPort()
get() = communicationPort
set(value) {
if (isValidPort(value)) {
prefs.setAirScout_asCommunicationPort(value)
communicationPort = value
}
}
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* Pick the AirScout frequency per station instead of forcing one value. Uses the
* station's current QRG first, then station-name and chat-category evidence.
*/
var airScout_autoBandSelectionEnabled: Boolean
get() = prefs.isAirScout_autoBandSelectionEnabled()
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
{ prefs.isAirScout_autoBandSelectionEnabled() },
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
/**
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* value produced in the JavaFX field.
*/
var airScout_asBandString: String
get() = prefs.getAirScout_asBandString()
get() = bandString
set(value) {
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
parseBandValue(value)?.let { bandString = it }
}
/**
@@ -31,6 +31,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextMainCat,
commit = state::commitBeaconTextMainCat,
onRefused = onRefused,
current = { state.beaconTextMainCat },
)
}
@@ -43,6 +44,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextSecondCat,
commit = state::commitBeaconTextSecondCat,
onRefused = onRefused,
current = { state.beaconTextSecondCat },
)
}
@@ -52,6 +54,12 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconIntervalMinutes.toString(),
commit = state::commitBeaconInterval,
onRefused = onRefused,
/*
* Compared as the number commitBeaconInterval stores, so " 9" or "09" after
* Enter does not count as a second edit and restart the timer again.
*/
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.beaconIntervalMinutes.toString() },
)
}
}
@@ -2,6 +2,9 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the beacon tab: the two CQ templates and the one interval they share.
@@ -9,34 +12,57 @@ import kst4contest.model.ChatPreferences
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it.
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
* @param restartBeaconTimer called only after the interval actually changed; the
* JavaFX code restarted the shared timer there and nowhere else.
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
* restarted the shared timer there and nowhere else.
*/
class BeaconTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val validateBeaconTemplate: (String) -> Unit,
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
) {
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledMainCat() },
) { prefs.setBcn_beaconsEnabledMainCat(it) }
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledSecondCat() },
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
) { prefs.setBcn_beaconTextMainCat(it) }
private var textSecondCat: String by draft.setting(
{ prefs.getBcn_beaconTextSecondCat() },
) { prefs.setBcn_beaconTextSecondCat(it) }
/*
* Both stored intervals written together, as applySharedBeaconInterval did. The
* second-category value is legacy XML which nothing reads any more, but it is written
* to keep the two in step.
*/
private var interval: Int by draft.setting(
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
effect = { restartBeaconTimer() },
) { minutes ->
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
}
val beaconTextMainCat: String
get() = prefs.getBcn_beaconTextMainCat()
get() = textMainCat
val beaconTextSecondCat: String
get() = prefs.getBcn_beaconTextSecondCat()
get() = textSecondCat
/**
* The interval both categories share, never below the minimum.
@@ -49,16 +75,16 @@ class BeaconTabState(
val beaconIntervalMinutes: Int
get() = maxOf(
ChatController.MIN_BEACON_INTERVAL_MINUTES,
prefs.getBcn_beaconIntervalInMinutesMainCat(),
interval,
)
/** @return null when stored, otherwise the reason it was refused. */
/** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextMainCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
commitBeaconText(template) { textMainCat = it }
/** @return null when stored, otherwise the reason it was refused. */
/** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextSecondCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
commitBeaconText(template) { textSecondCat = it }
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
try {
@@ -69,7 +95,7 @@ class BeaconTabState(
* so a refused template that replaced a working one would silently stop
* the beacon instead of reporting anything.
*/
return "The beacon message is invalid: " + refusal.message
return CurrentStrings.get().beaconInvalidMessage(refusal.message.orEmpty())
}
store(template)
@@ -77,28 +103,18 @@ class BeaconTabState(
}
/**
* Applies the shared interval to both categories and restarts the timer.
* Stages the shared interval for both categories; applying it restarts the timer.
*
* @return null when applied, otherwise the reason it was refused.
* @return null when staged, otherwise the reason it was refused.
*/
fun commitBeaconInterval(entered: String): String? {
val minutes = entered.trim().toIntOrNull()
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
return "Enter a whole beacon interval of at least " +
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
}
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
/*
* The second-category value is legacy XML which nothing reads any more, but
* it is written to keep the two in step, exactly as applySharedBeaconInterval
* did.
*/
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
restartBeaconTimer()
interval = minutes
return null
}
}
@@ -119,6 +119,13 @@ fun ColoursTab(
stored = row.hex,
commit = { typed -> state.set(row.role, typed) },
onRefused = reportRefusal,
/*
* A colour is compared the way PaletteOverrides normalises it -- trimmed,
* case ignored -- so retyping the shown colour in lower case is no edit
* and does not turn it into an override of itself.
*/
isUnchanged = { typed, now -> typed.trim().equals(now.trim(), ignoreCase = true) },
current = { state.roles().single { it.role == row.role }.hex },
)
}
}
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.AwtWindow
import java.awt.FileDialog
@@ -36,8 +37,7 @@ import java.io.File
* back on bad input. Writing through on every keystroke without that guard would be
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
* identifiers, the AirScout port and the AirScout band value substitute the factory
* default on bad input instead of keeping the previous value. It also reports focus
* loss, which is where a port change may rebind a socket.
* default on bad input instead of keeping the previous value.
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
*/
@Composable
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
enabled: Boolean = true,
accepts: (String) -> Boolean = { true },
onAccepted: (String) -> Unit,
onCommit: () -> Unit = {},
) {
/*
* Keyed on the stored value: the draft is only reset when the stored value really
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
* value such as a trailing space that is trimmed away, leaves the typed text alone.
*/
var draft by remember(value) { mutableStateOf(value) }
var hadFocus by remember { mutableStateOf(false) }
/*
* A refused entry stages nothing, so the draft keeps the last accepted value while the
* field shows the refused text in red. Inside the settings window the field refuses the
* apply instead of letting that hidden value go into effect.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
LabelledRow(label) {
CompactTextField(
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
},
enabled = enabled,
isError = !accepts(draft),
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (hadFocus && !focusState.isFocused) {
onCommit()
}
hadFocus = focusState.isFocused
},
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -103,7 +111,7 @@ internal fun FilePathRow(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Form.button("Choose...") { choosing = true }
Form.button(kst4contest.view.i18n.LocalStrings.current.formChoose) { choosing = true }
}
}
@@ -25,31 +25,31 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section(strings.guiStartupDesign) {
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
Form.check(strings.guiStartInEveningDesign, state.gUI_darkModeActiveByDefault) {
state.gUI_darkModeActiveByDefault = it
}
}
Form.flowingSection(strings.guiStartupFilters) {
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
Form.check(strings.guiFilterPublic, state.guiOptions_defaultFilterPublicMsgs) {
state.guiOptions_defaultFilterPublicMsgs = it
}
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
Form.check(strings.guiFilterPmToMe, state.guiOptions_defaultFilterPmToMe) {
state.guiOptions_defaultFilterPmToMe = it
}
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
Form.check(strings.guiFilterPmToOthers, state.guiOptions_defaultFilterPmToOther) {
state.guiOptions_defaultFilterPmToOther = it
}
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
Form.check(strings.guiFilterNothing, state.guiOptions_defaultFilterNothing) {
state.guiOptions_defaultFilterNothing = it
}
}
Form.flowingSection(strings.guiBandColumnHints) {
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
Form.check(strings.guiHintFreshCall, state.guiOptions_showFreshCallHintInBandColumns) {
state.guiOptions_showFreshCallHintInBandColumns = it
}
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
Form.check(strings.guiHintLargeGridWorked, state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
}
}
@@ -76,5 +76,12 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
)
}
/* Appended after the language for the same reason: no section position moves. */
Form.section(strings.guiWindows) {
Form.check(strings.guiRememberWindowPositions, state.guiOptions_rememberWindowPositions) {
state.guiOptions_rememberWindowPositions = it
}
}
}
}
@@ -1,42 +1,34 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.LanguageStore
/**
* The GUI tab as a typed facade over ChatPreferences.
*
* Writes through immediately and has no apply(), like every settings tab: the
* JavaFX controls wrote as they changed and "Save settings" only persisted.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The language is the exception: it switches at once, because the switch is
* itself what the operator wants to see.
*/
class GuiOptionsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val languageStore: LanguageStore,
) {
var gUI_darkModeActiveByDefault: Boolean
get() = prefs.isGUI_darkModeActiveByDefault()
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
var guiOptions_defaultFilterNothing: Boolean
get() = prefs.isGuiOptions_defaultFilterNothing()
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
var guiOptions_defaultFilterPmToMe: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToMe()
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
var guiOptions_defaultFilterPmToOther: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToOther()
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
var guiOptions_defaultFilterPublicMsgs: Boolean
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
var guiOptions_showFreshCallHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
/**
* The interface language, as the picker offers it: a language code, or the empty string
@@ -50,8 +42,8 @@ class GuiOptionsTabState(
get() = languageStore.stored
set(value) { languageStore.use(value) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
}
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
*
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did. "Save settings" persists, it does not collect.
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
* chooser writes the chosen path at once.
*
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
* disabled while its listener is off, as it was there.
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
value = state.logsynch_wintestNetworkPort.toString(),
enabled = state.logsynch_wintestNetworkListenerEnabled,
accepts = { it.trim().toIntOrNull() != null },
// The socket is rebound when the port is applied, not per digit.
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
// Rebinding the socket belongs to the finished entry, not to every digit.
onCommit = { state.commitWintestNetworkPort() },
)
GuardedTextField(
@@ -1,23 +1,24 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Log synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The log file path is the exception: it changes through a file chooser,
* an action, and is written when chosen.
*
* Two things this tab controlled are not settings at all but effects on the running
* session, and no coverage test can see them because they are not ChatPreferences
* setters. They are taken as callbacks rather than from a ChatController so the rules
* stay testable without opening a database:
* One thing this tab controls is not a setting at all but an effect on the running session,
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
* per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
* ChatController so the rules stay testable without opening a database.
*
* - the Win-Test UDP listener is started and stopped with its setting;
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
* default, which the JavaFX window did while building the settings pane.
* The broadcast address the JavaFX window auto-detected while building the pane is detected
* by repairStoredSettings now, before the window is built, not by this state.
*
* Ports, transports and framing are untouched: the accepted values, the defaults and
* the moment a socket is rebound are the ones the JavaFX window had.
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
* ChatController.restartWintestUdpListenerIfEnabled()
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
class LogSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val restartWintestListener: () -> Unit,
private val stopWintestListener: () -> Unit,
private val detectWintestBroadcastAddress: () -> String?,
) {
init {
autoDetectBroadcastAddressWhileStillDefault()
}
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
/**
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
@@ -53,75 +48,73 @@ class LogSynchTabState(
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
/**
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
* accepted any number here and rebound nothing — that listener comes up at start —
* so neither a range nor a restart is added.
*/
var logsynch_ucxUDPWkdCallListenerPort: Int
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
var logsynch_wintestNetworkListenerEnabled: Boolean
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
set(value) {
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
if (value) restartWintestListener() else stopWintestListener()
}
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
/**
* UDP port for the Win-Test listener, default 9871. The setting is written at once
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
* and unbind a UDP port for every digit on the way to the intended one.
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
* for every number on the way to the intended one.
*/
var logsynch_wintestNetworkPort: Int
get() = prefs.getLogsynch_wintestNetworkPort()
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
var logsynch_wintestNetworkPort: Int by draft.setting(
{ prefs.getLogsynch_wintestNetworkPort() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkPort(it) }
/** Applies the entered port to the running listener, if there is one. */
fun commitWintestNetworkPort() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
restartWintestListener()
}
}
/*
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
* trailing space does not count as a change.
*/
private var stationNameOfKst: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfKST() },
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
private var broadcastAddress: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
/** The name KST4Contest uses as source of Win-Test SKED packets. */
var logsynch_wintestNetworkStationNameOfKST: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
get() = stationNameOfKst
set(value) { stationNameOfKst = value.trim() }
/** UDP broadcast address used for sending to Win-Test. */
var logsynch_wintestNetworkBroadcastAddress: String
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
get() = broadcastAddress
set(value) { broadcastAddress = value.trim() }
/**
* Replaces the catch-all default 255.255.255.255 with the detected subnet
* broadcast address. An address the operator or a previous detection chose is
* left alone, and a failed detection keeps the default: guessing an address here
* would send SKED packets somewhere nobody is listening.
* Brings the Win-Test listener in line with the applied switch and port: rebound while
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
* once and binds the new port; a port changed while the listener is off meets a listener
* that is not running, and stopping that does nothing.
*/
private fun autoDetectBroadcastAddressWhileStillDefault() {
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
return
}
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (detected != null && detected.isNotBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
private fun applyWintestListener() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
}
companion object {
/** The stored value that still means "not configured for this network". */
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
/** The effect key the Win-Test switch and port share. */
private const val WINTEST_LISTENER = "wintestListener"
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the message-handling tab.
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
* that: a tab that wrote only the main category would leave the second chat on its
* previous value, which no part of the window ever displayed.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*/
class MessageHandlingTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applyDebugModeToFile: (Boolean) -> Unit = {},
) {
/** Drives both category flags, as the single JavaFX checkbox did. */
var autoAnswerEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerEnabled()
set(value) {
prefs.setMessageHandling_autoAnswerEnabled(value)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
}
var autoAnswerEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerEnabled() },
) { enabled ->
prefs.setMessageHandling_autoAnswerEnabled(enabled)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
}
/** Drives both category texts, as the single JavaFX text field did. */
var autoAnswerText: String
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
set(value) {
prefs.setMessageHandling_autoAnswerTextMainCat(value)
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
}
var autoAnswerText: String by draft.setting(
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
) { text ->
prefs.setMessageHandling_autoAnswerTextMainCat(text)
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
}
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
var autoAnswerToQRGRequestEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
/**
* Records the whole session to file. Beyond the preference it opens or closes the
* error-log handler and the history recorder, which only the application can sequence;
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
*/
var debugModeToFileEnabled: Boolean
get() = prefs.isMessageHandling_debugModeToFileEnabled()
set(value) {
prefs.setMessageHandling_debugModeToFileEnabled(value)
applyDebugModeToFile(value)
}
var debugModeToFileEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_debugModeToFileEnabled() },
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band
import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form
@@ -76,12 +77,18 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
stored = state.notify_dxclusterServerPort.toString(),
commit = state::commitDxClusterServerPort,
onRefused = onRefused,
// Compared as the number commitDxClusterServerPort stores.
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.notify_dxclusterServerPort.toString() },
)
Form.committed(
label = strings.notificationSpotterCallsign,
stored = state.notify_DXCSrv_SpottersCallSign,
commit = state::commitSpotterCallSign,
onRefused = onRefused,
// commitSpotterCallSign stores the callsign trimmed and in upper case.
isUnchanged = { typed, now -> typed.trim().uppercase(java.util.Locale.ROOT) == now },
current = { state.notify_DXCSrv_SpottersCallSign },
)
/*
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
@@ -134,6 +141,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
onValueChange = { entered = it },
singleLine = true,
label = { Text(strings.notificationCallsign) },
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
)
}
},
@@ -149,3 +157,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
)
}
}
/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */
internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field"
@@ -9,137 +9,144 @@ import kst4contest.view.GuiUtils
import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import java.util.Locale
/**
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
* server and the QSO monitoring list.
*
* Three of these reach past the preferences into the running session, which the
* settings coverage test cannot see, so they are constructor callbacks rather than
* plain properties: starting and stopping the server, restarting it after a port
* change, and sending the test spot.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them; the monitoring list too. The test spot is an action and is sent at once.
*
* Two of these reach past the preferences into the running session, which the settings
* coverage test cannot see, so they are constructor callbacks rather than plain
* properties: bringing the server in line with its switch and port, and sending the test
* spot.
*
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
* it lives on the controller, not in the preferences, which is why it is
* passed separately.
* @param applyDxClusterServerEnabled starts or stops the server, the work the
* JavaFX checkbox did beyond writing the flag.
* @param applyDxClusterServerPort restarts the server; called only when the port
* actually changed, as the JavaFX field did.
* @param applyDxClusterServer brings the running server in line with the applied switch
* and port: restarted while enabled, stopped otherwise. The switch and the port
* share it, so one apply restarts the server once, on the new port.
* @param broadcastTestSpot delivers the spot and returns null on success or the
* reason it could not be delivered.
*/
class NotificationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val monitoredRoster: SimpleRoster<String>,
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
private val applyDxClusterServerPort: (Int) -> Unit,
private val applyDxClusterServer: () -> Unit,
private val broadcastTestSpot: (ChatMember) -> String?,
) {
var notify_playSimpleSounds: Boolean
get() = prefs.isNotify_playSimpleSounds()
set(value) { prefs.setNotify_playSimpleSounds(value) }
var notify_playSimpleSounds: Boolean by draft.setting(
{ prefs.isNotify_playSimpleSounds() },
) { prefs.setNotify_playSimpleSounds(it) }
var notify_playCWCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
var notify_bandUpgradeHintOnLogEnabled: Boolean
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
var notify_bandUpgradePriorityBoostEnabled: Boolean
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
private var dxClusterServerEnabled: Boolean by draft.setting(
{ prefs.isNotify_dxClusterServerEnabled() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxClusterServerEnabled(it) }
private var dxClusterServerPort: Int by draft.setting(
{ prefs.getNotify_dxclusterServerPort() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxclusterServerPort(it) }
private var spotterCallSign: String by draft.setting(
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
val notify_dxClusterServerEnabled: Boolean
get() = prefs.isNotify_dxClusterServerEnabled()
get() = dxClusterServerEnabled
val notify_dxclusterServerPort: Int
get() = prefs.getNotify_dxclusterServerPort()
get() = dxClusterServerPort
val notify_DXCSrv_SpottersCallSign: String
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
get() = spotterCallSign
/**
* The band a spot falls back to when its frequency carries no band information.
*
* Stored as a prefix string, so an unreadable value is possible; reading it
* repairs the stored value to 144, which is what the JavaFX window did when it
* built the combo box (Kst4ContestApplication:11349).
/*
* Stored as a prefix string, so an unreadable value is possible. It is shown as 144 here
* and written back as 144 by repairStoredSettings before the window is built -- never
* from this read, which runs during composition.
*/
private var fallbackBand: Band by draft.setting(
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
/** The band a spot falls back to when its frequency carries no band information. */
var notify_optionalFrequencyPrefix: Band
get() {
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
if (stored == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
return Band.B_144
}
return stored
}
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
get() = fallbackBand
set(value) { fallbackBand = value }
val monitoredCallSigns = EditableListState(
monitoredRoster.snapshot(),
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
)
/** Writes the flag and lets the running session start or stop the server. */
/** Stages the server switch; applying it starts or stops the server. */
fun enableDxClusterServer(enabled: Boolean) {
prefs.setNotify_dxClusterServerEnabled(enabled)
applyDxClusterServerEnabled(enabled)
dxClusterServerEnabled = enabled
}
/**
* @return null when applied, otherwise the reason the port was refused.
* @return null when staged, otherwise the reason the port was refused.
*/
fun commitDxClusterServerPort(entered: String): String? {
val previousPort = prefs.getNotify_dxclusterServerPort()
val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) {
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
"Enter a value from 1 to 65535."
return CurrentStrings.get().notificationInvalidPort(entered.trim())
}
prefs.setNotify_dxclusterServerPort(port)
/*
* Restarting on an unchanged port would drop a connected logger for nothing;
* the JavaFX field guarded the restart the same way.
* The stored port staged again is no change, so it restarts nothing: restarting on
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
* guarded the restart the same way.
*/
if (port != previousPort) {
applyDxClusterServerPort(port)
}
dxClusterServerPort = port
return null
}
/**
* @return null when stored, otherwise the reason the callsign was refused.
* @return null when staged, otherwise the reason the callsign was refused.
*/
fun commitSpotterCallSign(entered: String): String? {
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
}
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
this.spotterCallSign = spotterCallSign
return null
}
/**
* Appends a callsign to the monitoring list and fills the roster at once, as the
* JavaFX dialog did.
* Appends a callsign to the monitoring list and stages the list.
*
* @return null when added, otherwise the reason it was refused.
*/
@@ -151,7 +158,7 @@ class NotificationTabState(
}
if (verdict !is CommitOutcome.Stored) {
return "Please enter a valid callsign."
return CurrentStrings.get().notificationInvalidCallsign
}
/*
@@ -163,9 +170,14 @@ class NotificationTabState(
return null
}
/** Fills the monitoring roster from the list. */
/** Stages the list; applying it fills the monitoring roster. */
fun commitMonitoredCallSigns() {
monitoredCallSigns.commitTo(monitoredRoster)
draft.stage(
MONITORED_CALL_SIGNS,
monitoredCallSigns.texts.toList(),
live = { monitoredRoster.snapshot() },
write = { monitoredCallSigns.commitTo(monitoredRoster) },
)
}
/**
@@ -202,13 +214,20 @@ class NotificationTabState(
)
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
return CommitOutcome.Refused("Please enter a valid callsign.")
return CommitOutcome.Refused(CurrentStrings.get().notificationInvalidCallsign)
}
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
return CommitOutcome.Refused(CurrentStrings.get().notificationAlreadyMonitored)
}
return CommitOutcome.Stored(baseCall)
}
private companion object {
/** The effect key the server switch and port share. */
const val DX_CLUSTER_SERVER = "dxClusterServer"
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
}
}
@@ -341,7 +341,7 @@ private fun ProfileDialogs(
)
}
},
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text(strings.dialogApply) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
)
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection
import kst4contest.view.i18n.CurrentStrings
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -57,7 +58,7 @@ class ProfilesTabState(
}
val activeProfileName: String
get() = activeSelection()?.profile?.displayName ?: "unknown"
get() = activeSelection()?.profile?.displayName ?: CurrentStrings.get().profilesUnknown
val preferencesPath: String
get() = activeSelection()?.preferencesAbsolutePath ?: ""
@@ -90,9 +91,7 @@ class ProfilesTabState(
}
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
?: return ProfileNotice.Error(
"The profile could not be created. The profile registry could not be written."
)
?: return ProfileNotice.Error(CurrentStrings.get().profilesCreateFailed)
refresh()
selected = created
@@ -101,10 +100,7 @@ class ProfilesTabState(
* Said explicitly because a new profile that cannot log in looks broken
* otherwise: callsign and password are deliberately not copied or invented.
*/
return ProfileNotice.Info(
"The profile \"" + created.displayName + "\" was created without callsign and " +
"password. Enter them on the Station tab after switching to it."
)
return ProfileNotice.Info(CurrentStrings.get().profilesCreatedWithoutCredentials(created.displayName))
}
fun duplicateSelected(name: String): ProfileNotice? {
@@ -115,7 +111,7 @@ class ProfilesTabState(
}
val duplicate = service.duplicateProfile(profile, name)
?: return ProfileNotice.Error("The profile could not be duplicated.")
?: return ProfileNotice.Error(CurrentStrings.get().profilesDuplicateFailed)
refresh()
selected = duplicate
@@ -124,7 +120,7 @@ class ProfilesTabState(
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
fun duplicateNameSuggestion(): String =
selected?.let { "Copy of " + it.displayName } ?: ""
selected?.let { CurrentStrings.get().profilesCopyOf(it.displayName) } ?: ""
fun renameSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection()
@@ -142,20 +138,16 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile uses the files of the installation itself and cannot be deleted."
)
return ProfileNotice.Error(CurrentStrings.get().profilesRootNotDeletable)
}
if (isActive(profile)) {
return ProfileNotice.Error(
"The profile currently in use cannot be deleted. Switch to another profile first."
)
return ProfileNotice.Error(CurrentStrings.get().profilesActiveNotDeletable)
}
if (!service.deleteProfile(profile)) {
refresh()
return ProfileNotice.Error("The profile could not be deleted.")
return ProfileNotice.Error(CurrentStrings.get().profilesDeleteFailed)
}
refresh()
@@ -164,22 +156,18 @@ class ProfilesTabState(
/** The folder the deletion confirmation names, and what happens to worked stations. */
fun describeDeletion(profile: OperatorProfile): String =
"The following folder is removed permanently:\n" +
service.getProfileDirectory(profile) + "\n\n" +
CurrentStrings.get().profilesDeletionFolder(service.getProfileDirectory(profile).toString()) + "\n\n" +
if (profile.isSharedWorkedDatabase) {
"The common station worked stations are not touched."
CurrentStrings.get().profilesDeletionKeepsShared
} else {
"The worked stations of this profile are deleted as well."
CurrentStrings.get().profilesDeletionRemovesOwn
}
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile always uses the common station worked stations, because " +
"that database is the one of the installation itself."
)
return ProfileNotice.Error(CurrentStrings.get().profilesRootAlwaysShared)
}
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
@@ -190,7 +178,7 @@ class ProfilesTabState(
refresh()
if (isActive(profile)) {
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
return ProfileNotice.Info(CurrentStrings.get().profilesChangeAfterSwitch)
}
return null
@@ -200,7 +188,7 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (isActive(profile)) {
return ProfileNotice.Info("This profile is already active.")
return ProfileNotice.Info(CurrentStrings.get().profilesAlreadyActive)
}
requestActivation(profile)
@@ -217,7 +205,7 @@ class ProfilesTabState(
}
private fun notASelection(): ProfileNotice =
ProfileNotice.Info("Select a profile in the table first.")
ProfileNotice.Info(CurrentStrings.get().profilesSelectFirst)
companion object {
@@ -230,9 +218,9 @@ class ProfilesTabState(
*/
fun describeWorkedDataMode(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"common station database"
CurrentStrings.get().profilesSharedDatabase
} else {
"own database"
CurrentStrings.get().profilesOwnDatabase
}
/** Blank for a profile never used, rather than an epoch date. */
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.SettingsDraft
/**
* State of the shortcuts-and-snippets tab: the two single-column lists from
* `grdPnlShorts`.
*
* Both lists write through to their roster on every committed change. The JavaFX
* tables did the same — their Add buttons called `mutate` on the roster directly —
* and the shortcut buttons above the message field are rebuilt from the roster, so
* a list that only wrote on Save would leave those buttons stale.
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
* message field, the snippet menus -- once per apply, however many edits went before.
*
* @param refreshShortcutButtons rebuilds the button row above the message field,
* as `refreshShortcutButtons` did after every edit commit and move.
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
*/
class ShortcutsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val refreshShortcutButtons: () -> Unit,
private val refreshTextSnippetContextMenus: () -> Unit,
) {
@@ -27,16 +28,26 @@ class ShortcutsTabState(
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
/** Fills the shortcut roster and rebuilds the buttons built from it. */
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
fun commitShortcuts() {
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
refreshShortcutButtons()
draft.stage(
SHORTCUTS,
shortcuts.texts.toList(),
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
effect = refreshShortcutButtons,
)
}
/** Fills the snippet roster and rebuilds the context menus built from it. */
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
fun commitSnippets() {
snippets.commitTo(prefs.getLst_txtSnipList())
refreshTextSnippetContextMenus()
draft.stage(
SNIPPETS,
snippets.texts.toList(),
live = { prefs.getLst_txtSnipList().snapshot() },
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
effect = refreshTextSnippetContextMenus,
)
}
companion object {
@@ -47,5 +58,8 @@ class ShortcutsTabState(
*/
const val NEW_ENTRY_TEMPLATE =
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
private const val SNIPPETS = "ShortcutsTabState.snippets"
}
}
@@ -11,11 +11,13 @@ import kst4contest.view.compose.Form
/**
* The Station tab: own station, server, active bands and path-analysis defaults.
*
* No confirmation callback: StationTabState writes through to ChatPreferences, the
* way the JavaFX controls did. "Save settings" persists, it does not collect.
* The controls stage their edits in the window's draft (see StationTabState); only the
* login credentials write through at once.
*
* @param onRefused shows why a committed entry was refused (the own ground height)
*/
@Composable
fun StationTab(state: StationTabState) {
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
val strings = LocalStrings.current
Column(
@@ -94,12 +96,39 @@ fun StationTab(state: StationTabState) {
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
}
/*
* Only with SO2R, as stored: Form.check and Form.int have no disabled state, and a
* rotator 2 switch without SO2R would do nothing. Switching SO2R on shows the block
* after Apply.
*/
if (state.so2rEnabled) {
Form.section(strings.stationRotator2Section) {
Form.check(strings.stationRotator2Enabled, state.stn_pstRotator2Enabled) { state.stn_pstRotator2Enabled = it }
Form.text(strings.stationRotator2Host, state.stn_pstRotator2Host) { state.stn_pstRotator2Host = it }
Form.int(strings.stationRotator2Port, state.stn_pstRotator2Port) { state.stn_pstRotator2Port = it }
}
}
Form.section(strings.stationPathSection) {
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
/*
* Committed on Enter or on leaving the field, not per keystroke: the stored
* value reads back as "250.0", and a per-keystroke write would feed that back
* into the field while the operator is still typing.
*/
Form.committed(
label = strings.stationOwnHeightAsl,
stored = state.stn_pathAnalysisOwnHeightAslText,
commit = state::commitOwnHeightAsl,
onRefused = onRefused,
isUnchanged = StationTabState::isOwnHeightAslUnchanged,
current = { state.stn_pathAnalysisOwnHeightAslText },
)
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
Form.check(strings.stationTargetHighestPoint, state.stn_pathAnalysisDxHighestPointInSquare) { state.stn_pathAnalysisDxHighestPointInSquare = it }
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
}
}
@@ -1,24 +1,32 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.view.compose.Form
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.CurrentStrings
/**
* The Station tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
* change behaviour an operator may rely on: today a changed setting takes effect
* in the running session at once, whether or not it is ever written to disk.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
* and still write through at once: callsign, names, locator, password and both chat
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
* one they take effect at the next login anyway, so holding them back would only make
* Connect use something other than what the tab shows.
*
* The property list was derived mechanically from the setters the JavaFX tab
* called, intersected with the setters ChatPreferences declares.
*/
class StationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
/**
* Applies the chat category to the running session. Passed in rather than taken
* from a ChatController so the rules stay testable without building a controller,
@@ -26,6 +34,16 @@ class StationTabState(
*/
private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit,
/**
* The state of the ON4KST session. A lambda over Compose state, so the login fields
* follow it while the tab is open; the default suits a state built without a session.
*/
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
/**
* Restarts or stops rotator 2 to match the stored settings; shared by this tab and the
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
*/
private val applySecondRotator: () -> Unit = {},
) {
var loginChatCategoryMain: ChatCategory?
@@ -37,45 +55,25 @@ class StationTabState(
get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double
get() = prefs.getStn_antennaBeamWidthDeg()
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
var stn_bandActive10G: Boolean
get() = prefs.isStn_bandActive10G()
set(value) { prefs.setStn_bandActive10G(value) }
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
var stn_bandActive1240: Boolean
get() = prefs.isStn_bandActive1240()
set(value) { prefs.setStn_bandActive1240(value) }
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
var stn_bandActive144: Boolean
get() = prefs.isStn_bandActive144()
set(value) { prefs.setStn_bandActive144(value) }
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
var stn_bandActive2300: Boolean
get() = prefs.isStn_bandActive2300()
set(value) { prefs.setStn_bandActive2300(value) }
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
var stn_bandActive3400: Boolean
get() = prefs.isStn_bandActive3400()
set(value) { prefs.setStn_bandActive3400(value) }
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
var stn_bandActive432: Boolean
get() = prefs.isStn_bandActive432()
set(value) { prefs.setStn_bandActive432(value) }
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
var stn_bandActive50: Boolean
get() = prefs.isStn_bandActive50()
set(value) { prefs.setStn_bandActive50(value) }
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
var stn_bandActive5600: Boolean
get() = prefs.isStn_bandActive5600()
set(value) { prefs.setStn_bandActive5600(value) }
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
var stn_bandActive70: Boolean
get() = prefs.isStn_bandActive70()
set(value) { prefs.setStn_bandActive70(value) }
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign()
@@ -97,57 +95,123 @@ class StationTabState(
get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double
get() = prefs.getStn_maxQRBDefault()
set(value) { prefs.setStn_maxQRBDefault(value) }
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
var stn_on4kstServersDns: String
get() = prefs.getStn_on4kstServersDns()
set(value) { prefs.setStn_on4kstServersDns(value) }
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
var stn_on4kstServersPort: Int
get() = prefs.getStn_on4kstServersPort()
set(value) { prefs.setStn_on4kstServersPort(value) }
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
var stn_pathAnalysisDemRootDirectory: String
get() = prefs.getStn_pathAnalysisDemRootDirectory()
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
var stn_pathAnalysisOwnAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
var stn_pathAnalysisOwnTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
var stn_pstRotatorEnabled: Boolean
get() = prefs.isStn_pstRotatorEnabled()
set(value) { prefs.setStn_pstRotatorEnabled(value) }
/**
* Own ground elevation above sea level in metres, NaN while the elevation model decides.
* Staged like every path-analysis setting; the reachability service reads the stored
* value, so a changed height reaches the analysis only once it is applied.
*/
var stn_pathAnalysisOwnHeightAslMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnHeightAslMeters() }) { prefs.setStn_pathAnalysisOwnHeightAslMeters(it) }
var stn_pstRotatorHost: String
get() = prefs.getStn_pstRotatorHost()
set(value) { prefs.setStn_pstRotatorHost(value) }
/** Whether the path ends at the highest point of the remote locator square. */
var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
var stn_pstRotatorPort: Int
get() = prefs.getStn_pstRotatorPort()
set(value) { prefs.setStn_pstRotatorPort(value) }
/**
* The entry last committed to the own-height field, as the operator typed it.
*
* [Form.committed] keys its draft text on the stored text, and the staged number reads
* back in its own spelling ("25.0" for "25"). The settings draft is Compose state, so
* the commit itself recomposes the field with the new key, and without this the field
* would swap "25" for "25.0" under the cursor right after Enter, or drop keys typed
* since. Handing back the committed spelling while it still names the same number
* keeps key and text in agreement.
*/
private var committedOwnHeightAslText by mutableStateOf<String?>(null)
var stn_qtfDefault: Double
get() = prefs.getStn_qtfDefault()
set(value) { prefs.setStn_qtfDefault(value) }
/**
* The own ground elevation as its field shows it: empty while the elevation model
* decides. The spelling of the last commit while it still names the drafted number,
* otherwise that number itself (discarded, applied elsewhere, or never committed here).
* Written only through [commitOwnHeightAsl].
*/
val stn_pathAnalysisOwnHeightAslText: String
get() {
val drafted = stn_pathAnalysisOwnHeightAslMeters.let { if (it.isFinite()) it.toString() else "" }
val committed = committedOwnHeightAslText
return if (committed != null && isOwnHeightAslUnchanged(committed, drafted)) committed else drafted
}
/**
* Commits an own ground elevation entry into the draft. Empty hands the elevation back
* to the model; a number in range is staged; anything else is refused and nothing is
* staged.
*
* @return null when staged, otherwise the reason shown to the operator
*/
fun commitOwnHeightAsl(entered: String): String? {
val meters = parseOwnHeightAsl(entered)
?: return CurrentStrings.get().stationInvalidOwnHeightAsl(entered.trim())
stn_pathAnalysisOwnHeightAslMeters = meters
committedOwnHeightAslText = entered.trim()
return null
}
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
/**
* Refused on apply when it clashes with rotor 2's port as it will be stored, but only
* while rotor 2 is in use (SO2R on and rotor 2 enabled), so a station without SO2R is
* never held back by rotor 2's default port. See [portsClash].
*/
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { port ->
if (prefs.isTrxSynch_so2rEnabled() && stn_pstRotator2Enabled) {
require(!portsClash(port, stn_pstRotator2Port)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
}
prefs.setStn_pstRotatorPort(port)
}
var stn_pstRotator2Enabled: Boolean by draft.setting(
{ prefs.isStn_pstRotator2Enabled() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Enabled(it) }
var stn_pstRotator2Host: String by draft.setting(
{ prefs.getStn_pstRotator2Host() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Host(it) }
/**
* Refused on apply when it is the same as or adjacent to rotator 1's port as it will be
* stored (see [portsClash]). Compared with the drafted value of rotator 1, so changing
* both in one apply is judged by the result, not by the order of the edits.
*/
var stn_pstRotator2Port: Int by draft.setting(
{ prefs.getStn_pstRotator2Port() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { port ->
require(!portsClash(port, stn_pstRotatorPort)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
prefs.setStn_pstRotator2Port(port)
}
/** Whether the rotator 2 block is active; follows the stored SO2R switch. */
val so2rEnabled: Boolean
get() = prefs.isTrxSynch_so2rEnabled()
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
/**
* The chat category is held twice: in ChatPreferences and in ChatController.
@@ -164,17 +228,21 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean
get() = prefs.isLoginToSecondChatEnabled()
set(value) { prefs.setLoginToSecondChatEnabled(value) }
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
* password, name, locator and both category pickers once the session was logged
* in: changing them there would not reach the running connection, so an edit
* would look applied and be ignored until the next login.
*
* Locked for every state but DISCONNECTED: a reconnect wait or a session being
* stopped still belongs to the credentials it was started with. Derived on each read
* rather than held, so a composition reading it follows the connection state the
* moment that changes, provided [connectionState] reads Compose state.
*/
var loginFieldsEnabled: Boolean = true
val loginFieldsEnabled: Boolean
get() = connectionState() == On4KstConnectionState.DISCONNECTED
/**
* The categories the operator can log into, built the way the JavaFX choice box
@@ -210,4 +278,40 @@ class StationTabState(
loginChatCategorySecond = category
applySecondCategory(category)
}
companion object {
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
const val SECOND_ROTATOR: String = "secondRotator"
/**
* Whether two PSTRotator ports collide: the same port, or one apart because
* PSTRotator answers on port + 1, which would be the other client's local port.
*/
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
/**
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
* number in the accepted range is taken, anything else is refused with null.
* A comma is read as the decimal separator, as in the other number fields.
*/
fun parseOwnHeightAsl(text: String): Double? {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return Double.NaN
}
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
}
/**
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
* a draft that does not parse is never unchanged, so leaving the field refuses it.
*/
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
val typed = parseOwnHeightAsl(draft) ?: return false
val now = parseOwnHeightAsl(stored) ?: return false
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
}
}
}
@@ -0,0 +1,41 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
/**
* Corrects stored settings the settings window cannot show as they are, once, before the
* window is built.
*
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
* did while building its panes. They used to happen inside the tab states -- one during a
* read in composition, one in a constructor -- where a write to the preferences is easy to
* miss and runs whenever Compose decides to read. Here they run at one known moment.
*
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
* while it is still the catch-all default 255.255.255.255. An address the operator or a
* previous detection chose is left alone, and a failed detection keeps the default:
* guessing an address would send SKED packets somewhere nobody is listening.
*
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
}
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (!detected.isNullOrBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
}
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current".
*
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did.
* The controls stage their edits in the window's draft (see TrxSynchTabState).
*/
@Composable
fun TrxSynchTab(state: TrxSynchTabState) {
@@ -63,6 +62,24 @@ fun TrxSynchTab(state: TrxSynchTabState) {
)
}
Form.section(strings.trxSynchSo2rSection) {
Form.check(
strings.trxSynchSo2rEnabled,
state.trxSynch_so2rEnabled,
) { state.trxSynch_so2rEnabled = it; revision++ }
GuardedTextField(
label = strings.trxSynchWinTestStationRadio2,
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
enabled = state.trxSynch_so2rEnabled,
onAccepted = {
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
revision++
},
)
Text(strings.trxSynchSo2rHint)
}
Form.section(strings.trxSynchHintSection) {
Text(strings.trxSynchHint)
}
@@ -1,12 +1,14 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The TRX synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*
* The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
@@ -18,34 +20,42 @@ import kst4contest.model.ChatPreferences
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
* cannot read a checked box as "my QRG is being kept current".
*
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
* follower as a trailing lambda keep binding to it.
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller.
*/
class TrxSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applySecondRotator: () -> Unit = {},
private val applyOwnQrgFollower: (Boolean) -> Unit,
) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
set(value) {
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
var logsynch_wintestQrgSyncEnabled: Boolean
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
set(value) {
prefs.setLogsynch_wintestQrgSyncEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean
get() = prefs.isLogsynch_wintestUsePassQrg()
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
{ prefs.isLogsynch_wintestUsePassQrg() },
) { prefs.setLogsynch_wintestUsePassQrg(it) }
// Staged trimmed, so the draft holds what will be stored.
private var stationFilter: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
/**
* Win-Test station name filter. An empty filter accepts every station, which is
@@ -53,8 +63,29 @@ class TrxSynchTabState(
* way the JavaFX field committed it.
*/
var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
get() = stationFilter
set(value) { stationFilter = value.trim() }
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
var trxSynch_so2rEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_so2rEnabled() },
effectKey = StationTabState.SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setTrxSynch_so2rEnabled(it) }
private var stationRadio2: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
var logsynch_wintestNetworkStationNameOfWintestClient2: String
get() = stationRadio2
set(value) { stationRadio2 = value.trim() }
/* Read from the stored values: it runs on apply, after both switches are written. */
private fun applyFollower() {
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
}
/**
* True when at least one source may update the own QRG. One source switched off is
@@ -74,5 +105,8 @@ class TrxSynchTabState(
* would read whichever one the tab happened to use.
*/
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
/** The effect key both QRG sources share. */
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
}
}
@@ -0,0 +1,69 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
@OptIn(ExperimentalTestApi::class)
class ChatInputRowSo2rScreenTest {
@TempDir
lateinit var tempDir: Path
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
getActualQTF().set(145.0)
getActualQTF2().set(270.0)
}
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
})
@Test
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
val prefs = prefs()
var so2r by mutableStateOf(false)
setContent {
ChatInputRow(
state = chatInputStateFor(prefs),
prefs = prefs,
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
antennaQtf = ObservedValue(prefs.getActualQTF()),
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
so2rEnabled = so2r,
)
}
onAllNodesWithText("270.0").assertCountEquals(0)
so2r = true
waitForIdle()
onNodeWithText("270.0").assertExists()
onNodeWithText("270.0").assertIsNotEnabled()
}
}
@@ -322,6 +322,26 @@ class DataTableStateTest {
assertSame(kept, table.selectedRow)
}
/**
* The filter reads fields of the row objects, which change in place: a station's QRB,
* its worked flags, its last activity. forceRedraw is how the application says so, and
* the cached visible rows must be filtered again after it (#21).
*/
@Test
fun `a redraw filters rows again whose fields changed in place`() {
val table = stationTable()
val moving = Station("DN9APW", 100)
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
table.rowFilter = { it.qrb > 300 }
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
moving.qrb = 500
table.forceRedraw()
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
"the cached rows from before the change were handed back")
}
}
/**
@@ -20,6 +20,8 @@ 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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The four ways the drawing layer went wrong while the build was green.
@@ -41,7 +43,7 @@ import org.junit.jupiter.api.Test
* 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 {
class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
@@ -52,9 +54,9 @@ class DrawingLayerRegressionTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
// The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) })
@@ -0,0 +1,54 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The size and position the host fitted a window to are not the operator's: the first
* matching report is swallowed, and nothing after it.
*/
class FitRecordTest {
private val fitted = Rectangle(1920, 0, 640, 480)
@Test
fun `nothing counts as fitted before a fit`() {
val fit = FitRecord()
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `the fit echoing back is recognised despite rounding and a nudge`() {
val fit = FitRecord()
fit.record(fitted)
assertTrue(fit.takeSize(641, 479))
assertTrue(fit.takePosition(Point(1920 + 10, 0 + 28)))
}
@Test
fun `a fit is consumed once, so a later identical resize or move is the operator's`() {
val fit = FitRecord()
fit.record(fitted)
fit.takeSize(640, 480)
fit.takePosition(Point(1920, 0))
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `an operator report in between clears the record`() {
val fit = FitRecord()
fit.record(fitted)
assertFalse(fit.takeSize(900, 700))
assertFalse(fit.takePosition(Point(100, 100)))
assertFalse(fit.takeSize(640, 480))
}
}
@@ -0,0 +1,26 @@
package kst4contest.view.compose
/**
* A draft that applies every change the moment it is staged.
*
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
* an edit triggers -- without the draft in between. The draft itself is pinned by
* SettingsDraftTest, and that every drafted setting really waits for Apply by
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
* over one unnoticed.
*/
internal class ImmediateDraft : SettingsDraft() {
override fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String?,
effect: (() -> Unit)?,
) {
super.stage(key, value, live, write, effectKey, effect)
val outcome = apply()
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
}
}
@@ -0,0 +1,323 @@
package kst4contest.view.compose
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.PaletteRole
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The in-window menu row, drawn and clicked (#107).
*
* [MainMenuModelTest] covers the bar as data. This covers what the operator actually meets:
* a title that opens, an item that runs its own action, a label that follows the state while
* the row is on screen. Two regressions are the reason it exists:
*
* - `fe4215bc` (Remove JavaFX, in `main` as `2a5c89b9`): the sixteen menu actions still hung
* on JavaFX `MenuItem`s that had been deleted, so picking them in the Compose menu did
* nothing. Found by a GUI acceptance run, never by the build. They were moved onto their
* own `menuAction…` methods behind [MainMenuActions].
* - `83e23f55` (Draw the menu bar in Compose, in `main` as `2a5c89b9`): Compose's `MenuBar`
* is a Swing `JMenuBar` that no theme reaches, so Linux showed the default Metal menu above
* a themed window. The row is now Compose and reads the stylesheet palette.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class MainMenuRowScreenTest {
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 val rowTag = "menu-row"
private fun ComposeUiTest.showRow(
state: MainMenuState,
actions: MainMenuActions,
rowState: MainMenuRowState = MainMenuRowState(),
settingsOpen: () -> Boolean = { false },
monitorOpen: () -> Boolean = { false },
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = state,
actions = actions,
settingsWindowOpen = settingsOpen(),
monitorWindowOpen = monitorOpen(),
rowState = rowState,
modifier = Modifier.testTag(rowTag),
)
}
}
}
private fun onlineState() = MainMenuState().apply {
connectionState = On4KstConnectionState.ONLINE
connectLabel = "Connect to 144/432 MHz"
}
/**
* Every item, picked through the drawn row, reaches its own action and nobody else's.
* This is the `fe4215bc` fault in the form the operator saw it: the item was there and
* picking it did nothing.
*/
@Test
fun everyItemPickedInTheRowRunsItsOwnAction() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
val rowState = MainMenuRowState()
showRow(state, actions, rowState)
// Online, so Disconnect is usable and Connect is not; Connect has its own test below.
val expected = listOf(
Triple("File", "Disconnect", "disconnect"),
Triple("File", "Switch operator profile...", "switchOperatorProfile"),
Triple("File", "Exit + disconnect", "exitApplication"),
Triple("Options", "Set QRG as name in Chat (main category)", "setQrgAsNameInChat"),
Triple("Options", "Show me as AWAY FROM chat!", "toggleAwayState"),
Triple("Options", "Show options", "toggleSettingsWindow"),
Triple("Windows", "Show cluster / stranger QSOs", "toggleMonitorWindow"),
Triple("Windows", "Show / hide station map", "toggleStationMap"),
Triple("Windows", "Use dark mode design", "useDarkDesign"),
Triple("Windows", "Use default mode design", "useDefaultDesign"),
Triple("Info", "Donate for kst4Contest development via PayPal", "openDonationPage"),
Triple("Info", "Donate for OV3T´s plane feed service", "openOv3tDonationPage"),
Triple("Info", "Visit DARC X08-Homepage", "openHomepage"),
Triple("Info", "Join kst4Contest newsgroup", "openNewsgroup"),
Triple("Info", "Contact the author using default mail app", "contactAuthor"),
Triple("Info", "About...", "showAbout"),
)
expected.forEach { (menu, item, action) ->
onNodeWithText(menu).performClick()
awaitNode(inPopup(item)).performClick()
awaitGone(inPopup(item))
assertEquals(action, actions.calls.lastOrNull(), "'$menu > $item' ran the wrong action")
assertEquals(null, rowState.openMenu, "'$menu' stayed down after '$item' was picked")
}
assertEquals(
expected.size,
actions.calls.size,
"an item ran more than one action, or one ran none",
)
}
/** Offline, Connect works; once an attempt runs it is greyed out and Disconnect works. */
@Test
fun theConnectItemsFollowTheConnectionStateWhileTheMenuIsDown() = runComposeUiTest {
val actions = RecordingActions()
val state = MainMenuState().apply { connectLabel = "Connect to 144/432 MHz" }
showRow(state, actions)
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz")).assertIsEnabled()
onNode(inPopup("Disconnect")).assertIsNotEnabled()
onNode(inPopup("Connect to 144/432 MHz")).performClick()
awaitGone(inPopup("Connect to 144/432 MHz"))
assertEquals(listOf("connect"), actions.calls, "Connect did not run while offline")
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz"))
// The state changes from elsewhere -- the connection callback -- with the menu down.
runOnIdle { state.connectionState = On4KstConnectionState.CONNECTING }
waitForIdle()
onNodeWithText("Connect to 144/432 MHz").assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsEnabled()
onNodeWithText("Connect to 144/432 MHz").performClick()
waitForIdle()
assertEquals(listOf("connect"), actions.calls, "a disabled item ran its action")
}
/**
* The away item's label is the only place the AFK state is shown at all. JavaFX flipped
* it inside the click handler, which left it wrong whenever the state changed from
* elsewhere; here it must follow the state, drawn.
*/
@Test
fun theAwayLabelFollowsTheStateAndNotTheClick() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
showRow(state, actions)
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").performClick()
waitForIdle()
// The click alone changes nothing: the action did not touch the state.
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").assertExists()
runOnIdle { state.awayFromChat = true }
waitForIdle()
onNodeWithText("Show me as ACTIVE in chat!").assertExists()
onNodeWithText("Show me as AWAY FROM chat!").assertDoesNotExist()
}
/** Both toggles name the next action, and follow windows that close on their own. */
@Test
fun theWindowTogglesFollowWhetherTheWindowIsOpen() = runComposeUiTest {
var monitorOpen by mutableStateOf(false)
showRow(onlineState(), RecordingActions(), monitorOpen = { monitorOpen })
onNodeWithText("Windows").performClick()
waitForIdle()
onNodeWithText("Show cluster / stranger QSOs").assertExists()
runOnIdle { monitorOpen = true }
waitForIdle()
onNodeWithText("Hide cluster / stranger QSOs").assertExists()
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
}
/** A second click on an open title closes it again, the way a menu bar behaves. */
@Test
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
val rowState = MainMenuRowState()
showRow(onlineState(), RecordingActions(), rowState)
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals("Info", rowState.openMenu)
onNodeWithText("About...").assertExists()
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals(null, rowState.openMenu)
onNodeWithText("About...").assertDoesNotExist()
}
/**
* The `83e23f55` fault: the bar must be drawn in the palette, and a change of the palette
* must reach it while it is on screen. Measured on the rendered row, to the right of the
* last title, where nothing but the row's own background is drawn.
*/
@Test
fun theRowIsDrawnInThePaletteAndFollowsAChange() = runComposeUiTest {
var stored = ""
val store = PaletteStore(
shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") },
fileRolesOf = { emptyMap() },
storedOverridesOf = { stored },
storeOverrides = { _, encoded -> stored = encoded },
)
setContent {
CompositionLocalProvider(LocalPaletteStore provides store) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
modifier = Modifier.testTag(rowTag),
)
}
}
}
waitForIdle()
assertEquals(store.shipped(false).base, rightEdgeColour(), "the row is not in the shipped base")
runOnIdle { store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") }
waitForIdle()
assertEquals(Color(0xFFFF0000), rightEdgeColour(), "a palette change did not reach the row")
}
private fun ComposeUiTest.rightEdgeColour(): Color {
val pixels = onNodeWithTag(rowTag).captureToImage().toPixelMap()
// Well inside the row's right end, clear of any edge antialiasing.
return pixels[pixels.width - 12, pixels.height / 2]
}
/** A language picked after the first draw reaches the titles and the items. */
@Test
fun aLanguageChosenWhileTheRowIsShownReachesIt() = runComposeUiTest {
var storedLanguage = "en"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.UK,
)
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
)
}
}
}
onNodeWithText("File").assertExists()
try {
runOnIdle { languages.use("de") }
waitForIdle()
onNodeWithText("Datei").performClick()
waitForIdle()
onNodeWithText("Trennen").assertExists()
onNodeWithText("File").assertDoesNotExist()
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
}
@@ -1,6 +1,7 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -123,36 +124,79 @@ class MainWindowFrameTest {
* 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.
* JO50JP (73 users online, 73 shown), 29 messages total." The first Compose title took
* the connection manager's diagnostic instead and read "ON4KST session is authenticated
* and synchronized" for the whole contest.
*/
@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."
val line = MainWindowFrame.onlineChatState(
mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = null,
callSign = "DN5PW",
name = "Testing",
locator = "JO50JP",
usersOnline = 73,
shown = 70,
messagesTotal = 29,
)
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail))
assertEquals(
"Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
"(73 users online, 70 shown), 29 messages total.",
line,
)
assertEquals(line, MainWindowFrame.chatState(On4KstConnectionState.ONLINE) { line })
}
@Test
fun `both categories are named when the second chat is logged in`() {
val line = MainWindowFrame.onlineChatState(
mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = ChatCategory(ChatCategory.MICROWAVE),
callSign = "DN5PW",
name = "",
locator = "JO50JP",
usersOnline = 1,
shown = 1,
messagesTotal = 1,
)
assertEquals(
"Connected to: 2: 144/432 MHz and 3: Microwave as DN5PW () in JO50JP " +
"(1 users online, 1 shown), 1 messages total.",
line,
)
}
@Test
fun `without a main category the title does not name an empty one`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.onlineChatState(null, null, "DN5PW", "x", "JO50JP", 1, 1, 1),
)
}
@Test
fun `large counts are not grouped by locale`() {
val line = MainWindowFrame.onlineChatState(ChatCategory(ChatCategory.VUHF), null, "DN5PW", "x", "JO50JP", 1234, 1200, 23456)
assertTrue(line.contains("(1234 users online, 1200 shown), 23456 messages total."), line)
}
@Test
fun `an unusable chat says what it is doing instead`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"),
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING) { error("not online") },
)
}
@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, " "),
)
fun `no state at all reads as disconnected without building the online line`() {
assertEquals(
MainWindowFrame.offlineChatState(null),
MainWindowFrame.chatState(null, null),
MainWindowFrame.chatState(null) { error("not online") },
)
}
}
@@ -6,6 +6,8 @@ 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 org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
@@ -13,11 +15,11 @@ import org.junit.jupiter.api.Test
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest {
class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -31,7 +33,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -44,7 +46,7 @@ class MainWindowStateRosterTest {
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -60,7 +62,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -79,9 +81,9 @@ class MainWindowStateRosterTest {
* 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 {
class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
private val prefs = ChatPreferences()
private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
@Test
fun `the message column has as many dividers as the profile stores positions`() {
@@ -0,0 +1,158 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The cluster and QSO monitor's body, drawn with live tables (#107).
*
* This was the first window whose tables fill while the operator watches, and the defects
* that the Compose port shipped here were all about the coupling between a live list and
* what gets drawn, not about the table itself (`cc8c9020`, Draw the whole interface in
* Compose; in `main` as `2a5c89b9`):
*
* - The monitor sat empty beside a filling JavaFX tab and only redrew when an unrelated
* click forced it: `DataTableState.visibleRows` returned its cache before reading the row
* state, so a recomposition that hit the cache left the table subscribed to nothing.
* - Rows without identity were keyed in a way that could hand out the same key twice, which
* a `LazyColumn` refuses; two messages that read the same are still two messages.
*
* The tables here are built exactly as `Kst4ContestApplication` builds them: the monitor
* columns, keyed by reference.
*
* Headless: [MonitorContent] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class MonitorWindowScreenTest {
private fun clusterTable() =
DataTableState(MonitorColumns.dxCluster(), RowKeys.byReference<ClusterMessage>(), "dx-cluster-monitor")
private fun qsoTable() =
DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference<ChatMessage>(), "qso-other-monitor")
private fun member(call: String) = ChatMember().apply { callSign = call }
private fun spot(from: String, to: String, text: String) = ClusterMessage().apply {
sender = member(from)
receiver = member(to)
messageInhibited = text
}
private fun qso(from: String, to: String, text: String) = ChatMessage().apply {
sender = member(from)
receiver = member(to)
messageText = text
}
private fun ComposeUiTest.show(
cluster: DataTableState<ClusterMessage>,
qsos: DataTableState<ChatMessage>,
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MonitorContent(cluster, qsos)
}
}
waitForIdle()
}
@Test
fun bothTablesAreDrawnUnderTheirHeadings() = runComposeUiTest {
show(clusterTable(), qsoTable())
onNodeWithText("DX cluster").assertExists()
onNodeWithText("QSO of the other").assertExists()
onNodeWithText("Call tx").assertExists()
onNodeWithText("Call TX").assertExists()
}
@Test
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "144.300 tnx qso")))
qsos.replaceRows(listOf(qso("DL2CCC", "OK2DDD", "pse sked 432")))
}
waitForIdle()
onNodeWithText("144.300 tnx qso").assertExists()
onNodeWithText("pse sked 432").assertExists()
}
/**
* The empty-monitor fault in its exact shape: the table recomposes once for a reason that
* is not its rows -- here a column moved by the operator -- and the rows that arrive after
* that must still come through.
*/
@Test
fun rowsStillArriveAfterARedrawThatWasNotAboutTheRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle { cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "first spot"))) }
waitForIdle()
onNodeWithText("first spot").assertExists()
// A redraw with the order cache warm.
runOnIdle { cluster.moveColumn("message", 0) }
waitForIdle()
runOnIdle {
cluster.replaceRows(
listOf(spot("DL3EEE", "OK3FFF", "second spot")) + cluster.rows,
)
}
waitForIdle()
onNodeWithText("second spot").assertExists()
onNodeWithText("first spot").assertExists()
}
/** Two spots that read alike are two rows; neither is dropped and the list does not throw. */
@Test
fun twoIdenticalLookingMessagesAreTwoRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle {
cluster.replaceRows(
listOf(
spot("DL1AAA", "OK1BBB", "repeated spot"),
spot("DL1AAA", "OK1BBB", "repeated spot"),
),
)
}
waitForIdle()
assertEquals(2, onAllNodesWithText("repeated spot").fetchSemanticsNodes().size)
}
/** A half-filled message off the wire draws as empty cells, not as a crash. */
@Test
fun aMessageWithoutSenderOrReceiverStillDraws() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(ClusterMessage().apply { messageInhibited = "orphan spot" }))
qsos.replaceRows(listOf(ChatMessage().apply { messageText = "orphan qso" }))
}
waitForIdle()
onNodeWithText("orphan spot").assertExists()
onNodeWithText("orphan qso").assertExists()
}
}
@@ -0,0 +1,65 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.awt.Point
/**
* Which reported positions of an opened window count as the operator's move (issue #59).
*/
class MoveTrackerTest {
private val opened = Point(560, 220)
private fun offset(dx: Int, dy: Int = 0) = Point(opened.x + dx, opened.y + dy)
@Test
fun `a nudge around the placed position is not remembered`() {
val tracker = MoveTracker(opened)
assertNull(tracker.observe(offset(0, 30)))
assertNull(tracker.observe(offset(-MOVE_TOLERANCE_PX)))
}
@Test
fun `a move away from the placed position is remembered`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
}
/** The review case: open at O, drag to O+100, drag back to O+10. */
@Test
fun `a move back to near the opening spot replaces the far one`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
assertEquals(offset(10), tracker.observe(offset(10)))
assertEquals(opened, tracker.observe(opened))
}
@Test
fun `an unplaced window takes the platform's placement jump as its anchor`() {
val tracker = MoveTracker(null)
/* Provisional position, then the window manager's real placement on another screen. */
assertNull(tracker.observe(Point(0, 0)))
assertNull(tracker.observe(Point(2400, 300)))
/* A decoration nudge settles it. */
assertNull(tracker.observe(Point(2400, 330)))
/* Now the operator drags. */
assertEquals(Point(2600, 330), tracker.observe(Point(2600, 330)))
}
@Test
fun `an unplaced window settled by the start of a drag remembers the rest of it`() {
val tracker = MoveTracker(null)
assertNull(tracker.observe(Point(500, 300)))
assertNull(tracker.observe(Point(510, 300)))
assertNull(tracker.observe(Point(540, 300)))
assertEquals(Point(700, 300), tracker.observe(Point(700, 300)))
assertEquals(Point(520, 300), tracker.observe(Point(520, 300)))
}
}
@@ -5,6 +5,8 @@ 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 org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
@@ -15,11 +17,11 @@ import org.junit.jupiter.api.Test
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField.
*/
class ObservedValueTest {
class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test
fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -30,7 +32,7 @@ class ObservedValueTest {
/** 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()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF())
@@ -41,7 +43,7 @@ class ObservedValueTest {
@Test
fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null)
@@ -55,7 +57,7 @@ class ObservedValueTest {
*/
@Test
fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -73,7 +75,7 @@ class ObservedValueTest {
*/
@Test
fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -85,7 +87,7 @@ class ObservedValueTest {
@Test
fun `releasing twice is harmless`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
observed.release()
@@ -0,0 +1,45 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
/*
* Waiting for what a click opens on top of the window (#107).
*
* In Compose Multiplatform 1.8.2 a Material3 AlertDialog goes through the skiko Dialog, and a
* DropdownMenu through the skiko Popup; both compose their content into a ComposeSceneLayer of
* the same scene (Dialog_skikoKt calls rememberComposeSceneLayer, checked in the bytecode of
* ui-desktop 1.8.2). The layer's content is set from the composition that opened it, so it can
* take one more frame than the click itself. These helpers wait for the node instead of
* assuming that frame has already happened, and they look only inside the overlay, so a text
* that also stands in the window below can never be picked by accident.
*/
private const val OVERLAY_TIMEOUT_MILLIS = 5_000L
/** Waits until a node matching [matcher] is in the tree, then returns it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher): SemanticsNodeInteraction {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() }
return onNode(matcher)
}
/** Waits until no node matches [matcher] any more. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitGone(matcher: SemanticsMatcher) {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isEmpty() }
}
/** A text inside an open dialog. */
internal fun inDialog(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isDialog())
/** A text inside an open popup, which is what a dropdown menu is. */
internal fun inPopup(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isPopup())
@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The factory binds the store to one profile's preferences and one profile's stylesheet.
@@ -15,7 +17,7 @@ import java.io.File
* 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 {
class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
private val daylight = OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
@@ -24,7 +26,7 @@ class PaletteStoreFactoryTest {
@Test
fun aChangeLandsInTheDesignsOwnPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -35,7 +37,7 @@ class PaletteStoreFactoryTest {
@Test
fun anEveningChangeLandsInTheEveningPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -47,7 +49,7 @@ class PaletteStoreFactoryTest {
@Test
fun whatIsAlreadyStoredIsInForceFromTheStart() {
// The store is built at startup; a colour saved last session has to be there at once.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -57,7 +59,7 @@ class PaletteStoreFactoryTest {
@Test
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -73,7 +75,7 @@ class PaletteStoreFactoryTest {
file.writeText(".root { -fx-accent: #123456; }")
val store = PaletteStoreFactory.create(
ChatPreferences(),
ChatPreferences(tempDir.resolve("preferences.xml")),
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
)
@@ -0,0 +1,49 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import java.nio.file.Path
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
/**
* The opt-in gate of the stored window positions: off means nothing is restored and
* nothing is written, not even a request to save.
*/
class PreferencesWindowPositionMemoryTest {
@TempDir
lateinit var directory: Path
private val stored = StoredWindowPosition(Point(2100, 40), Rectangle(1920, 0, 2560, 1440))
@Test
fun `with the opt-in off nothing is written or restored`() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.setWindowPosition("Map", 1, 2, 0, 0, 10, 10)
var saves = 0
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
memory.save("settings-window", stored)
assertEquals(0, saves)
assertNull(prefs.getWindowPosition("settings-window"))
assertNull(memory.load("Map"))
}
@Test
fun `with the opt-in on a position is stored, saved and restored`() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.setGuiOptions_rememberWindowPositions(true)
var saves = 0
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
memory.save("Map", stored)
assertEquals(1, saves)
assertEquals(stored, memory.load("Map"))
}
}
@@ -39,6 +39,42 @@ class SelectedStationStateTest {
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun turnAntennaSendsTheSelectedStationsHeading() {
val headings = mutableListOf<Double>()
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
val station = member("DL1ABC")
station.setQTFdirection(145.7)
state.select(station)
assertTrue(state.canTurnAntenna)
state.turnAntenna()
assertEquals(listOf(145.7), headings)
}
@Test
fun turnAntennaIsUnavailableWithoutARotatorOrAHeading() {
val withoutRotator = SelectedStationState(propagateNotQrv = { }, createSked = { _, _, _, _ -> true })
withoutRotator.select(member("DL1ABC").apply { setQTFdirection(145.7) })
assertFalse(withoutRotator.canTurnAntenna)
val headings = mutableListOf<Double>()
val withoutHeading = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
withoutHeading.select(member("DL1ABC"))
assertFalse(withoutHeading.canTurnAntenna)
withoutHeading.turnAntenna()
assertTrue(headings.isEmpty())
}
@Test
fun `without a selection there is nothing to act on`() {
val state = Session().state()
@@ -223,4 +259,57 @@ class SelectedStationStateTest {
assertTrue(session.failMarked.isEmpty())
}
@Test
fun `antenna 2 is offered only with SO2R and turns rotator 2`() {
val headings2 = mutableListOf<Double>()
var offered = false
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { },
turnSecondAntennaTo = { headings2.add(it) },
secondAntennaOffered = { offered },
)
state.select(member("DL1ABC").apply { setQTFdirection(145.7) })
assertFalse(state.showsSecondAntenna)
offered = true
assertTrue(state.showsSecondAntenna)
assertTrue(state.canTurnSecondAntenna)
state.turnSecondAntenna()
assertEquals(listOf(145.7), headings2)
}
@Test
fun `antenna 2 is greyed out without a heading`() {
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnSecondAntennaTo = { },
secondAntennaOffered = { true },
)
state.select(member("DL1ABC"))
assertTrue(state.showsSecondAntenna)
assertFalse(state.canTurnSecondAntenna)
}
@Test
fun `antenna 2 offer follows the surroundings flag without a new selection`() {
val surroundings = MainWindowSurroundings()
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnSecondAntennaTo = { },
secondAntennaOffered = { surroundings.secondAntennaOffered },
)
state.select(member("DL1ABC"))
assertFalse(state.showsSecondAntenna)
surroundings.secondAntennaOffered = true
assertTrue(state.showsSecondAntenna)
}
}
@@ -0,0 +1,224 @@
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
/**
* The settings draft: edits wait in it, and only apply() writes them and runs their effects.
*/
class SettingsDraftTest {
/** A stored setting with a log of what happened to it. */
private class Stored(var value: Int, private val log: MutableList<String>, private val name: String) {
fun write(v: Int) {
value = v
log += "write $name=$v"
}
}
@Test
fun `a staged value is shown but not written`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write)
assertEquals(9872, draft.valueOf("port") { port.value })
assertEquals(9871, port.value, "a staged value reached the stored setting")
assertTrue(draft.hasPendingChanges)
assertTrue(log.isEmpty())
}
@Test
fun `an unstaged key reads the stored value`() {
val draft = SettingsDraft()
assertEquals(42, draft.valueOf("anything") { 42 })
assertFalse(draft.hasPendingChanges)
}
@Test
fun `apply writes and runs the effect, then nothing is pending`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.apply()
assertEquals(9872, port.value)
assertEquals(listOf("write port=9872", "restart"), log)
assertFalse(draft.hasPendingChanges)
}
@Test
fun `discard writes nothing and runs nothing`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.discard()
assertEquals(9871, port.value)
assertTrue(log.isEmpty())
assertFalse(draft.hasPendingChanges)
assertEquals(9871, draft.valueOf("port") { port.value })
}
@Test
fun `staging the stored value again leaves nothing pending`() {
val log = mutableListOf<String>()
val flag = Stored(0, log, "flag")
val draft = SettingsDraft()
draft.stage("flag", 1, live = { flag.value }, write = flag::write, effect = { log += "effect" })
draft.stage("flag", 0, live = { flag.value }, write = flag::write, effect = { log += "effect" })
assertFalse(draft.hasPendingChanges, "a box ticked and unticked again is no change")
draft.apply()
assertTrue(log.isEmpty(), "apply ran something for a setting that did not change")
}
@Test
fun `the last staged value of a key wins`() {
val log = mutableListOf<String>()
val port = Stored(1, log, "port")
val draft = SettingsDraft()
draft.stage("port", 2, live = { port.value }, write = port::write)
draft.stage("port", 3, live = { port.value }, write = port::write)
draft.apply()
assertEquals(listOf("write port=3"), log, "one write per setting, with the final value")
}
/*
* Every write lands before the first effect, so an effect sees the finished state: a
* listener restarted between "enabled" and "port" would bind the old port.
*/
@Test
fun `all writes come before any effect, in the order first changed`() {
val log = mutableListOf<String>()
val a = Stored(0, log, "a")
val b = Stored(0, log, "b")
val draft = SettingsDraft()
draft.stage("b", 1, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.stage("a", 1, live = { a.value }, write = a::write, effect = { log += "effect a" })
draft.stage("b", 2, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.apply()
assertEquals(listOf("write b=2", "write a=1", "effect b", "effect a"), log)
}
@Test
fun `settings sharing an effect key run that effect once`() {
val log = mutableListOf<String>()
val enabled = Stored(0, log, "enabled")
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("enabled", 1, live = { enabled.value }, write = enabled::write,
effectKey = "listener", effect = { log += "restart" })
draft.stage("port", 9872, live = { port.value }, write = port::write,
effectKey = "listener", effect = { log += "restart" })
draft.apply()
assertEquals(listOf("write enabled=1", "write port=9872", "restart"), log)
}
@Test
fun `a failing effect is reported and the other effects still run`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var a = 0
var b = 0
draft.stage("a", 1, live = { a }, write = { a = it }, effect = { error("socket refused") })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "timer" })
val outcome = draft.apply()
assertEquals(1, a, "the write before the failing effect happened")
assertEquals(listOf("timer"), log, "one failing effect stopped the others")
assertEquals(listOf("a: socket refused"), outcome.failedEffects)
assertTrue(outcome.failedWrites.isEmpty())
assertFalse(draft.hasPendingChanges, "a stored setting would be applied a second time")
}
@Test
fun `a failing write stays pending, skips its effect, and the rest is applied`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var b = 0
draft.stage("a", 1, live = { 0 }, write = { error("disk full") }, effect = { log += "effect a" })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "effect b" })
val outcome = draft.apply()
assertEquals(1, b)
assertEquals(listOf("effect b"), log, "an effect ran for a value that was never stored")
assertEquals(listOf("a: disk full"), outcome.failedWrites)
assertFalse(outcome.isClean)
assertTrue(draft.hasPendingChanges, "the failed setting was dropped")
assertEquals(1, draft.valueOf("a") { 0 })
}
private class TwoSettings(draft: SettingsDraft, val log: MutableList<String>) {
var storedA = 1
var storedB = 1
var a: Int by draft.setting({ storedA }) { storedA = it; log += "a=$it" }
var b: Int by draft.setting({ storedB }, effect = { log += "effect b" }) { storedB = it; log += "b=$it" }
}
@Test
fun `a drafted property reads through the draft and stages its writes`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.a = 7
assertEquals(7, tab.a)
assertEquals(1, tab.storedA)
assertEquals(1, tab.b, "staging one property touched another")
draft.apply()
assertEquals(7, tab.storedA)
assertEquals(listOf("a=7"), tab.log)
}
@Test
fun `a drafted property carries its effect`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.b = 3
draft.apply()
assertEquals(listOf("b=3", "effect b"), tab.log)
}
@Test
fun `typed entries are committed on request and report their refusals`() {
val draft = SettingsDraft()
var stored = 0
val entry = draft.registerTypedEntry {
draft.stage("k", 5, live = { stored }, write = { stored = it })
null
}
draft.registerTypedEntry { "Port: \"98x\" is not a valid entry." }
val refusals = draft.commitTypedEntries()
assertEquals(listOf("Port: \"98x\" is not a valid entry."), refusals)
assertTrue(draft.hasPendingChanges, "the accepted entry was not staged")
assertEquals(0, stored, "committing an entry applied it")
draft.discard()
entry.close()
draft.commitTypedEntries()
assertFalse(draft.hasPendingChanges, "a removed field was still committed")
}
}
@@ -162,4 +162,27 @@ class StationColumnsTest {
"reachability is computed by the controller, not carried by the row")
assertEquals("250", columns.first { it.id == "score" }.value(member))
}
/**
* The key Kst4ContestApplication binds the station list to (#107). The category is part
* of the chat identity, so the same callsign in two categories is two keys; keyed by the
* callsign alone the two rows collided (21251377). The exact text is pinned because it
* was moved out of an inline Java lambda and must read the same as that lambda did.
*/
@Test
fun `the row key separates the categories of one callsign`() {
fun member(call: String?, category: Int?) = ChatMember().apply {
callSign = call
chatCategory = category?.let { kst4contest.model.ChatCategory(it) }
}
assertEquals("DL1ABC|2", StationColumns.rowKey(member("DL1ABC", 2)))
assertEquals("DL1ABC|3", StationColumns.rowKey(member("DL1ABC", 3)))
assertTrue(
StationColumns.rowKey(member("DL1ABC", 2)) != StationColumns.rowKey(member("DL1ABC", 3)),
"two categories of one callsign share a key",
)
assertEquals("DL1ABC|", StationColumns.rowKey(member("DL1ABC", null)))
assertEquals("null|2", StationColumns.rowKey(member(null, 2)))
}
}
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import kst4contest.view.compose.tabs.checkBeside
import kst4contest.view.compose.tabs.fieldBeside
import kst4contest.view.compose.tabs.showTab
import kst4contest.view.compose.tabs.toggle
import kst4contest.view.compose.tabs.type
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.util.EnumSet
/**
* The QRB part of the station filter bar, drawn (#21).
*
* [StationFilterStateTest] pins which distances pass; this pins that the two QRB controls
* on screen write into their own filter and not into the other one. The two labels differ
* only in `<=` and `>`, so a swapped binding would look right and filter the wrong side.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class StationFilterBarScreenTest {
private val maxLabel = "Show only QRB [km] <="
private val minLabel = "Show only QRB [km] >"
private class Bar {
val filters = StationFilterState()
var changes = 0
}
private fun ComposeUiTest.showBar(): Bar {
val bar = Bar()
showTab {
StationFilterBar(
filters = bar.filters,
activeBands = EnumSet.of(Band.B_144),
onChanged = { bar.changes++ },
)
}
return bar
}
@Test
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
val bar = showBar()
type(minLabel, "300")
toggle(minLabel)
assertTrue(bar.filters.minQrbEnabled)
assertEquals(300.0, bar.filters.minQrbKm)
assertEquals(setOf(StationFilter.MIN_QRB), bar.filters.activeFilters)
assertEquals(0.0, bar.filters.maxQrbKm, "the minimum field wrote into the maximum")
assertTrue(bar.changes > 0, "switching the filter on did not reapply the filters")
checkBeside(minLabel).assertIsOn()
checkBeside(maxLabel).assertIsOff()
}
@Test
fun bothQrbControlsTogetherMakeARange() = runComposeUiTest {
val bar = showBar()
type(maxLabel, "500")
toggle(maxLabel)
type(minLabel, "200")
toggle(minLabel)
assertEquals(setOf(StationFilter.MAX_QRB, StationFilter.MIN_QRB), bar.filters.activeFilters)
fieldBeside(maxLabel).assertTextEquals("500")
fieldBeside(minLabel).assertTextEquals("200")
assertFalse(bar.filters.acceptsQrb(150.0))
assertTrue(bar.filters.acceptsQrb(350.0))
assertFalse(bar.filters.acceptsQrb(650.0))
}
/** A pasted "Infinity" as the minimum hid every station, a "NaN" let every one through. */
@Test
fun theQrbFieldsKeepTheirValueWhenTheTextIsNoDistance() = runComposeUiTest {
val bar = showBar()
type(maxLabel, "500")
type(minLabel, "200")
listOf("Infinity", "NaN", "-5").forEach { text ->
type(maxLabel, text)
type(minLabel, text)
}
assertEquals(500.0, bar.filters.maxQrbKm)
assertEquals(200.0, bar.filters.minQrbKm)
fieldBeside(maxLabel).assertTextEquals("500")
fieldBeside(minLabel).assertTextEquals("200")
}
@Test
fun resettingTheFiltersUnticksTheMinimumQrb() = runComposeUiTest {
val bar = showBar()
toggle(minLabel)
checkBeside(minLabel).assertIsOn()
onNodeWithText("Reset filters").performClick()
waitForIdle()
checkBeside(minLabel).assertIsOff()
assertFalse(bar.filters.minQrbEnabled)
}
/**
* The first row used to be a plain row: with both QRB filters in it, a narrow station
* pane cut the bearing buttons off at the right edge. It wraps now, and every control
* has to stay on screen.
*/
@Test
fun aNarrowBarWrapsInsteadOfCuttingControlsOff() = runComposeUiTest {
val filters = StationFilterState()
val width = 300.dp
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Box(Modifier.width(width)) {
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {})
}
}
}
waitForIdle()
val limit = with(density) { width.toPx() }
val controls = Bearing.entries.map { onNodeWithText(it.name) } +
listOf(
onNodeWithText(maxLabel),
onNodeWithText(minLabel),
onNodeWithText("Show only QTF:"),
fieldBeside(maxLabel),
fieldBeside(minLabel),
)
controls.forEach { node ->
node.assertIsDisplayed()
val right = node.fetchSemanticsNode().boundsInRoot.right
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
}
}
}
@@ -131,6 +131,108 @@ class StationFilterStateTest {
assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
}
@Test
fun `the minimum QRB filter needs its own switch`() {
val filters = StationFilterState()
filters.minQrbKm = 300.0
assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing")
filters.minQrbEnabled = true
assertEquals(setOf(StationFilter.MIN_QRB), filters.activeFilters)
}
@Test
fun `resetting switches both QRB filters off`() {
val filters = StationFilterState()
filters.maxQrbEnabled = true
filters.minQrbEnabled = true
filters.reset()
assertTrue(filters.activeFilters.isEmpty())
assertFalse(filters.minQrbEnabled)
assertFalse(filters.maxQrbEnabled)
}
@Test
fun `without a QRB filter every distance passes, an unknown one too`() {
val filters = StationFilterState()
filters.maxQrbKm = 100.0
filters.minQrbKm = 500.0
assertTrue(filters.acceptsQrb(50.0))
assertTrue(filters.acceptsQrb(1000.0))
assertTrue(filters.acceptsQrb(null))
}
/** The behaviour the controller predicate had before #21 moved it here. */
@Test
fun `the maximum QRB keeps stations up to and including the distance`() {
val filters = StationFilterState()
filters.maxQrbEnabled = true
filters.maxQrbKm = 300.0
assertTrue(filters.acceptsQrb(299.9))
assertTrue(filters.acceptsQrb(300.0))
assertFalse(filters.acceptsQrb(300.1))
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a short one")
}
@Test
fun `the minimum QRB keeps only stations further away than the distance`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 300.0
assertFalse(filters.acceptsQrb(299.9))
assertFalse(filters.acceptsQrb(300.0), "the issue asks for QRB > X, so X itself is out")
assertTrue(filters.acceptsQrb(300.1))
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a long one")
}
@Test
fun `both QRB filters together keep a distance range`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 200.0
filters.maxQrbEnabled = true
filters.maxQrbKm = 500.0
assertFalse(filters.acceptsQrb(200.0))
assertTrue(filters.acceptsQrb(200.1))
assertTrue(filters.acceptsQrb(500.0))
assertFalse(filters.acceptsQrb(500.1))
assertFalse(filters.acceptsQrb(null))
}
@Test
fun `a range with the minimum above the maximum keeps nothing`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 500.0
filters.maxQrbEnabled = true
filters.maxQrbKm = 200.0
listOf(100.0, 300.0, 600.0).forEach {
assertFalse(filters.acceptsQrb(it), "$it km passed an empty range")
}
}
@Test
fun `a QRB field reads plain distances`() {
assertEquals(300.0, parseQrbKm("300"))
assertEquals(12.5, parseQrbKm("12.5"))
assertEquals(0.0, parseQrbKm("0"))
}
@Test
fun `a QRB field refuses what is no distance`() {
listOf("NaN", "Infinity", "-Infinity", "-5", "abc", "").forEach {
assertNull(parseQrbKm(it), "\"$it\" was taken as a distance")
}
}
@Test
fun `a search text filters while it is there and stops when it is cleared`() {
val filters = StationFilterState()
@@ -0,0 +1,224 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import java.util.EnumSet
/**
* The station list of the main window, drawn: the operator's main working surface (#107).
*
* The same [DataTableView] over [StationColumns] and [StationColumns.rowKey] that
* `Kst4ContestApplication` builds; only the computed cells (tropo, score, band status) are
* stubbed, because they come from the controller. Three regressions are why this list is
* tested on screen and not only as state:
*
* - `21251377` (Codex review findings on PR 89): the row key was the callsign alone, so the
* same callsign in two chat categories collided -- two rows under one key, which a
* `LazyColumn` refuses outright.
* - `21251377` again: sorting worked on all rows instead of the filtered ones, so choosing a
* sort column silently switched the filters off.
* - `ab282162` (#87): the AP column had no comparator and sorted by its text, so
* "13 (75%)" came before "3 (50%)".
*
* Plus the rule the table was built around in `cc8c9020` (Draw the whole interface in
* Compose): rows replaced after the first draw reach the screen, and the selection is kept by
* key across a refresh that hands in new instances.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class StationListScreenTest {
private fun stationList(): DataTableState<ChatMember> = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.noneOf(Band::class.java),
),
RowKeys.byValue(StationColumns::rowKey),
"compose-stations",
)
private fun station(
call: String,
category: Int = 2,
qrb: Double? = null,
planes: List<Pair<Int, Int>>? = null,
): ChatMember = ChatMember().apply {
callSign = call
name = "op $call"
chatCategory = ChatCategory(category)
this.qrb = qrb
if (planes != null) {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = planes.map { (minutes, potential) ->
AirPlane().apply {
arrivingDurationMinutes = minutes
this.potential = potential
}
}.toMutableList()
}
}
}
private fun ComposeUiTest.show(
state: DataTableState<ChatMember>,
onRowClick: (ChatMember) -> Unit = {},
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(state = state, onRowClick = onRowClick)
}
}
waitForIdle()
}
/** The callsigns as they stand on screen, top to bottom. */
private fun ComposeUiTest.drawnOrder(vararg calls: String): List<String> =
calls.sortedBy { call ->
onNodeWithText(call).fetchSemanticsNode().positionInRoot.y
}
@Test
fun theSameCallsignInTwoCategoriesIsTwoRowsAndOnlyTheClickedOneIsSelected() = runComposeUiTest {
val state = stationList()
val inMain = station("DL1ABC", category = 2)
val inSecond = station("DL1ABC", category = 3)
val clicked = mutableListOf<ChatMember>()
show(state) { clicked += it }
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
waitForIdle()
assertEquals(
2,
onAllNodesWithText("DL1ABC").fetchSemanticsNodes().size,
"the second category's row did not reach the screen",
)
// Unsorted, the rows keep their arrival order: the second category is the lower row.
val rows = onAllNodesWithText("DL1ABC")
val lower = rows.fetchSemanticsNodes().withIndex().maxBy { it.value.positionInRoot.y }.index
rows[lower].performClick()
waitForIdle()
assertSame(inSecond, clicked.single(), "the click reached the other category's row")
assertSame(inSecond, state.selectedRow)
assertEquals(false, state.isSelected(inMain), "both categories read as selected")
}
@Test
fun aSortChosenWhileFilteredKeepsTheFilter() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL3CCC", qrb = 300.0),
station("DL1AAA", qrb = 100.0),
station("DL2BBB", qrb = 200.0),
),
)
state.rowFilter = { it.callSign != "DL2BBB" }
}
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
onNodeWithText("QRB").performClick()
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
}
@Test
fun theApColumnSortsByMinutesAndNotByItsText() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL1THIRTEEN", planes = listOf(13 to 75)),
station("DL1NONE", planes = emptyList()),
station("DL1THREE", planes = listOf(3 to 50)),
),
)
}
waitForIdle()
onNodeWithText("AP [minutes / pot%]").performClick()
waitForIdle()
assertEquals(
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
"the text order puts \"13 (75%)\" above \"3 (50%)\"",
)
onNodeWithText("AP [minutes / pot%] ▲").assertExists()
}
@Test
fun rowsArrivingAfterTheFirstDrawReachTheScreenAndKeepTheSelection() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle { state.replaceRows(listOf(station("DL1AAA"), station("DL2BBB"))) }
waitForIdle()
onNodeWithText("DL2BBB").performClick()
waitForIdle()
// A refresh from the roster hands in new instances, with one more station on top.
val refreshed = station("DL2BBB")
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"), refreshed)) }
waitForIdle()
onNodeWithText("DL9NEW").assertExists()
assertSame(refreshed, state.selectedRow, "the selection did not follow the station by key")
// A station that left takes the selection with it rather than handing it on.
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"))) }
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(null, state.selectedRow)
}
/**
* A station's fields change in place and the application calls forceRedraw: the list
* filters again, so a station the filter now excludes leaves the screen (#21).
*/
@Test
fun aRedrawAfterAFieldChangedFiltersTheListAgain() = runComposeUiTest {
val state = stationList()
val moving = station("DL1MOVE", qrb = 500.0)
show(state)
runOnIdle {
state.replaceRows(listOf(moving, station("DL2STAY", qrb = 800.0)))
state.rowFilter = { (it.qrb ?: 0.0) > 300.0 }
}
waitForIdle()
onNodeWithText("DL1MOVE").assertExists()
runOnIdle {
moving.qrb = 100.0
state.forceRedraw()
}
waitForIdle()
onNodeWithText("DL1MOVE").assertDoesNotExist()
onNodeWithText("DL2STAY").assertExists()
}
}
@@ -0,0 +1,252 @@
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
import java.awt.Point
import java.awt.Rectangle
/**
* Where a secondary window opens (issue #59).
*
* The screens here are made up, which is the point: no test machine has a second
* monitor left of the primary one, or one that was just unplugged.
*/
class WindowPlacementTest {
/** 1920x1080 primary with a 40 px task bar at the bottom. */
private val primary = ScreenArea(
bounds = Rectangle(0, 0, 1920, 1080),
usable = Rectangle(0, 0, 1920, 1040),
)
/** 2560x1440 to the right of the primary, without panels. */
private val right = ScreenArea(
bounds = Rectangle(1920, 0, 2560, 1440),
usable = Rectangle(1920, 0, 2560, 1440),
)
/** 1280x1024 left of the primary, so with negative coordinates, and a 30 px top bar. */
private val left = ScreenArea(
bounds = Rectangle(-1280, 0, 1280, 1024),
usable = Rectangle(-1280, 30, 1280, 994),
)
@Test
fun `a window opens centred on the screen the main window is on`() {
val main = Rectangle(2200, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(Rectangle(1920 + 880, 420, 800, 600), placed)
}
@Test
fun `the main window on the primary screen keeps the window there`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a screen left of the primary one works with negative coordinates`() {
val main = Rectangle(-1200, 50, 1000, 700)
val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 600)
assertEquals(Rectangle(-1280 + 240, 30 + 197, 800, 600), placed)
}
@Test
fun `a main window straddling two screens counts for the one holding its centre`() {
val main = Rectangle(1500, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(right.bounds, WindowPlacement.screenOf(listOf(primary, right), main).bounds)
assertEquals(1920 + 880, placed!!.x)
}
@Test
fun `a window larger than the screen is cut down to its usable area`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, null, 2500, 1300)
assertEquals(Rectangle(0, 0, 1920, 1040), placed)
}
@Test
fun `only the oversized dimension is cut`() {
val main = Rectangle(-1200, 50, 1000, 700)
val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 2000)
assertEquals(Rectangle(-1280 + 240, 30, 800, 994), placed)
}
@Test
fun `a window moved during the session reopens where it was moved to`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, Point(3000, 200), 800, 600)
assertEquals(Rectangle(3000, 200, 800, 600), placed)
}
@Test
fun `a remembered position on an unplugged screen falls back to the main window's screen`() {
val main = Rectangle(100, 100, 1200, 800)
/* Moved to the right-hand screen, which is no longer connected. */
val placed = WindowPlacement.place(listOf(primary), main, Point(3000, 200), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered position with the title bar above the screen falls back`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, -200), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered position with the title bar under the task bar falls back`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, 1035), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a title bar that is mostly past the edge but still grabbable is pulled back in`() {
val main = Rectangle(100, 100, 1200, 800)
/* 120 px of the title bar remain on the primary screen: the spot counts, the overhang goes. */
val placed = WindowPlacement.place(listOf(primary), main, Point(1800, 300), 800, 600)
assertEquals(Rectangle(1920 - 800, 300, 800, 600), placed)
}
@Test
fun `a remembered window hanging into the task bar is lifted above it`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, 700), 800, 600)
assertEquals(Rectangle(400, 1040 - 600, 800, 600), placed)
}
@Test
fun `a remembered window that fits stays exactly where it was`() {
val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1100, 200), 800, 600)
assertEquals(Rectangle(-1100, 200, 800, 600), placed)
}
@Test
fun `a title bar with too little left to grab falls back`() {
val main = Rectangle(100, 100, 1200, 800)
/* Only 20 px of the title bar remain on the primary screen. */
val placed = WindowPlacement.place(listOf(primary), main, Point(1900, 300), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered window is cut down to the screen it is on`() {
val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1200, 100), 2000, 600)
assertEquals(Rectangle(-1280, 100, 1280, 600), placed)
}
@Test
fun `without a main window the platform decides`() {
assertNull(WindowPlacement.place(listOf(primary, right), null, null, 800, 600))
}
@Test
fun `without a display the platform decides`() {
assertNull(WindowPlacement.place(emptyList(), Rectangle(0, 0, 100, 100), Point(10, 10), 800, 600))
}
@Test
fun `a main window off every screen falls back to the first screen`() {
val main = Rectangle(9000, 9000, 800, 600)
assertEquals(primary, WindowPlacement.screenOf(listOf(primary, right), main))
}
@Test
fun `empty insets leave the whole screen usable`() {
val broken = ScreenArea(Rectangle(0, 0, 1920, 1080), Rectangle(0, 0, 0, 0))
val placed = WindowPlacement.place(listOf(broken), Rectangle(0, 0, 100, 100), null, 800, 600)
assertEquals(Rectangle(560, 240, 800, 600), placed)
}
@Test
fun `a window manager's nudge is not taken for the operator's move`() {
assertFalse(movedAway(Point(560, 220), Point(560, 252)))
assertFalse(movedAway(Point(560, 220), Point(560 - MOVE_TOLERANCE_PX, 220)))
assertTrue(movedAway(Point(560, 220), Point(560, 220 + MOVE_TOLERANCE_PX + 1)))
assertTrue(movedAway(Point(560, 220), Point(3000, 220)))
}
@Test
fun `a stored position is restored on the very screen it was stored on`() {
val stored = StoredWindowPosition(Point(2500, 300), right.bounds)
assertEquals(Rectangle(2500, 300, 800, 600), WindowPlacement.restore(listOf(primary, right), stored, 800, 600))
}
@Test
fun `a stored position is kept within the usable area of its screen`() {
val stored = StoredWindowPosition(Point(1500, 900), primary.bounds)
assertEquals(Rectangle(1120, 440, 800, 600), WindowPlacement.restore(listOf(primary), stored, 800, 600))
}
@Test
fun `a stored position is not used when its screen is gone or changed`() {
val unplugged = StoredWindowPosition(Point(2500, 300), right.bounds)
val otherResolution = StoredWindowPosition(Point(100, 100), Rectangle(0, 0, 2560, 1440))
assertNull(WindowPlacement.restore(listOf(primary, left), unplugged, 800, 600))
assertNull(WindowPlacement.restore(listOf(primary), otherResolution, 800, 600))
}
/** A 640x480 field monitor right of the primary screen. */
private val tiny = ScreenArea(
bounds = Rectangle(1920, 0, 640, 480),
usable = Rectangle(1920, 0, 640, 480),
)
@Test
fun `a window moved onto a smaller screen is cut down to it`() {
val moved = Rectangle(1950, 20, 1000, 800)
assertEquals(Rectangle(1920, 0, 640, 480), WindowPlacement.fitted(listOf(primary, tiny), moved))
}
@Test
fun `a window that fits its screen is left alone`() {
assertNull(WindowPlacement.fitted(listOf(primary, tiny), Rectangle(100, 100, 800, 600)))
}
@Test
fun `a minimum size is capped to the smallest connected screen`() {
assertEquals(java.awt.Dimension(640, 480), WindowPlacement.minimumWithin(listOf(primary, tiny), 900, 600))
assertEquals(java.awt.Dimension(900, 600), WindowPlacement.minimumWithin(listOf(primary), 900, 600))
}
}
@@ -0,0 +1,473 @@
package kst4contest.view.compose.map
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.locatorUtils.Location
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import kst4contest.model.ClusterMessage
import kst4contest.view.compose.BlinkingNotice
import kst4contest.view.compose.ChatInputActions
import kst4contest.view.compose.ChatInputState
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.MainMenuState
import kst4contest.view.compose.MainWindowState
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.RowKeys
import kst4contest.view.compose.SelectedStationState
import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.StationFilterState
import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
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.Path
import java.io.IOException
import java.util.EnumSet
/**
* The station map window's body, drawn and clicked (#107).
*
* The map has the longest regression history of the Compose windows, and the two that
* reached an operator are both about what a click on the map does to the rest of the
* application:
*
* - `21251377` (Codex review findings on PR 89): a marker click and "Reset view" set the
* map's own copy of the selection instead of going through the central station selector,
* so the station list, the score service and the send line did not follow the map.
* - `cc8c9020` (Draw the whole interface in Compose, in `main` as `2a5c89b9`): an earlier
* Compose version recomputed the zoom on every selection and threw an operator working at
* zoom 15 out to about 7. AGENTS.md: reset clears the selection without changing the zoom.
*
* Tiles never arrive: an empty temporary cache and a source that always fails, the
* arrangement `3dd2cf10` gave the drawing test after the default fetcher read the user's
* disk cache and the real tile server. No running application exists: the path analysis is
* either absent, as it is before a runtime exists, or a recording stand-in passed through the
* window's [MapPathAnalysis] seam, which is what lets the zoom rule on selection run here.
*
* Headless: [StationMapWindow.Content] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class StationMapWindowScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
private lateinit var offlineTileFetcher: TileFetcher
@BeforeEach
fun openTileFetcher() {
offlineTileFetcher = TileFetcher(
directory.resolve("tile-cache"),
{ throw IOException("no tile source in this test") },
)
}
/** The fetcher owns a thread pool; the window closes it on dispose, and so does this. */
@AfterEach
fun closeTileFetcher() {
offlineTileFetcher.close()
}
/** Records what the window asks the path analysis for, instead of a running application. */
private class RecordingPathAnalysis : MapPathAnalysis {
val requested = mutableListOf<ChatMember>()
override fun request(
member: ChatMember,
snapshot: MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
) {
requested += member
}
}
private val ownLocator = "JO40HA"
private fun station(call: String, category: Int, locator: String = "JO50JP") = ChatMember().apply {
callSign = call
qra = locator
chatCategory = ChatCategory(category)
}
private class NoChatActions : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = ""
override fun ownQrgSecond(): String = ""
}
private fun windowState(prefs: ChatPreferences): MainWindowState {
val stations = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
),
RowKeys.byValue(StationColumns::rowKey),
)
val menu = MainMenuState()
return MainWindowState(
activeBands = EnumSet.of(Band.B_144, Band.B_432),
prefs = prefs,
stations = stations,
stationFilter = StationFilterState(),
directedMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
publicMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
clusterMessages = DataTableState(emptyList(), RowKeys.byReference<ClusterMessage>()),
qsoOfTheOther = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStationMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStation = SelectedStationState(
propagateNotQrv = {},
createSked = { _, _, _, _ -> false },
),
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
timeline = TimelineState(),
chatInput = ChatInputState(NoChatActions()),
menu = menu,
surroundings = MainWindowSurroundings(menu),
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = ThreadStatusButtons(),
outerSplitter = SplitterState(2, doubleArrayOf(0.5)) {},
messageSplitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {},
rightSplitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, doubleArrayOf(0.4, 0.6)) {},
)
}
private fun prefs(pathAnalysisVisible: Boolean = false) = preferences().apply {
stn_loginLocatorMainCat = ownLocator
isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
isGUIstationMapClusteringEnabled = false
}
private fun ComposeUiTest.show(
state: MainWindowState,
members: List<ChatMember>,
mapState: MapState,
pathAnalysis: MapPathAnalysis? = null,
onPathAnalysisAsked: () -> Unit = {},
) {
state.stations.replaceRows(members)
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StationMapWindow.Content(
mainWindowState = state,
tileFetcher = offlineTileFetcher,
chatMembers = { members },
mapState = mapState,
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
)
}
}
waitForIdle()
}
/** A map looking straight at a locator, so its marker sits in the middle of the canvas. */
private fun mapLookingAt(locator: String, zoom: Float): MapState {
val location = Location(locator)
return MapState(
zoom = zoom,
centerLon = location.longitude.toDegrees(),
centerLat = location.latitude.toDegrees(),
)
}
@Test
fun resetViewClearsTheSelectionThroughTheSelectorAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
assertSame(
dl1abc,
state.selectedStation.selected,
"reset cleared the panel's selection behind the selector's back",
)
assertEquals(15f, mapState.zoom, "reset changed the zoom")
val home = Location(ownLocator)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(home.latitude.toDegrees(), mapState.centerLat, 1e-9)
}
/** Without a selector -- before the host set one -- reset still clears the selection. */
@Test
fun resetViewWithoutASelectorClearsThePanelSelection() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
show(state, listOf(dl1abc), mapLookingAt("JO50JP", zoom = 9f))
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(null, state.selectedStation.selected)
}
/**
* A marker stands for a raw callsign. With one of its category variants already selected
* the click keeps that one, goes through the selector, and prepares "/cq CALL" over
* whatever was typed -- the JavaFX handleMapCallsignSelection behaviour.
*/
@Test
fun aMarkerClickSelectsThroughTheSelectorAndKeepsTheSelectedCategory() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
state.selectedStation.select(inSecond)
state.chatInput.value = TextFieldValue("half typed")
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 12f)
show(state, listOf(inMain, inSecond), mapState)
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertEquals(1, selectorCalls.size, "the marker click did not reach the station selector")
assertSame(inSecond, selectorCalls.single(), "the click swapped the selected category")
assertEquals("/cq DL1ABC ", state.chatInput.value.text)
assertEquals(12f, mapState.zoom, "selecting from the map changed the zoom")
}
@Test
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertSame(inMain, selectorCalls.single())
}
/**
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
*/
@Test
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 15f)
show(state, listOf(dl1abc), mapState, analysis)
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
val target = Location("JO50JP")
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
}
/**
* With grouping on, a selection zooms in only as far as it takes to break the station out
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
*/
@Test
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
val state = windowState(prefs)
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val far = mapLookingAt(ownLocator, zoom = 5f)
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
}
/** Without a running application there is nothing to ask, and the map stays put. */
@Test
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 15f)
val home = Location(ownLocator)
var asked = 0
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(15f, mapState.zoom)
}
/** The two header switches write the profile, so the map opens the same way next time. */
@Test
fun theHeaderSwitchesWriteThePreferences() = runComposeUiTest {
val prefs = prefs(pathAnalysisVisible = true)
val state = windowState(prefs)
show(state, emptyList(), MapState())
onNodeWithText("Hide path analysis").performClick()
waitForIdle()
assertEquals(false, prefs.isGUIstationMapPathAnalysisVisible)
onNodeWithText("Path analysis is hidden.").assertExists()
onNodeWithText("Show path analysis").assertExists()
onNodeWithText("Show path analysis").performClick()
waitForIdle()
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
// The grouping checkbox sits beside its label; the label itself is not clickable.
onNodeWithText("Group nearby stations").assertExists()
onNode(isToggleable()).assertIsOff().performClick()
waitForIdle()
onNode(isToggleable()).assertIsOn()
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
}
/**
* The map shows the stations the list shows (#21). The Compose map drew every station
* whatever the filter bar said, although its status line reported a filtered view; the
* JavaFX map took the filtered list. A filter switched on after the first draw has to
* take the marker away too, and a click where it stood selects nothing.
*/
@Test
fun aStationTheFiltersHideHasNoMarker() = runComposeUiTest {
val state = windowState(prefs())
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithText("Showing 2 visible stations").assertExists()
runOnIdle {
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
}
waitForIdle()
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
// The map looks straight at the hidden station: a click there finds no marker.
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertEquals(emptyList<ChatMember?>(), selectorCalls, "the hidden station still had a marker")
}
/** A station whose QRB changed in place leaves the map after the redraw, as it leaves the list. */
@Test
fun aRedrawAfterAFieldChangedFiltersTheMapAgain() = runComposeUiTest {
val state = windowState(prefs())
val moving = station("DL1MOVE", 2, locator = "JO50JP").apply { qrb = 500.0 }
val stay = station("DL2STAY", 2, locator = "JO62QM").apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
show(state, listOf(moving, stay), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithText("Showing 2 visible stations | filtered view active").assertExists()
runOnIdle {
moving.qrb = 100.0
state.stations.forceRedraw()
}
waitForIdle()
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
}
/**
* A selected station the filters hide has no marker, and so nothing to analyse: no
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
* The visible station beside it is the control: selecting it does ask and does load.
*/
@Test
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
val state = windowState(prefs(pathAnalysisVisible = true))
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 9f)
val home = Location(ownLocator)
show(state, listOf(hidden, shown), mapState, analysis)
runOnIdle { state.selectedStation.select(hidden) }
waitForIdle()
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
runOnIdle { state.selectedStation.select(shown) }
waitForIdle()
assertEquals(listOf(shown), analysis.requested)
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
}
}
@@ -1,16 +1,24 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class AirscoutTabStateTest {
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
val state by lazy { AirscoutTabState(prefs) }
/*
* The rules below are pinned with a draft that applies at once; what the draft itself
* changes is pinned by the two tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state by lazy { AirscoutTabState(prefs, draft) }
}
@Test
@@ -32,7 +40,39 @@ class AirscoutTabStateTest {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
fun `an edit waits for apply`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asCommunicationPort(9872)
s.state.airScout_asCommunicationPort = 9873
s.state.airScout_asServerNameString = " AS3 "
assertEquals(9872, s.prefs.getAirScout_asCommunicationPort(), "an edit took effect before Apply")
assertEquals(9873, s.state.airScout_asCommunicationPort, "the tab shows the edit")
assertEquals("AS3", s.state.airScout_asServerNameString, "the draft holds the trimmed value")
s.draft.apply()
assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
assertEquals("AS3", s.prefs.getAirScout_asServerNameString())
}
@Test
fun `a discarded edit changes nothing, and an invalid one never enters the draft`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asBandString("1440000")
s.state.airScout_asBandString = "-1"
assertFalse(s.draft.hasPendingChanges, "a refused value reached the draft")
s.state.airScout_asBandString = "4320000"
s.draft.discard()
assertEquals("1440000", s.prefs.getAirScout_asBandString())
}
@Test
fun `writing reaches the preferences on apply`() {
val s = Session()
s.state.airScout_asUDPListenerEnabled = true
@@ -2,22 +2,34 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class BeaconTabStateTest {
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
private class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences()
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(
private val templateRefusal: String? = null,
val draft: SettingsDraft = ImmediateDraft(),
) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var timerRestarts = 0
var validated = mutableListOf<String>()
fun state() = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template ->
validated.add(template)
templateRefusal?.let { throw IllegalArgumentException(it) }
@@ -29,7 +41,46 @@ class BeaconTabStateTest {
}
@Test
fun `the enable flags write through at once`() {
fun `beacon edits wait for apply, and the timer restarts only then`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
assertEquals("CQ MYCALL", state.beaconTextMainCat)
s.draft.apply()
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
assertEquals(1, s.timerRestarts)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
val state = s.state()
state.commitBeaconTextMainCat("CQ MYCALL MYQRG")
state.commitBeaconInterval("9")
s.draft.discard()
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(0, s.timerRestarts)
}
@Test
fun `the enable flags reach the preferences`() {
val s = Session()
val state = s.state()
@@ -0,0 +1,227 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.Locale
/**
* Every drafted setting of every tab, with a real [SettingsDraft]: unchanged in the
* preferences after the edit, changed after apply, unchanged after discard.
*
* The tab tests pin each tab's rules with a draft that applies at once, so they cannot tell
* a setting that goes through the draft from one that writes ChatPreferences directly. This
* table can, setter by setter. A setting added to a tab belongs in it; one that is meant to
* stay immediate (login credentials, language, colours, actions) belongs in the spec instead.
*/
class DraftedSettingsTableTest {
@TempDir
lateinit var directory: Path
/** All drafted tabs over one preferences file and one draft. */
private class Fixture(val prefs: ChatPreferences, val monitored: SimpleRoster<String> = SimpleRoster()) {
val draft = SettingsDraft()
val station = StationTabState(prefs, draft, { }, { })
val logSynch = LogSynchTabState(prefs, draft, { }, { })
val trx = TrxSynchTabState(prefs, draft) { }
val airscout = AirscoutTabState(prefs, draft)
val notification = NotificationTabState(prefs, draft, monitored, { }, { null })
val shortcuts = ShortcutsTabState(prefs, draft, { }, { })
val beacon = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
val messages = MessageHandlingTabState(prefs, draft) { }
val gui = GuiOptionsTabState(prefs, draft, LanguageStore({ "en" }, { }, Locale.UK))
}
/** One drafted setting: how the tab changes it, and how the stored value is read. */
private class Case(val name: String, val change: (Fixture) -> Unit, val stored: (Fixture) -> Any?)
private fun flag(name: String, read: (Fixture) -> Boolean, write: (Fixture, Boolean) -> Unit) =
Case(name, { f -> write(f, !read(f)) }, read)
private fun cases(): List<Case> = listOf(
// Station, all but the login credentials
Case("station beam width", { it.station.stn_antennaBeamWidthDeg += 1.5 }, { it.prefs.getStn_antennaBeamWidthDeg() }),
flag("station 10G", { it.station.stn_bandActive10G }) { f, v -> f.station.stn_bandActive10G = v },
flag("station 1240", { it.station.stn_bandActive1240 }) { f, v -> f.station.stn_bandActive1240 = v },
flag("station 144", { it.station.stn_bandActive144 }) { f, v -> f.station.stn_bandActive144 = v },
flag("station 2300", { it.station.stn_bandActive2300 }) { f, v -> f.station.stn_bandActive2300 = v },
flag("station 3400", { it.station.stn_bandActive3400 }) { f, v -> f.station.stn_bandActive3400 = v },
flag("station 432", { it.station.stn_bandActive432 }) { f, v -> f.station.stn_bandActive432 = v },
flag("station 50", { it.station.stn_bandActive50 }) { f, v -> f.station.stn_bandActive50 = v },
flag("station 5600", { it.station.stn_bandActive5600 }) { f, v -> f.station.stn_bandActive5600 = v },
flag("station 70", { it.station.stn_bandActive70 }) { f, v -> f.station.stn_bandActive70 = v },
Case("station max QRB", { it.station.stn_maxQRBDefault += 1.5 }, { it.prefs.getStn_maxQRBDefault() }),
Case("station server host", { it.station.stn_on4kstServersDns += "x" }, { it.prefs.getStn_on4kstServersDns() }),
Case("station server port", { it.station.stn_on4kstServersPort += 1 }, { it.prefs.getStn_on4kstServersPort() }),
Case("station target gain", { it.station.stn_pathAnalysisDefaultTargetAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }),
Case("station target power", { it.station.stn_pathAnalysisDefaultTargetTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }),
Case("station DEM directory", { it.station.stn_pathAnalysisDemRootDirectory += "x" },
{ it.prefs.getStn_pathAnalysisDemRootDirectory() }),
Case("station own gain", { it.station.stn_pathAnalysisOwnAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaGainDbi() }),
Case("station own height", { it.station.stn_pathAnalysisOwnAntennaHeightMeters += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
// Through the field's commit, which validates and then stages.
Case("station own ground height ASL", { it.station.commitOwnHeightAsl("312") },
{ it.prefs.getStn_pathAnalysisOwnHeightAslMeters() }),
flag("station target on highest point", { it.station.stn_pathAnalysisDxHighestPointInSquare }) { f, v ->
f.station.stn_pathAnalysisDxHighestPointInSquare = v
},
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
flag("station second chat", { it.station.loginToSecondChatEnabled }) { f, v -> f.station.loginToSecondChatEnabled = v },
// Log synch, all but the file chooser
flag("log file polling", { it.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled }) { f, v ->
f.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled = v
},
flag("log UCX listener", { it.logSynch.logsynch_ucxUDPWkdCallListenerEnabled }) { f, v ->
f.logSynch.logsynch_ucxUDPWkdCallListenerEnabled = v
},
Case("log UCX port", { it.logSynch.logsynch_ucxUDPWkdCallListenerPort += 1 },
{ it.prefs.getLogsynch_ucxUDPWkdCallListenerPort() }),
flag("log Win-Test listener", { it.logSynch.logsynch_wintestNetworkListenerEnabled }) { f, v ->
f.logSynch.logsynch_wintestNetworkListenerEnabled = v
},
Case("log Win-Test port", { it.logSynch.logsynch_wintestNetworkPort += 1 }, { it.prefs.getLogsynch_wintestNetworkPort() }),
Case("log Win-Test station name", { it.logSynch.logsynch_wintestNetworkStationNameOfKST += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfKST() }),
Case("log Win-Test broadcast", { it.logSynch.logsynch_wintestNetworkBroadcastAddress = "10.9.8.255" },
{ it.prefs.getLogsynch_wintestNetworkBroadcastAddress() }),
// TRX synch
flag("trx UCX QRG", { it.trx.trxSynch_ucxLogUDPListenerEnabled }) { f, v -> f.trx.trxSynch_ucxLogUDPListenerEnabled = v },
flag("trx Win-Test QRG", { it.trx.logsynch_wintestQrgSyncEnabled }) { f, v -> f.trx.logsynch_wintestQrgSyncEnabled = v },
flag("trx pass QRG", { it.trx.logsynch_wintestUsePassQrg }) { f, v -> f.trx.logsynch_wintestUsePassQrg = v },
Case("trx station filter", { it.trx.logsynch_wintestNetworkStationNameOfWintestClient1 += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() }),
// Airscout
flag("airscout listener", { it.airscout.airScout_asUDPListenerEnabled }) { f, v -> f.airscout.airScout_asUDPListenerEnabled = v },
Case("airscout server", { it.airscout.airScout_asServerNameString += "X" }, { it.prefs.getAirScout_asServerNameString() }),
Case("airscout client", { it.airscout.airScout_asClientNameString += "X" }, { it.prefs.getAirScout_asClientNameString() }),
Case("airscout port", { it.airscout.airScout_asCommunicationPort += 1 }, { it.prefs.getAirScout_asCommunicationPort() }),
flag("airscout auto band", { it.airscout.airScout_autoBandSelectionEnabled }) { f, v ->
f.airscout.airScout_autoBandSelectionEnabled = v
},
Case("airscout band", { f -> f.airscout.airScout_asBandString = ((f.airscout.airScout_asBandString.toLong()) + 1).toString() },
{ it.prefs.getAirScout_asBandString() }),
// Notification, all but the test spot
flag("sound simple", { it.notification.notify_playSimpleSounds }) { f, v -> f.notification.notify_playSimpleSounds = v },
flag("sound CW", { it.notification.notify_playCWCallsignsOnRxedPMs }) { f, v -> f.notification.notify_playCWCallsignsOnRxedPMs = v },
flag("sound voice", { it.notification.notify_playVoiceCallsignsOnRxedPMs }) { f, v ->
f.notification.notify_playVoiceCallsignsOnRxedPMs = v
},
flag("band upgrade hint", { it.notification.notify_bandUpgradeHintOnLogEnabled }) { f, v ->
f.notification.notify_bandUpgradeHintOnLogEnabled = v
},
flag("band upgrade boost", { it.notification.notify_bandUpgradePriorityBoostEnabled }) { f, v ->
f.notification.notify_bandUpgradePriorityBoostEnabled = v
},
Case("cluster server", { f -> f.notification.enableDxClusterServer(!f.notification.notify_dxClusterServerEnabled) },
{ it.prefs.isNotify_dxClusterServerEnabled() }),
Case("cluster port", { f -> f.notification.commitDxClusterServerPort((f.notification.notify_dxclusterServerPort + 1).toString()) },
{ it.prefs.getNotify_dxclusterServerPort() }),
Case("cluster spotter", { f ->
f.notification.commitSpotterCallSign(if (f.notification.notify_DXCSrv_SpottersCallSign == "DL1ABC") "DO5AMF" else "DL1ABC")
}, { it.prefs.getNotify_DXCSrv_SpottersCallSign().get() }),
Case("cluster fallback band", { f ->
f.notification.notify_optionalFrequencyPrefix =
if (f.notification.notify_optionalFrequencyPrefix == Band.B_432) Band.B_144 else Band.B_432
}, { it.prefs.getNotify_optionalFrequencyPrefix().get() }),
Case("monitoring list", { it.notification.addMonitoredCallSign("DL1ABC") }, { it.monitored.snapshot() }),
// Shortcuts
Case("shortcut buttons", { f ->
f.shortcuts.shortcuts.addAtTop("cq")
f.shortcuts.commitShortcuts()
}, { it.prefs.getLst_txtShortCutBtnList().snapshot() }),
Case("text snippets", { f ->
f.shortcuts.snippets.addAtTop("73")
f.shortcuts.commitSnippets()
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
{ it.prefs.getBcn_beaconTextSecondCat() }),
Case("beacon interval", { it.beacon.commitBeaconInterval((it.beacon.beaconIntervalMinutes + 1).toString()) },
{ it.prefs.getBcn_beaconIntervalInMinutesMainCat() }),
// Message handling
flag("auto answer", { it.messages.autoAnswerEnabled }) { f, v -> f.messages.autoAnswerEnabled = v },
Case("auto answer text", { it.messages.autoAnswerText += " x" }, { it.prefs.getMessageHandling_autoAnswerTextMainCat() }),
flag("QRG answer", { it.messages.autoAnswerToQRGRequestEnabled }) { f, v -> f.messages.autoAnswerToQRGRequestEnabled = v },
flag("debug to file", { it.messages.debugModeToFileEnabled }) { f, v -> f.messages.debugModeToFileEnabled = v },
// GUI, all but the language
flag("gui dark by default", { it.gui.gUI_darkModeActiveByDefault }) { f, v -> f.gui.gUI_darkModeActiveByDefault = v },
flag("gui filter nothing", { it.gui.guiOptions_defaultFilterNothing }) { f, v -> f.gui.guiOptions_defaultFilterNothing = v },
flag("gui filter pm to me", { it.gui.guiOptions_defaultFilterPmToMe }) { f, v -> f.gui.guiOptions_defaultFilterPmToMe = v },
flag("gui filter pm to other", { it.gui.guiOptions_defaultFilterPmToOther }) { f, v ->
f.gui.guiOptions_defaultFilterPmToOther = v
},
flag("gui filter public", { it.gui.guiOptions_defaultFilterPublicMsgs }) { f, v -> f.gui.guiOptions_defaultFilterPublicMsgs = v },
flag("gui fresh call hint", { it.gui.guiOptions_showFreshCallHintInBandColumns }) { f, v ->
f.gui.guiOptions_showFreshCallHintInBandColumns = v
},
flag("gui gross field hint", { it.gui.guiOptions_showGrossFieldWorkedHintInBandColumns }) { f, v ->
f.gui.guiOptions_showGrossFieldWorkedHintInBandColumns = v
},
)
@TestFactory
fun everyDraftedSettingWaitsForApply(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
assertEquals(before, storedNow(case, f), "${case.name} took effect before Apply")
f.draft.apply()
assertNotEquals(before, storedNow(case, f), "${case.name} did not take effect with Apply")
}
}
@TestFactory
fun everyDraftedSettingIsLeftAloneByDiscard(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
f.draft.discard()
assertEquals(before, storedNow(case, f), "${case.name} changed although discarded")
}
}
/*
* The stored value, read past the draft: a flag case reads through the tab, which shows
* the draft, so it reads from tab states over the same preferences with a draft of their own.
*/
private fun storedNow(case: Case, f: Fixture): Any? = case.stored(Fixture(f.prefs, f.monitored))
private fun fresh(): Path = Files.createTempDirectory(directory, "case")
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LanguageStoreFactory
import kst4contest.view.i18n.SYSTEM_LANGUAGE
@@ -9,8 +11,10 @@ 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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class GuiOptionsTabStateTest {
class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
/*
* Over lambdas rather than over the preferences, so switching the language in a test does
@@ -33,31 +37,46 @@ class GuiOptionsTabStateTest {
* 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))
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
assertEquals("de", state.language)
assertEquals("de", prefs.guiOptions_language)
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
assertFalse(draft.hasPendingChanges)
}
@Test
fun `reading comes straight from the preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
}
@Test
fun `writing reaches the preferences at once`() {
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, languageStore())
fun `an edit waits for apply, and discarding drops it`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, languageStore())
state.gUI_darkModeActiveByDefault = !prefs.isGUI_darkModeActiveByDefault()
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
assertFalse(prefs.isGuiOptions_defaultFilterPublicMsgs(), "an edit took effect before Apply")
assertTrue(state.guiOptions_defaultFilterPublicMsgs, "the tab shows the edit")
val darkBefore = prefs.isGUI_darkModeActiveByDefault()
draft.discard()
assertEquals(darkBefore, prefs.isGUI_darkModeActiveByDefault())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
draft.apply()
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
@@ -68,8 +87,8 @@ class GuiOptionsTabStateTest {
// 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())
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_defaultFilterPmToMe = true
@@ -0,0 +1,189 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The two tabs built around a list, drawn: Shortcuts and the worked-station database (#107).
*
* The shortcut lists are what the button row above the send line and the snippet menus are
* built from. Their write-through has a history: the refresh hook was called into thin air
* after the JavaFX buttons were unplugged, so an edited shortcut reached the operator only
* after the window was rebuilt (`fe4215bc`, Remove JavaFX). The hook is what these tests see.
*/
@OptIn(ExperimentalTestApi::class)
class ListTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Shortcuts
private class Shortcuts(isolated: ChatPreferences) {
val prefs = isolated.apply {
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
lst_txtSnipList.setAll(listOf("tnx qso 73"))
}
var shortcutRefreshes = 0
var snippetRefreshes = 0
val state = ShortcutsTabState(prefs, ImmediateDraft(), { shortcutRefreshes++ }, { snippetRefreshes++ })
}
@Test
fun shortcutsShowsBothListsWithWhatTheProfileHolds() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
onNodeWithText("Shortcut buttons above the message field").assertExists()
onNodeWithText("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)").assertExists()
onNode(hasSetTextAction() and hasText("pse sked")).assertExists()
onNode(hasSetTextAction() and hasText("tnx qso 73")).assertExists()
onNodeWithText("Add shortcut").assertExists()
onNodeWithText("Add new snippet").assertExists()
}
@Test
fun anEditedShortcutIsWrittenAndTheButtonsAreRefreshed() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
val row = onNode(hasSetTextAction() and hasText("pse sked"))
row.performScrollTo().performClick()
row.performTextReplacement("pse sked 432")
onNode(hasSetTextAction() and hasText("pse sked 432")).performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals(listOf("MYQRG", "pse sked 432"), s.prefs.lst_txtShortCutBtnList.snapshot())
assertTrue(s.shortcutRefreshes > 0, "the shortcut buttons were not told")
}
@Test
fun addingASnippetPutsTheTemplateOnTopAndWritesIt() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
onNodeWithText("Add new snippet").performScrollTo().performClick()
waitForIdle()
assertEquals(
listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "tnx qso 73"),
s.prefs.lst_txtSnipList.snapshot(),
)
assertTrue(s.snippetRefreshes > 0, "the snippet menus were not told")
assertEquals(listOf("MYQRG", "pse sked"), s.prefs.lst_txtShortCutBtnList.snapshot())
}
// ------------------------------------------------------------------ Worked database
private class Worked(affected: Int = 12) {
val roster = SimpleRoster<ChatMember>().apply {
setAll(listOf(ChatMember().apply { callSign = "DL1ABC"; isWorked = true; isWorked144 = true }))
}
var resets = 0
var guiRefreshes = 0
var viewRefreshes = 0
val state = WorkedDatabaseTabState(
resetInDatabase = { resets++; affected },
refreshGuiLists = { guiRefreshes++ },
refreshView = { viewRefreshes++ },
workedRoster = roster,
)
}
@Test
fun theWorkedDatabaseShowsItsRows() = runComposeUiTest {
val w = Worked()
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Worked station database").assertExists()
onNodeWithText("DL1ABC").assertExists()
onNodeWithText("wkd").assertExists()
}
@Test
fun refreshReReadsTheRoster() = runComposeUiTest {
val w = Worked()
showTab { WorkedDatabaseTab(w.state) }
runOnIdle { w.roster.setAll(listOf(ChatMember().apply { callSign = "OK1XYZ" })) }
onNodeWithText("Refresh worked database").performScrollTo().performClick()
waitForIdle()
assertEquals(1, w.viewRefreshes)
onNodeWithText("OK1XYZ").assertExists()
onNodeWithText("DL1ABC").assertDoesNotExist()
}
/** Nothing is touched until the dialog is confirmed; then the database goes first. */
@Test
fun aResetAsksFirstAndReportsWhatItDid() = runComposeUiTest {
val w = Worked(affected = 12)
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Reset contest data"))
assertEquals(0, w.resets, "the reset ran before it was confirmed")
onNode(inDialog("Cancel")).performClick()
awaitGone(inDialog("Reset contest data"))
assertEquals(0, w.resets)
onNodeWithText("Reset worked, NOT-QRV and grid data...").performClick()
waitForIdle()
awaitNode(inDialog("Reset data")).performClick()
awaitGone(inDialog("Reset data"))
assertEquals(1, w.resets)
assertEquals(1, w.guiRefreshes)
onNodeWithText("Last reset affected 12 rows.").assertExists()
}
@Test
fun aFailedResetIsSaidAndLeavesTheListsAlone() = runComposeUiTest {
val w = Worked(affected = -1)
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Reset data")).performClick()
awaitGone(inDialog("Reset data"))
awaitNode(
inDialog(
"The contest data could not be reset. The internal database may be unavailable or " +
"inconsistent. No successful reset was confirmed.",
),
)
assertEquals(0, w.guiRefreshes, "the lists were cleared although the database was not")
}
}
@@ -1,24 +1,27 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class LogSynchTabStateTest {
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session(detected: String? = null) {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var restarts = 0
var stops = 0
val detectedBroadcast = detected
val draft = SettingsDraft()
fun state() = LogSynchTabState(
prefs,
draft,
restartWintestListener = { restarts++ },
stopWintestListener = { stops++ },
detectWintestBroadcastAddress = { detectedBroadcast },
)
}
@@ -42,23 +45,44 @@ class LogSynchTabStateTest {
}
@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.
fun `an edit waits for apply, the chosen log file does not`() {
val s = Session()
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(false)
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
state.logsynch_ucxUDPWkdCallListenerPort = 12061
state.logsynch_wintestNetworkPort = 9879
// The file chooser is an action: the chosen path is written when chosen.
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertFalse(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled(), "an edit took effect before Apply")
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(12061, state.logsynch_ucxUDPWkdCallListenerPort, "the tab shows the edit")
s.draft.apply()
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())
assertEquals(12061, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9879, s.prefs.getLogsynch_wintestNetworkPort())
}
@Test
fun `a discarded edit changes nothing and touches no listener`() {
val s = Session()
val state = s.state()
val enabled = s.prefs.isLogsynch_wintestNetworkListenerEnabled()
state.logsynch_wintestNetworkListenerEnabled = !enabled
state.logsynch_wintestNetworkPort = 9879
s.draft.discard()
assertEquals(enabled, s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(0, s.restarts + s.stops)
}
@Test
@@ -66,91 +90,84 @@ class LogSynchTabStateTest {
// 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()
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
val state = s.state()
state.logsynch_wintestNetworkStationNameOfKST = " KST "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
state.logsynch_wintestNetworkStationNameOfKST = "KST "
assertFalse(s.draft.hasPendingChanges, "a trailing space is no change")
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
state.logsynch_wintestNetworkStationNameOfKST = " KST2 "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
s.draft.apply()
assertEquals("KST2", 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`() {
fun `applying the Win-Test switch starts or stops the listener`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkListenerEnabled = true
assertEquals(0, s.restarts, "the listener followed the box before Apply")
s.draft.apply()
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.restarts, "the running listener must follow the setting")
state.logsynch_wintestNetworkListenerEnabled = false
s.draft.apply()
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.
fun `an applied port change rebinds the listener only while it is enabled`() {
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
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkPort = 9872
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(0, s.restarts, "a disabled listener has nothing to rebind")
state.commitWintestNetworkPort()
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
state.logsynch_wintestNetworkListenerEnabled = true
val restartsAfterEnable = s.restarts
s.prefs.setLogsynch_wintestNetworkListenerEnabled(true)
state.logsynch_wintestNetworkPort = 9873
state.commitWintestNetworkPort()
assertEquals(0, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(restartsAfterEnable + 1, s.restarts)
assertEquals(1, s.restarts)
}
/*
* Switch and port in one apply: one restart, on the new port. Two restarts in a row can
* fail on the port the first has not released yet.
*/
@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")
fun `switch and port applied together rebind once, on the new port`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
var portAtRestart = -1
val state = LogSynchTabState(
s.prefs, s.draft,
restartWintestListener = { s.restarts++; portAtRestart = s.prefs.getLogsynch_wintestNetworkPort() },
stopWintestListener = { s.stops++ },
)
s.state()
state.logsynch_wintestNetworkListenerEnabled = true
state.logsynch_wintestNetworkPort = 9874
s.draft.apply()
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())
assertEquals(1, s.restarts)
assertEquals(9874, portAtRestart)
}
}
@@ -1,33 +1,71 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class MessageHandlingTabStateTest {
class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `the state starts from the stored preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(true)
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
assertTrue(state.autoAnswerEnabled)
assertTrue(state.autoAnswerToQRGRequestEnabled)
assertEquals("qrv 144.300", state.autoAnswerText)
}
@Test
fun `an edit waits for apply, and the recording follows only then`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs, draft) { recording += it }
state.autoAnswerText = "qrv 432.200"
state.debugModeToFileEnabled = !prefs.isMessageHandling_debugModeToFileEnabled()
val wanted = state.debugModeToFileEnabled
assertEquals("qrv 432.200", state.autoAnswerText, "the tab shows the edit")
assertTrue(recording.isEmpty(), "the recording followed before Apply")
draft.apply()
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
assertEquals(listOf(wanted), recording)
}
@Test
fun `a discarded edit changes nothing`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(false)
val draft = SettingsDraft()
val state = MessageHandlingTabState(prefs, draft)
state.autoAnswerEnabled = true
draft.discard()
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
assertFalse(state.autoAnswerEnabled)
}
@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)
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerEnabled = true
@@ -42,8 +80,8 @@ class MessageHandlingTabStateTest {
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences()
val state = MessageHandlingTabState(prefs)
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerText = "qrv 432.200"
@@ -52,9 +90,9 @@ class MessageHandlingTabStateTest {
}
@Test
fun `the QRG reply flag writes through on its own`() {
val prefs = ChatPreferences()
val state = MessageHandlingTabState(prefs)
fun `the QRG reply flag is stored on its own`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerToQRGRequestEnabled = true
@@ -0,0 +1,414 @@
package kst4contest.view.compose.tabs
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.observe.SimpleRoster
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import androidx.compose.ui.test.onNodeWithTag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
/**
* The tabs that decide what this station says and hears, drawn: Message handling, GUI,
* Beacon and Notification (#107).
*
* Each is composed headless, shows its sections and key controls, and an edit made through
* the drawn control reaches the preferences. The message-handling tab carries the
* debug-to-file switch that the Compose redraw had dropped and `c8ce0e98` gave back.
*/
@OptIn(ExperimentalTestApi::class)
class MessagingTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Message handling
@Test
fun messageHandlingWritesBothCategoriesAndAppliesTheFileRecording() = runComposeUiTest {
val prefs = preferences().apply {
isMessageHandling_autoAnswerEnabled = false
isMessageHandling_debugModeToFileEnabled = false
}
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs, ImmediateDraft()) { recording += it }
showTab { MessageHandlingTab(state) }
onNodeWithText("Automatic answer to private messages").assertExists()
onNodeWithText("Diagnostics").assertExists()
toggle("Answer private messages automatically")
assertTrue(prefs.isMessageHandling_autoAnswerEnabled)
assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat, "the second category was left behind")
type("Answer text", "qrv 432.200")
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextMainCat)
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextSecondCat)
// The c8ce0e98 control: it writes the preference and asks the application to act.
toggle("Record the whole session to file")
assertTrue(prefs.isMessageHandling_debugModeToFileEnabled)
assertEquals(listOf(true), recording)
}
// ------------------------------------------------------------------ GUI
@Test
fun guiOptionsWritesTheStartupChoicesAndTheLanguage() = runComposeUiTest {
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
var storedLanguage = "en"
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languages)
showTab { GuiOptionsTab(state) }
onNodeWithText("Startup design").assertExists()
onNodeWithText("Message filters active at startup").assertExists()
checkBeside("Start in the evening design").assertIsOff()
toggle("Start in the evening design")
assertTrue(prefs.isGUI_darkModeActiveByDefault)
val before = prefs.isGuiOptions_defaultFilterPublicMsgs
toggle("Public messages")
assertEquals(!before, prefs.isGuiOptions_defaultFilterPublicMsgs)
try {
onNodeWithText("EN").performScrollTo().performClick()
waitForIdle()
awaitNode(inPopup("DE")).performClick()
waitForIdle()
assertEquals("de", storedLanguage, "the picked language was not stored")
assertEquals("de", languages.language)
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
// ------------------------------------------------------------------ Beacon
/*
* A draft that applies at once by default, so the tests below keep pinning the field's
* commit rules (#128) against the stored value; the drafted behaviour is pinned by the
* tests that pass a real draft.
*/
private class Beacon(
val prefs: ChatPreferences,
val refuse: Boolean = false,
val draft: SettingsDraft = ImmediateDraft(),
) {
var restarts = 0
val refusals = mutableListOf<String>()
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = { restarts++ },
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
}
@Test
fun beaconShowsOneSectionPerCategoryAndCommitsOnEnter() = runComposeUiTest {
val s = Beacon(preferences())
showTab { BeaconTab(s.state) { s.refusals += it } }
onNodeWithText("CQ beacon for Microwave").assertExists()
onNodeWithText("CQ beacon for 144/432 MHz").assertExists()
onNodeWithText("Shared interval").assertExists()
typeAndCommit("Beacon message", "cq de dn9apw %MYQRG%", occurrence = 1)
assertEquals("cq de dn9apw %MYQRG%", s.prefs.bcn_beaconTextSecondCat)
typeAndCommit("Interval in whole minutes", "7")
assertEquals(7, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
/** A refused template is reported, not stored, and the field goes back to what is stored. */
@Test
fun beaconRefusesAnInvalidTemplateAndPutsTheFieldBack() = runComposeUiTest {
val s = Beacon(preferences(), refuse = true)
s.prefs.bcn_beaconTextMainCat = "cq cq"
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndCommit("Beacon message", "cq %foo%")
assertEquals("cq cq", s.prefs.bcn_beaconTextMainCat)
assertEquals(1, s.refusals.size)
fieldBeside("Beacon message").assertTextEquals("cq cq")
}
/*
* Opening the tab used to commit all three fields once, because a committed field's
* focus callback fires with "not focused" when it is first attached: the beacon timer
* was restarted and a stored template the validator refuses was reported, although
* the operator had done nothing but look.
*/
@Test
fun openingTheBeaconTabNeitherRestartsTheTimerNorValidates() = runComposeUiTest {
val s = Beacon(preferences(), refuse = true)
s.prefs.bcn_beaconTextMainCat = "cq %foo%"
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
assertEquals(0, s.restarts, "opening the tab restarted the beacon timer")
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
}
/*
* The tab reads the interval from a plain preference getter, so nothing recomposes it
* after Enter commits. Compared with that stale value, leaving the field afterwards
* looked like a second edit and restarted the timer again.
*/
@Test
fun enterAndThenLeavingTheFieldRestartsTheTimerOnce() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndCommit("Interval in whole minutes", "9")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, s.restarts, "one edit restarted the beacon timer more than once")
}
/*
* Pins what happens to a typed but uncommitted entry when the field leaves the screen
* while it has the focus -- the settings window switching to another tab, or closing.
* Compose clears the focus of a removed focused node and reports that to the field as a
* focus loss, so the entry is committed, exactly once. That was so before the field
* learned to tell a real focus loss from its first attachment, and must stay so: an
* operator who typed a value and switched tabs expects it to count.
*/
@Test
fun aFieldRemovedWhileTypingCommitsTheEntryOnce() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val visible = mutableStateOf(true)
showTab { if (visible.value) BeaconTab(s.state) { s.refusals += it } }
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
runOnIdle { visible.value = false }
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
assertEquals(1, s.restarts)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
/*
* The stored value can change under a field -- a profile switch puts 5 back after the field
* committed 9. The field used to keep showing 9 then, because nothing recomposed the tab
* after a commit, and #128 made leaving it commit the shown 9 again. Since the edits go
* through the settings draft, which is Compose state, a commit recomposes the tab: the field
* follows the stored value, shows the 5, and leaving it commits nothing. Either way what the
* field shows is what is stored once the operator has left it.
*/
@Test
fun aValueResetBehindTheFieldIsShownAndNotCommittedAgain() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val generation = mutableStateOf(0)
val reportA: (String) -> Unit = { s.refusals += it }
val reportB: (String) -> Unit = { s.refusals += it }
// A different callback per generation, so bumping it recomposes BeaconTab itself.
showTab { BeaconTab(s.state, if (generation.value % 2 == 0) reportA else reportB) }
typeAndCommit("Interval in whole minutes", "9")
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
runOnIdle {
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
generation.value++
}
waitForIdle()
fieldBeside("Interval in whole minutes").assertTextEquals("5")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(5, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the field committed a value it no longer showed")
assertEquals(1, s.restarts)
}
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
@Test
fun aTypedIntervalIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndLeave("Interval in whole minutes", "9", leaveTo = "Beacon message")
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, s.restarts)
typeAndLeave("Beacon message", "cq de dn9apw", leaveTo = "Beacon message", leaveToOccurrence = 1)
assertEquals("cq de dn9apw", s.prefs.bcn_beaconTextMainCat)
assertEquals(1, s.restarts, "leaving a template field restarted the timer")
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
// ------------------------------------------------------------------ Notification
private class Notification(val prefs: ChatPreferences, val draft: SettingsDraft = ImmediateDraft()) {
init {
prefs.isNotify_dxClusterServerEnabled = false
}
val monitored = SimpleRoster<String>()
val serverEnabled = mutableListOf<Boolean>()
val ports = mutableListOf<Int>()
val spots = mutableListOf<ChatMember>()
val refusals = mutableListOf<String>()
val state = NotificationTabState(
prefs,
draft,
monitored,
// What the server is brought to on apply: its switch, and the port it binds.
applyDxClusterServer = {
serverEnabled += prefs.isNotify_dxClusterServerEnabled
ports += prefs.notify_dxclusterServerPort
},
broadcastTestSpot = { spots += it; "no logger connected" },
)
}
@Test
fun notificationStoresItsFieldsAndStartsTheClusterServer() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
listOf("Sounds", "Band upgrade hints", "Local DX Cluster server", "QSO monitoring")
.forEach { onNodeWithText(it).assertExists() }
val before = s.prefs.isNotify_playSimpleSounds
toggle("Play simple sounds")
assertEquals(!before, s.prefs.isNotify_playSimpleSounds)
toggle("Run the DX Cluster server")
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
assertEquals(listOf(true), s.serverEnabled)
s.ports.clear()
typeAndCommit("Spotter callsign", "dn9apw")
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
val storedPort = s.prefs.notify_dxclusterServerPort
typeAndCommit("TCP port", "0")
assertEquals(1, s.refusals.size, "an impossible port was not refused")
assertEquals(storedPort, s.prefs.notify_dxclusterServerPort)
assertTrue(s.ports.isEmpty(), "the server was moved to a refused port")
}
/*
* Opening the tab used to commit the port and the spotter callsign once, because a
* committed field's focus callback fires with "not focused" when it is first attached.
* A stored value the rule refuses was then reported the moment the tab was looked at.
*/
@Test
fun openingTheNotificationTabCommitsNothing() = runComposeUiTest {
val s = Notification(preferences())
s.prefs.setNotify_DXCSrv_SpottersCallSign("x")
showTab { NotificationTab(s.state) { s.refusals += it } }
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
assertTrue(s.ports.isEmpty(), "opening the tab moved the cluster server")
assertEquals("x", s.prefs.notify_DXCSrv_SpottersCallSign.get())
}
/** Leaving the field commits what was typed, without Enter. */
@Test
fun aTypedSpotterCallsignAndPortAreCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val s = Notification(preferences())
s.prefs.notify_dxclusterServerPort = 7300
showTab { NotificationTab(s.state) { s.refusals += it } }
typeAndLeave("Spotter callsign", "do5amf", leaveTo = "TCP port")
assertEquals("DO5AMF", s.prefs.notify_DXCSrv_SpottersCallSign.get())
typeAndLeave("TCP port", "7301", leaveTo = "Spotter callsign")
assertEquals(7301, s.prefs.notify_dxclusterServerPort)
assertEquals(listOf(7301), s.ports)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
@Test
fun theFallbackBandAndTheTestSpotReachTheirTargets() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
val current = s.state.notify_optionalFrequencyPrefix
onNodeWithText(current.prefix + " MHz").performScrollTo().performClick()
waitForIdle()
val other = Band.entries.first { it != current }
awaitNode(inPopup(other.prefix + " MHz")).performClick()
waitForIdle()
assertEquals(other.prefix, s.prefs.notify_optionalFrequencyPrefix.get())
onNodeWithText("Send a test spot").performScrollTo().performClick()
waitForIdle()
assertEquals("DO5AMF", s.spots.single().callSign)
assertEquals(listOf("no logger connected"), s.refusals, "the outcome was not reported")
}
/** The add button asks for the callsign first; what is confirmed reaches the roster. */
@Test
fun aMonitoredCallsignAddedThroughTheDialogReachesTheRoster() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
onNodeWithText("Add monitored callsign").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Add a callsign to the monitoring list"))
onNodeWithTag(MONITORED_CALLSIGN_FIELD_TAG).performTextReplacement("DL1ABC")
waitForIdle()
awaitNode(inDialog("OK")).performClick()
awaitGone(inDialog("OK"))
assertEquals(listOf("DL1ABC"), s.monitored.snapshot())
assertFalse(s.refusals.isNotEmpty(), "the callsign was refused: ${s.refusals}")
}
}
@@ -4,6 +4,8 @@ import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -11,28 +13,104 @@ 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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class NotificationTabStateTest {
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
var serverApplies = 0
var enabledAtApply: Boolean? = null
var portAtApply: Int? = null
var spotSent: ChatMember? = null
var spotRefusal: String? = null
fun state() = NotificationTabState(
prefs,
draft,
monitored,
applyDxClusterServerEnabled = { serverEnabledApplied = it },
applyDxClusterServerPort = { portApplied = it },
applyDxClusterServer = {
serverApplies++
enabledAtApply = prefs.isNotify_dxClusterServerEnabled()
portAtApply = prefs.getNotify_dxclusterServerPort()
},
broadcastTestSpot = { spotSent = it; spotRefusal },
)
}
@Test
fun `the sound settings read from and write to the preferences at once`() {
fun `an edit waits for apply, and discarding drops it`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
state.notify_playSimpleSounds = false
state.notify_optionalFrequencyPrefix = Band.B_432
assertNull(state.commitSpotterCallSign("DO5AMF"))
assertTrue(s.prefs.isNotify_playSimpleSounds(), "an edit took effect before Apply")
assertFalse(state.notify_playSimpleSounds, "the tab shows the edit")
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
s.draft.discard()
assertTrue(s.prefs.isNotify_playSimpleSounds())
assertTrue(state.notify_playSimpleSounds)
state.notify_playSimpleSounds = false
s.draft.apply()
assertFalse(s.prefs.isNotify_playSimpleSounds())
}
@Test
fun `the server is touched on apply only, and once for switch and port together`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_dxClusterServerEnabled(false)
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
state.enableDxClusterServer(true)
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(0, s.serverApplies, "the server moved before Apply")
s.draft.apply()
assertEquals(1, s.serverApplies, "two restarts in a row can fail on the port")
assertEquals(true, s.enabledAtApply)
assertEquals(7300, s.portAtApply, "the server must come up on the new port")
}
@Test
fun `the monitoring list reaches the roster on apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.addMonitoredCallSign("DN9APW"))
assertTrue(s.monitored.snapshot().isEmpty(), "the list took effect before Apply")
assertTrue(s.draft.hasPendingChanges)
s.draft.apply()
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
}
@Test
fun `the test spot is an action and needs no apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.sendTestSpot())
assertNotNull(s.spotSent)
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `the sound settings read from and write to the preferences`() {
val s = Session()
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
@@ -49,7 +127,7 @@ class NotificationTabStateTest {
}
@Test
fun `the band-upgrade hints write through`() {
fun `the band-upgrade hints reach the preferences`() {
val s = Session()
val state = s.state()
@@ -63,12 +141,13 @@ class NotificationTabStateTest {
@Test
fun `enabling the DX cluster server also tells the running session`() {
val s = Session()
s.prefs.setNotify_dxClusterServerEnabled(false)
val state = s.state()
state.enableDxClusterServer(true)
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
assertEquals(true, s.serverEnabledApplied,
assertEquals(true, s.enabledAtApply,
"the JavaFX checkbox started or stopped the server as well, and the "
+ "settings coverage test cannot see that call")
}
@@ -81,7 +160,7 @@ class NotificationTabStateTest {
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
assertEquals(7300, s.portApplied)
assertEquals(7300, s.portAtApply)
}
@Test
@@ -94,7 +173,7 @@ class NotificationTabStateTest {
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")
assertEquals(0, s.serverApplies, "an refused port must not restart the server")
}
@Test
@@ -115,7 +194,7 @@ class NotificationTabStateTest {
assertNull(state.commitDxClusterServerPort("8000"))
assertNull(s.portApplied,
assertEquals(0, s.serverApplies,
"the JavaFX field only restarted the server when the port actually changed")
}
@@ -153,15 +232,15 @@ class NotificationTabStateTest {
}
@Test
fun `an unsupported stored prefix falls back to 144 and is written back`() {
val s = Session()
fun `an unsupported stored prefix is shown as 144 without writing anything`() {
val s = Session(SettingsDraft())
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")
assertEquals("4711", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"reading the tab wrote the preferences; repairStoredSettings does that, once")
}
@Test
@@ -182,7 +261,7 @@ class NotificationTabStateTest {
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")
"applied, the roster holds the entry")
}
@Test
@@ -0,0 +1,316 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.OperatorProfileStore
import kst4contest.model.OperatorProfile
import kst4contest.model.PaletteRole
import kst4contest.view.compose.JavaFxStylesheet
import kst4contest.view.compose.OperatorProfilePaletteFiles
import kst4contest.view.compose.PaletteSource
import kst4contest.view.compose.PaletteStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
/**
* The two tabs that belong to the operator profile, drawn: Profiles and Colours (#107).
*
* Profiles: only what refuses before the service writes anything, or what writes into a
* temporary registry, is exercised. Creating, renaming and deleting a real profile reach
* into the application directory under the operator's home ([ProfilesTabStateTest] says why
* that is not done here either).
*
* Colours: a store made of lambdas and stylesheet files in a temporary directory, so nothing
* reaches the operator's profile. The theme reading the store is `ThemeReadsPaletteStoreTest`;
* this is the tab that writes into it.
*/
@OptIn(ExperimentalTestApi::class)
class ProfileTabsScreenTest {
@TempDir
lateinit var directory: Path
// ------------------------------------------------------------------ Profiles
private fun profilesState(activated: MutableList<OperatorProfile> = mutableListOf()) =
ProfilesTabState(
OperatorProfileManagementService(
OperatorProfileStore(directory.resolve("profiles.xml").toString())
),
{ null },
{ activated += it },
)
@Test
fun profilesShowsTheDefaultProfileAndItsActions() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("Active profile:").assertExists()
onNodeWithText("Profile").assertExists()
onNodeWithText(state.profiles.single().displayName).assertExists()
listOf("New profile...", "Duplicate...", "Rename...", "Delete...",
"Change worked stations...", "Switch to selected profile...")
.forEach { onNodeWithText(it).assertExists() }
}
/** The default profile is the installation's own; deleting it is refused, and said so. */
@Test
fun deletingTheDefaultProfileIsRefusedInADialog() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("Delete...").performScrollTo().performClick()
waitForIdle()
// A selected profile is always asked about first; the refusal comes on confirmation.
awaitNode(inDialog("Delete operator profile"))
onNode(inDialog("Delete profile")).performClick()
awaitNode(
inDialog("The default profile uses the files of the installation itself and cannot be deleted."),
)
onNode(inDialog("OK")).performClick()
awaitGone(inDialog("Operator profiles"))
assertEquals(1, state.profiles.size)
}
@Test
fun switchingAsksTheApplicationToActivateTheSelectedProfile() = runComposeUiTest {
val activated = mutableListOf<OperatorProfile>()
val state = profilesState(activated)
showTab { ProfilesTab(state) }
onNodeWithText("Switch to selected profile...").performScrollTo().performClick()
waitForIdle()
assertEquals(listOf(state.profiles.single()), activated)
}
/** The New dialog opens and, cancelled, creates nothing. */
@Test
fun aCancelledNewProfileDialogCreatesNothing() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("New profile...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("New operator profile"))
onNode(inDialog("Cancel")).performClick()
awaitGone(inDialog("New operator profile"))
assertEquals(1, state.profiles.size)
}
// ------------------------------------------------------------------ Colours
private class Colours(directory: Path) {
val stored = mutableMapOf(false to "", true to "")
val store = PaletteStore(
shippedOf = { dark ->
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { emptyMap() },
storedOverridesOf = { dark -> stored.getValue(dark) },
storeOverrides = { dark, encoded -> stored[dark] = encoded },
)
val files = OperatorProfilePaletteFiles(
dayFile = directory.resolve("day.css").toFile(),
eveningFile = directory.resolve("evening.css").toFile(),
)
val state = ColoursTabState(store, { false }, files)
val refusals = mutableListOf<String>()
val outcomes = mutableListOf<String>()
}
private fun ComposeUiTest.show(c: Colours) {
showTab { ColoursTab(c.state, { c.refusals += it }, { c.outcomes += it }) }
}
/** The label a role row carries right now: its name and where its colour came from. */
private fun Colours.labelOf(role: PaletteRole, name: String): String {
val provenance = when (state.roles().single { it.role == role }.source) {
PaletteSource.SHIPPED -> "shipped"
PaletteSource.FILE -> "from file"
PaletteSource.CHANGED -> "changed"
}
return "$name ($provenance)"
}
@Test
fun coloursShowsTheSixRolesAndTheWaysBack() = runComposeUiTest {
val c = Colours(directory)
show(c)
onNodeWithText("Colours of this design").assertExists()
onNodeWithText("Ways back").assertExists()
listOf(
PaletteRole.SURFACE to "Surface (menu strip, selected rows)",
PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)",
PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)",
PaletteRole.TEXT to "Text",
PaletteRole.ACCENT to "Accent",
PaletteRole.SEPARATOR to "Separator line",
).forEach { (role, name) ->
fieldBeside(c.labelOf(role, name)).assertTextEquals(c.state.roles().single { it.role == role }.hex)
}
onNodeWithText(
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
).assertExists()
}
@Test
fun aTypedColourIsCommittedIntoTheStore() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
assertEquals("#3C7A4B", c.state.roles().single { it.role == PaletteRole.ACCENT }.hex)
assertEquals(Color(0xFF3C7A4B), c.store.resolved(false).palette.accent)
assertTrue(c.refusals.isEmpty(), "a valid colour was refused: ${c.refusals}")
}
/*
* Opening the tab used to commit every role field once, because a committed field's
* focus callback fires with "not focused" when it is first attached. Each role then
* became an override of itself: the labels said "changed" instead of "shipped", and
* after "Save settings" the overrides hid the profile's stylesheet file for good.
*/
@Test
fun openingTheTabWritesNoColour() = runComposeUiTest {
val c = Colours(directory)
show(c)
assertEquals(mapOf(false to "", true to ""), c.stored, "opening the tab wrote overrides")
c.state.roles().forEach { row ->
assertEquals(PaletteSource.SHIPPED, row.source, "${row.role} is no longer the shipped colour")
}
assertTrue(c.refusals.isEmpty(), "opening the tab reported: ${c.refusals}")
}
/** Passing through a field without typing is not an edit either. */
@Test
fun enteringAndLeavingAFieldWithoutTypingWritesNothing() = runComposeUiTest {
val c = Colours(directory)
show(c)
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
val text = c.labelOf(PaletteRole.TEXT, "Text")
fieldBeside(accent).performScrollTo().performClick()
waitForIdle()
fieldBeside(text).performScrollTo().performClick()
waitForIdle()
fieldBeside(accent).performScrollTo().performClick()
waitForIdle()
assertEquals(mapOf(false to "", true to ""), c.stored, "passing through a field wrote an override")
}
/*
* The field shows the colour in upper case. Typing the same colour in lower case names
* no other colour, so it is no edit and must not become an override of itself.
*/
@Test
fun theSameColourInOtherCaseIsNoChange() = runComposeUiTest {
val c = Colours(directory)
show(c)
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
val shown = c.state.roles().single { it.role == PaletteRole.ACCENT }.hex
typeAndCommit(accent, shown.lowercase())
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).performScrollTo().performClick()
waitForIdle()
assertEquals(mapOf(false to "", true to ""), c.stored, "an unchanged colour was written as an override")
assertEquals(PaletteSource.SHIPPED, c.state.roles().single { it.role == PaletteRole.ACCENT }.source)
assertTrue(c.refusals.isEmpty(), "the colour was refused: ${c.refusals}")
}
/** Leaving the field still commits what was typed, without Enter. */
@Test
fun aTypedColourIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndLeave(
c.labelOf(PaletteRole.ACCENT, "Accent"),
"#3C7A4B",
leaveTo = c.labelOf(PaletteRole.TEXT, "Text"),
)
val accent = c.state.roles().single { it.role == PaletteRole.ACCENT }
assertEquals("#3C7A4B", accent.hex)
assertEquals(PaletteSource.CHANGED, accent.source)
assertEquals(
PaletteSource.SHIPPED,
c.state.roles().single { it.role == PaletteRole.TEXT }.source,
"the field that was only entered became an override",
)
}
@Test
fun aMistypedColourIsRefusedAndChangesNothing() = runComposeUiTest {
val c = Colours(directory)
show(c)
val before = c.state.roles().single { it.role == PaletteRole.TEXT }.hex
typeAndCommit(c.labelOf(PaletteRole.TEXT, "Text"), "#12")
assertEquals(1, c.refusals.size)
assertEquals(before, c.state.roles().single { it.role == PaletteRole.TEXT }.hex)
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).assertTextEquals(before)
}
@Test
fun resetToShippedPutsEveryRoleBack() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
onNodeWithText(
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
).performScrollTo().performClick()
waitForIdle()
assertEquals(c.store.shipped(false).accent, c.store.resolved(false).palette.accent)
}
/** The export writes the shipped sheet into the profile, and never over an existing file. */
@Test
fun theExportWritesTheStylesheetOnceAndThenLeavesItAlone() = runComposeUiTest {
val c = Colours(directory)
show(c)
val day: File = c.files.dayFile
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
waitForIdle()
assertTrue(day.isFile, "nothing was written")
assertEquals(listOf("Written to ${day.path}. Edit it and restart to see the result."), c.outcomes)
day.writeText("/* the operator's own */")
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
waitForIdle()
assertEquals("/* the operator's own */", day.readText(), "an existing stylesheet was replaced")
assertEquals("${day.path} is already there and was left untouched.", c.outcomes.last())
}
}
@@ -0,0 +1,579 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.Form
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.SettingsQuestion
import kst4contest.view.compose.runOnce
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotice
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.applyAndSave
import kst4contest.view.compose.SettingsWindow
import kst4contest.view.compose.applyChecked
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.inDialog
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 org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The settings window around its tabs: edits wait in the draft, Apply/Close applies them,
* Save applies before it writes, and the X asks about what is still waiting.
*
* Drawn with the Beacon tab because it has all three kinds of control that matter here: a
* checkbox, a field that commits on Enter or focus loss, and an effect (the timer restart).
* The button bar is a stand-in: the real one needs a ChatController, which opens a database;
* it calls the same [applyChecked] and [applyAndSave] this one does.
*/
@OptIn(ExperimentalTestApi::class)
class SettingsApplyScreenTest {
@TempDir
lateinit var directory: Path
private inner class Window(refuse: Boolean = false, timerFails: Boolean = false) {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
bcn_beaconIntervalInMinutesMainCat = 5
isBcn_beaconsEnabledMainCat = false
}
val draft = SettingsDraft()
val notices = SettingsNotices()
val question = SettingsQuestion()
var closed = false
var restarts = 0
/** The title bar's X. */
fun x() = question.settleClose(draft, notices)
/** A profile switch or the exit, as SettingsWindow.settleThen hands it on. */
fun switchOrExit(action: () -> Unit) = question.settle(draft, notices, then = action, nothingPending = action)
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = {
restarts++
if (timerFails) throw IllegalStateException("timer gone")
},
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
val station = StationTabState(prefs, draft, { }, { })
val airscout = AirscoutTabState(prefs, draft)
val tabs = listOf(
SettingsTab("Beacon") {
androidx.compose.foundation.layout.Column {
BeaconTab(state, notices::problem)
StationTab(station)
}
},
)
}
private fun androidx.compose.ui.test.ComposeUiTest.show(w: Window, tabs: List<SettingsTab> = w.tabs) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = tabs,
notices = w.notices,
buttons = { close ->
Form.button(APPLY_AND_CLOSE) { if (applyChecked(w.draft, w.notices)) close() }
},
close = { w.closed = true },
draft = w.draft,
question = w.question,
)
}
}
waitForIdle()
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/*
* A click on a button leaves the focus in the text field, so the field has not committed
* when the button runs. Apply commits it first; otherwise the entry would reach the draft
* only when the closing window removed the field -- after the apply, into nothing.
*/
@Test
fun applyAndCloseTakesTheEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/* A refused entry stops the button, so the reason is not closed away with the window. */
@Test
fun aRefusedEntryKeepsTheWindowOpenAndShowsWhy() = runComposeUiTest {
val w = Window(refuse = true)
show(w)
val field = fieldBeside("Beacon message")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("cq %foo%")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed over a refused entry")
assertTrue(w.notices.current is SettingsNotice.Problem)
}
/*
* A refusal stops the apply before anything goes into effect: the valid edit beside the
* refused one is still pending, so the operator can still discard everything.
*/
@Test
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
/*
* A number field showing text that does not parse holds the last valid value in the draft.
* Applying that value would put something into effect the operator no longer sees.
*/
@Test
fun anInvalidNumberRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
val storedPort = w.prefs.stn_on4kstServersPort
show(w)
type("Server port", "23005")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed with the old port applied")
assertEquals(storedPort, w.prefs.stn_on4kstServersPort)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("Server port") &&
notice.message.contains("23x"), "the notice does not name the field: $notice")
}
/* The same for the guarded fields of the external interfaces. */
@Test
fun anInvalidGuardedEntryRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
show(w, listOf(SettingsTab("Airscout") { AirscoutTab(w.airscout) }))
type(AIRSCOUT_PORT, "0")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains(AIRSCOUT_PORT),
"the notice does not name the field: $notice")
}
/* A failing effect is reported, the window stays open, and the other changes are in. */
@Test
fun aFailingEffectIsReportedAndTheRestIsApplied() = runComposeUiTest {
val w = Window(timerFails = true)
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the failure was closed away")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone"), "$notice")
}
@Test
fun theXClosesAtOnceWhenNothingIsPending() = runComposeUiTest {
val w = Window()
show(w)
runOnIdle { w.x() }
waitForIdle()
assertTrue(w.closed)
}
@Test
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed, "the window closed without asking")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.closed)
}
@Test
fun discardingOnTheXChangesNothing() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
assertFalse(w.draft.hasPendingChanges)
}
@Test
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
/* An entry typed just before the X counts as pending, although no Enter committed it. */
@Test
fun theXAsksAboutAnEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
}
/* A list row still being edited counts too: the lists commit a row on Enter or focus loss. */
@Test
fun theXAsksAboutARowStillBeingEdited() = runComposeUiTest {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
}
val draft = SettingsDraft()
val question = SettingsQuestion()
val notices = SettingsNotices()
var closed = false
val state = ShortcutsTabState(prefs, draft, { }, { })
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = listOf(SettingsTab("Shortcuts") { ShortcutsTab(state) { } }),
notices = notices,
buttons = { },
close = { closed = true },
draft = draft,
question = question,
)
}
}
waitForIdle()
val row = onNode(hasSetTextAction() and hasText("pse sked"))
row.performScrollTo().performClick()
waitForIdle()
row.performTextReplacement("pse sked 432")
waitForIdle()
runOnIdle { question.settleClose(draft, notices) }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertEquals(listOf("MYQRG", "pse sked 432"), prefs.lst_txtShortCutBtnList.snapshot())
assertTrue(closed)
}
/*
* A profile switch or the exit asks the same question. Apply applies the edits to the
* profile still in force and goes on; the window itself is left to the teardown.
*/
@Test
fun aSwitchOrExitAsksAndApplyGoesOnWithTheEditsApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@Test
fun goingBackCancelsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "the switch or exit went ahead after Back")
assertTrue(w.draft.hasPendingChanges)
}
@Test
fun discardingGoesOnWithNothingApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
@Test
fun aRefusalAtTheQuestionStopsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
type("Server port", "23x")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
}
@Test
fun withNothingPendingTheSwitchOrExitGoesOnAtOnce() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
runOnIdle { w.switchOrExit { wentOn = true } }
waitForIdle()
assertTrue(wentOn)
assertFalse(w.closed)
}
/*
* An X while the exit or switch question is open must not replace its continuation:
* Apply would then only close the window, and the exit would be dropped without a word.
*/
@Test
fun theXDoesNotReplaceAnOpenSwitchOrExitQuestion() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
runOnIdle { w.x() }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn, "the exit or switch was dropped")
assertFalse(w.closed)
}
/*
* Invalid text in a field refuses Apply, but must not trap the operator: the question is
* shown anyway, with the reason, and Discard closes.
*/
@Test
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
awaitNode(inDialog("Apply")).assertIsNotEnabled()
onNode(hasText("23x", substring = true) and hasAnyAncestor(isDialog())).assertExists()
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@Test
fun anInvalidFieldStillLetsASwitchOrExitDiscard() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
type("Server port", "23x")
runOnIdle { w.switchOrExit { wentOn = true } }
assertFalse(wentOn, "went on past a field that refuses its text")
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
}
@Test
fun aContinuationRunsOnlyOnce() {
var runs = 0
val once = runOnce { runs++ }
once.run()
once.run()
assertEquals(1, runs)
}
/* Without a settings window there is nothing to ask: the action runs at once, synchronously. */
@Test
fun settleThenRunsAtOnceWithoutAnOpenWindow() {
var ran = false
SettingsWindow.settleThen { ran = true }
assertTrue(ran)
}
/* A failed effect does not keep the file from being written; its setting was stored. */
@Test
fun saveStillWritesWhenOnlyAnEffectFailed() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { throw IllegalStateException("timer gone") }, "Microwave", "144/432 MHz")
var saved = false
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
saved = true
"preferences.xml"
}
assertTrue(saved)
val notice = notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone") &&
notice.message.contains("preferences.xml"), "$notice")
}
/* Save applies first, so the file holds what the window shows; the window stays open. */
@Test
fun saveAppliesBeforeItWrites() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
var intervalWhenSaved = -1
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
intervalWhenSaved = prefs.bcn_beaconIntervalInMinutesMainCat
"preferences.xml"
}
assertEquals(9, intervalWhenSaved, "the file was written before the draft was applied")
assertFalse(draft.hasPendingChanges)
assertTrue(notices.current is SettingsNotice.Info)
}
private companion object {
const val APPLY_AND_CLOSE = "Apply/Close prefs"
const val AIRSCOUT_PORT = "AirScout UDP port [9872] — reconnect after changing:"
const val PENDING_QUESTION =
"Some settings have been changed but not applied yet. Apply them first?"
}
}
@@ -0,0 +1,140 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import kst4contest.view.compose.Kst4ContestTheme
/*
* Shared plumbing for the settings-tab screen tests (#107).
*
* The form controls draw a label beside a field, not a labelled field: there is no semantic
* link between the two. These helpers find a control by the label the operator reads, on the
* same row, which is exactly how the operator finds it -- and which breaks, as it should,
* when a label moves away from its field.
*/
/**
* Draws one tab the way the settings window does: themed, in a column that scrolls, so a
* control further down can be scrolled to and clicked.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
content()
}
}
}
waitForIdle()
}
@OptIn(ExperimentalTestApi::class)
private fun ComposeUiTest.labelNode(label: String, occurrence: Int): SemanticsNode {
val labels = onAllNodes(hasText(label, substring = false)).fetchSemanticsNodes()
check(labels.size > occurrence) { "no label '$label' (#$occurrence) on screen; found ${labels.size}" }
return labels.sortedBy { it.boundsInRoot.top }[occurrence]
}
/** The control matching [matcher] on the row of [label], picked by [choose] among candidates. */
@OptIn(ExperimentalTestApi::class)
private fun ComposeUiTest.besideLabel(
label: String,
occurrence: Int,
matcher: SemanticsMatcher,
choose: (label: SemanticsNode, candidates: List<SemanticsNode>) -> SemanticsNode?,
): SemanticsNodeInteraction {
val anchor = labelNode(label, occurrence)
val all = onAllNodes(matcher).fetchSemanticsNodes()
val sameRow = all.filter { node ->
node.boundsInRoot.center.y in anchor.boundsInRoot.top..anchor.boundsInRoot.bottom
}
val chosen = choose(anchor, sameRow) ?: error("no control beside '$label' (#$occurrence)")
return onAllNodes(matcher)[all.indexOf(chosen)]
}
/**
* The text field to the right of [label]. Matched by its editable text rather than by its
* set-text action, which a disabled field does not offer, so a locked field is found too.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.fieldBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
besideLabel(label, occurrence, SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) { anchor, candidates ->
candidates.filter { it.boundsInRoot.left >= anchor.boundsInRoot.right - 1f }
.minByOrNull { it.boundsInRoot.left }
}
/** The checkbox directly to the left of [label], as [kst4contest.view.compose.Form.check] draws it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.checkBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
besideLabel(label, occurrence, isToggleable()) { anchor, candidates ->
candidates.filter { it.boundsInRoot.right <= anchor.boundsInRoot.left + 1f }
.maxByOrNull { it.boundsInRoot.right }
}
/** Ticks or unticks the checkbox beside [label], scrolling to it first. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.toggle(label: String, occurrence: Int = 0) {
checkBeside(label, occurrence).performScrollTo().performClick()
waitForIdle()
}
/** Replaces the text of the field beside [label], as typing a whole new value would. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.type(label: String, text: String, occurrence: Int = 0) {
fieldBeside(label, occurrence).performTextReplacement(text)
waitForIdle()
}
/**
* Types into a field that commits on Enter or on leaving it, the way
* [kst4contest.view.compose.Form.committed] does, and presses Enter.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.typeAndCommit(label: String, text: String, occurrence: Int = 0) {
val field = fieldBeside(label, occurrence)
field.performScrollTo().performClick()
field.performTextReplacement(text)
field.performKeyInput { pressKey(Key.Enter) }
waitForIdle()
}
/**
* Types into a field that commits on leaving it, then leaves it the way the operator does:
* by clicking into the field beside [leaveTo]. No Enter is pressed, so only the focus loss
* can commit the entry.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.typeAndLeave(
label: String,
text: String,
leaveTo: String,
occurrence: Int = 0,
leaveToOccurrence: Int = 0,
) {
val field = fieldBeside(label, occurrence)
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement(text)
waitForIdle()
fieldBeside(leaveTo, leaveToOccurrence).performScrollTo().performClick()
waitForIdle()
}
@@ -1,20 +1,29 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ShortcutsTabStateTest {
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var shortcutRefreshes = 0
var snippetRefreshes = 0
fun state() = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = { shortcutRefreshes++ },
refreshTextSnippetContextMenus = { snippetRefreshes++ },
)
@@ -33,7 +42,42 @@ class ShortcutsTabStateTest {
}
@Test
fun `a new shortcut lands at the top and reaches the roster at once`() {
fun `a list edit waits for apply and rebuilds the buttons once`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
state.shortcuts.addAtTop("cq")
state.commitShortcuts()
state.shortcuts.addAtTop("tnx")
state.commitShortcuts()
assertEquals(listOf("alt"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
"the list took effect before Apply")
assertEquals(0, s.shortcutRefreshes)
s.draft.apply()
assertEquals(listOf("tnx", "cq", "alt"), s.prefs.getLst_txtShortCutBtnList().snapshot())
assertEquals(1, s.shortcutRefreshes)
}
@Test
fun `a discarded list edit leaves the roster alone`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtSnipList().setAll(listOf("73"))
val state = s.state()
state.snippets.commitAt(0, "")
state.commitSnippets()
s.draft.discard()
assertEquals(listOf("73"), s.prefs.getLst_txtSnipList().snapshot())
assertEquals(0, s.snippetRefreshes)
}
@Test
fun `a new shortcut lands at the top and reaches the roster`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
@@ -0,0 +1,426 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.runtime.mutableStateOf
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.feed.ConnectionStateFeed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The Station tab, drawn (#107).
*
* [StationTabStateTest] pins the facade; this pins that the drawn controls show what the
* profile holds and that editing one writes through, which is the class of fault the
* settings window shipped repeatedly with a green build (`83e23f55`, Etappe 7).
*/
@OptIn(ExperimentalTestApi::class)
class StationTabScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
private class Session(isolated: ChatPreferences) {
val prefs = isolated.apply {
stn_loginCallSign = "DN9APW"
stn_on4kstServersPort = 23001
isStn_bandActive144 = true
isStn_bandActive10G = false
}
val mainApplied = mutableListOf<ChatCategory?>()
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { mainApplied += it }, { }, { link.value })
}
@Test
fun theTabShowsItsSectionsAndWhatTheProfileHolds() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() }
fieldBeside("Callsign").assertTextEquals("DN9APW")
fieldBeside("Server port").assertTextEquals("23001")
checkBeside("144 MHz").assertIsOn()
checkBeside("10 GHz").assertIsOff()
}
/* The credentials write through; every other field waits for Apply. */
@Test
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
val s = Session(preferences())
val storedHost = s.prefs.stn_on4kstServersDns
showTab { StationTab(s.state) }
type("Callsign", "DO5AMF")
type("Locator", "JO51IJ")
type("Server host name", "www.on4kst.info")
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
assertEquals(storedHost, s.prefs.stn_on4kstServersDns, "the host took effect before Apply")
fieldBeside("Server host name").assertTextEquals("www.on4kst.info")
runOnIdle { s.draft.apply() }
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
fieldBeside("Callsign").assertTextEquals("DO5AMF")
}
/** A number field writes only what parses; a half-typed entry leaves the stored value. */
@Test
fun theNumberFieldsWriteOnlyWhatParses() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
type("Server port", "23000")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort)
type("Server port", "23x")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
type("QTF", "135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
/*
* The draft is Compose state, so a staged number comes straight back to its field. The
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
* before the next key would make "135,5" impossible to type.
*/
@Test
fun aDecimalCanBeTypedKeyByKey() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val field = fieldBeside("QTF")
field.performScrollTo().performClick()
field.performTextReplacement("")
"135,5".forEach { key ->
field.performTextInput(key.toString())
waitForIdle()
}
field.assertTextEquals("135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
@Test
fun aBandCheckboxIsStagedAndApplied() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
toggle("10 GHz")
checkBeside("10 GHz").assertIsOn()
assertFalse(s.prefs.isStn_bandActive10G, "the box took effect before Apply")
toggle("144 MHz")
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertFalse(s.prefs.isStn_bandActive144)
}
/*
* In a contest the session is up. Everything but the credentials stays editable then,
* and an edit still waits for Apply.
*/
@Test
fun theDraftedFieldsCanBeEditedWhileConnected() = runComposeUiTest {
val s = Session(preferences())
s.link.value = On4KstConnectionState.ONLINE
showTab { StationTab(s.state) }
fieldBeside("Callsign").assertIsNotEnabled()
fieldBeside("Server port").assertIsEnabled()
toggle("10 GHz")
type("Server port", "23005")
assertFalse(s.prefs.isStn_bandActive10G)
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertEquals(23005, s.prefs.stn_on4kstServersPort)
}
@Test
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
val s = Session(preferences())
s.prefs.loginChatCategoryMain = ChatCategory(2)
showTab { StationTab(s.state) }
val target = s.state.availableCategories.first { it.categoryNumber == 3 }
val current = s.state.describeCategory(s.prefs.loginChatCategoryMain)
// The main category is the upper of the two pickers; the second may show the same name.
onAllNodesWithText(current).onFirst().performScrollTo().performClick()
waitForIdle()
awaitNode(inPopup(s.state.describeCategory(target))).performClick()
waitForIdle()
assertEquals(3, s.prefs.loginChatCategoryMain?.categoryNumber)
assertEquals(3, s.mainApplied.single()?.categoryNumber, "the running session was not told")
}
/*
* The credentials are locked while a session exists or is being made: the running
* connection would not pick up a change, so an edit would look applied and be ignored
* until the next login. The lock used to be a plain field nothing ever wrote.
*/
@Test
fun theLoginFieldsAreLockedWhileConnectedOrConnecting() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val credentials = listOf("Callsign", "Name, main category", "Name, second category", "Locator", "Password")
credentials.forEach { fieldBeside(it).assertIsEnabled() }
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
runOnIdle { s.link.value = On4KstConnectionState.ONLINE }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
fieldBeside("Server host name").assertIsEnabled()
runOnIdle { s.link.value = On4KstConnectionState.DISCONNECTED }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsEnabled() }
}
/*
* The application hands the tab `surroundings.connectionState`, which ConnectionStateFeed
* fills from the controller's callback. The application class itself cannot be built in
* a test -- it needs a controller with its database, the windows and the update service --
* so this drives the same chain from the feed on: a pushed state reaches the drawn tab
* without anything rebuilding it.
*/
@Test
fun theLoginFieldsFollowWhatTheConnectionFeedPushes() = runComposeUiTest {
val surroundings = MainWindowSurroundings()
val immediately = object : UiDispatcher {
override fun runOnUi(task: Runnable) = task.run()
override fun isUiThread(): Boolean = true
}
val feed = ConnectionStateFeed(surroundings, immediately)
val state = StationTabState(preferences(), SettingsDraft(), { }, { }, { surroundings.connectionState })
showTab { StationTab(state) }
fieldBeside("Callsign").assertIsEnabled()
runOnIdle { feed.push(On4KstConnectionState.AUTHENTICATING, null) }
waitForIdle()
fieldBeside("Callsign").assertIsNotEnabled()
// An absent state reads as DISCONNECTED in the feed, and unlocks the fields.
runOnIdle { feed.push(null, null) }
waitForIdle()
fieldBeside("Callsign").assertIsEnabled()
}
private val ownHeightLabel = "Own ground height ASL, metres"
private val antennaHeightLabel = "Own antenna height, metres"
/** Far down the tab: the row lookup compares on-screen bounds, so bring it into view. */
private fun androidx.compose.ui.test.ComposeUiTest.scrollToOwnHeight() {
onNodeWithText(ownHeightLabel).performScrollTo()
waitForIdle()
}
/** Types [keys] one by one into the cleared own-height field, as an operator would. */
private fun androidx.compose.ui.test.ComposeUiTest.typeKeysIntoOwnHeight(keys: String) {
val field = fieldBeside(ownHeightLabel)
field.performClick()
field.performTextClearance()
keys.forEach { field.performTextInput(it.toString()) }
waitForIdle()
}
/** Leaves the own-height field by clicking the antenna height field above it. */
private fun androidx.compose.ui.test.ComposeUiTest.leaveOwnHeight() {
fieldBeside(antennaHeightLabel).performClick()
waitForIdle()
}
/*
* Typed key by key, nothing is staged until the field is left, nothing reaches the
* preferences before Apply, and the typed text is never replaced by the value read back
* ("2" did turn into "2.0" while typing "250").
*/
@Test
fun theOwnHeightIsTypedKeyByKeyAndCommittedOnLeavingTheField() = runComposeUiTest {
val s = Session(preferences())
val refusals = mutableListOf<String>()
showTab { StationTab(s.state) { refusals += it } }
scrollToOwnHeight()
fieldBeside(ownHeightLabel).assertTextEquals("")
typeKeysIntoOwnHeight("250")
fieldBeside(ownHeightLabel).assertTextEquals("250")
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "staged before the field was left")
leaveOwnHeight()
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
typeKeysIntoOwnHeight("12,5")
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
leaveOwnHeight()
assertEquals(12.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "the height took effect before Apply")
runOnIdle { s.draft.apply() }
waitForIdle()
assertEquals(12.5, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
assertEquals(listOf<String>(), refusals)
}
/*
* After Enter the field keeps the operator's draft while the stored value reads back
* differently ("250" against "250.0"). A recomposition of the tab must not swap the
* draft for the stored text and drop what was typed since. The tab is forced to
* recompose down to the field: the refusal callback captures a counter, so a new
* counter value hands the path-analysis section a new lambda and it re-reads the
* stored text. A connection change, the tab's own state, is driven as well.
*/
@Test
fun aRecompositionAfterACommitKeepsTheKeysTypedSince() = runComposeUiTest {
val s = Session(preferences())
val tick = mutableStateOf(0)
val refusals = mutableListOf<String>()
showTab {
val seen = tick.value
StationTab(s.state) { refusals += "$it ($seen)" }
}
scrollToOwnHeight()
typeKeysIntoOwnHeight("25")
fieldBeside(ownHeightLabel).performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals(25.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).performTextInput("0")
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
runOnIdle { tick.value++ }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
fieldBeside(ownHeightLabel).performTextInput("1")
waitForIdle()
runOnIdle { tick.value++ }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("2501")
leaveOwnHeight()
assertEquals(2501.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertEquals(listOf<String>(), refusals)
// Applying changes the stored spelling to "2501.0"; the field must keep "2501".
runOnIdle { s.draft.apply() }
waitForIdle()
assertEquals(2501.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).assertTextEquals("2501")
}
@Test
fun anOutOfRangeOwnHeightIsRefusedAndFallsBackToTheStoredValue() = runComposeUiTest {
val s = Session(preferences())
val refusals = mutableListOf<String>()
showTab { StationTab(s.state) { refusals += it } }
scrollToOwnHeight()
typeAndCommit(ownHeightLabel, "250")
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
typeKeysIntoOwnHeight("9500")
leaveOwnHeight()
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "an out-of-range height was written")
assertEquals(1, refusals.size, "the refusal was not reported")
assertTrue(refusals.single().contains("9500"))
fieldBeside(ownHeightLabel).assertTextEquals("250")
}
@Test
fun clearingTheOwnHeightHandsItBackToTheElevationModel() = runComposeUiTest {
val s = Session(preferences())
s.prefs.stn_pathAnalysisOwnHeightAslMeters = 312.0
showTab { StationTab(s.state) }
scrollToOwnHeight()
fieldBeside(ownHeightLabel).assertTextEquals("312.0")
typeAndCommit(ownHeightLabel, "")
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "clearing did not hand back to the model")
assertEquals(312.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "cleared before Apply")
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN())
}
@Test
fun theHighestPointCheckboxIsOnAndTakesEffectOnApply() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val label = "Target station on the highest point of its locator square"
checkBeside(label).assertIsOn()
toggle(label)
checkBeside(label).assertIsOff()
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare, "the switch took effect before Apply")
runOnIdle { s.draft.apply() }
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare)
}
}
@@ -3,24 +3,122 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import kst4contest.view.compose.SettingsDraft
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.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class StationTabStateTest {
class StationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val secondRotatorCalls = mutableListOf<Unit>()
val draft = SettingsDraft()
val state = StationTabState(
prefs,
draft,
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
applySecondRotator = { secondRotatorCalls.add(Unit) },
)
}
@Test
fun `rotator 2 settings restart it once per apply however many changed`() {
val s = Session()
s.state.stn_pstRotator2Enabled = true
s.state.stn_pstRotator2Host = "10.0.0.7"
s.state.stn_pstRotator2Port = 12020
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(12020, s.prefs.getStn_pstRotator2Port())
assertEquals(1, s.secondRotatorCalls.size)
}
@Test
fun `rotator 2 on the port of rotator 1 is refused and stays in the draft`() {
val s = Session()
s.state.stn_pstRotatorPort = 12040
s.state.stn_pstRotator2Port = 12040
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size)
assertTrue(outcome.failedWrites.single().contains("12040"), outcome.failedWrites.toString())
assertEquals(12010, s.prefs.getStn_pstRotator2Port())
assertEquals(12040, s.state.stn_pstRotator2Port, "the refused value is still shown")
}
@Test
fun `rotator 1 onto the port of rotator 2 is refused while SO2R and rotator 2 are on`() {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, outcome.failedWrites.toString())
assertTrue(outcome.failedWrites.single().contains("12010"), outcome.failedWrites.toString())
assertNotEquals(12010, s.prefs.getStn_pstRotatorPort())
}
@Test
fun `rotator 1 onto the port of rotator 2 is accepted without SO2R`() {
val s = Session()
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(12010, s.prefs.getStn_pstRotatorPort())
}
@Test
fun `rotator 1 onto the port of rotator 2 is accepted while rotator 2 is off`() {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
}
@Test
fun `adjacent rotator ports are refused in both directions and distant ones accepted`() {
for (p2 in intArrayOf(12041, 12039)) {
val s = Session()
s.state.stn_pstRotatorPort = 12040
s.state.stn_pstRotator2Port = p2
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, "rotor 2 on $p2: ${outcome.failedWrites}")
}
for (p1 in intArrayOf(12011, 12009)) {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = p1
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, "rotor 1 on $p1: ${outcome.failedWrites}")
}
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12030
assertTrue(s.draft.apply().isClean)
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
@@ -32,18 +130,55 @@ class StationTabStateTest {
}
@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.
fun `a station setting waits in the draft until it is applied`() {
val s = Session()
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_on4kstServersPort = 23002
assertFalse(s.prefs.isStn_pstRotatorEnabled(), "an edit took effect before Apply")
assertNotEquals(23002, s.prefs.getStn_on4kstServersPort())
assertTrue(s.state.stn_pstRotatorEnabled, "the tab shows the edited value")
assertEquals(135.0, s.state.stn_qtfDefault, 0.001)
s.draft.apply()
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
}
@Test
fun `a discarded station setting leaves the preferences alone`() {
val s = Session()
s.prefs.setStn_bandActive144(true)
s.state.stn_bandActive144 = false
s.state.stn_bandActive432 = true
s.draft.discard()
assertTrue(s.prefs.isStn_bandActive144())
assertFalse(s.prefs.isStn_bandActive432())
assertTrue(s.state.stn_bandActive144, "after discarding the tab shows the stored value")
}
/*
* The credentials stay immediate: they are locked while a session exists, and without
* one Connect must use what the tab shows.
*/
@Test
fun `the login credentials still write through at once`() {
val s = Session()
s.state.stn_loginCallSign = "DO5AMF"
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_loginLocatorMainCat = "JO51IJ"
s.state.stn_loginPassword = "secret"
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals("JO51IJ", s.prefs.getStn_loginLocatorMainCat())
assertEquals("secret", s.prefs.getStn_loginPassword())
assertFalse(s.draft.hasPendingChanges, "a credential went into the draft")
}
@Test
@@ -52,6 +187,7 @@ class StationTabStateTest {
s.state.stn_bandActive144 = true
s.state.stn_bandActive432 = false
s.draft.apply()
assertTrue(s.prefs.isStn_bandActive144())
assertEquals(false, s.prefs.isStn_bandActive432())
@@ -81,18 +217,104 @@ class StationTabStateTest {
assertNull(s.prefs.getLoginChatCategorySecond(),
"the JavaFX window wrote null for 'no second chat'")
assertTrue(s.secondWasApplied, "the running session must learn about it too")
assertFalse(s.draft.hasPendingChanges, "a chat category went into the draft")
}
@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, { }, { })
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { }, { })
state.loginToSecondChatEnabled = true
draft.apply()
assertTrue(prefs.isLoginToSecondChatEnabled())
assertEquals(true, state.loginToSecondChatEnabled)
}
@Test
fun `the own height above sea level reads empty while the elevation model decides`() {
val s = Session()
assertEquals("", s.state.stn_pathAnalysisOwnHeightAslText)
assertNull(s.state.commitOwnHeightAsl("312,5"))
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "a commit took effect before Apply")
assertEquals(312.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText, "the field keeps the committed spelling")
s.draft.apply()
assertEquals(312.5, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText)
assertNull(s.state.commitOwnHeightAsl(" "))
s.draft.apply()
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "clearing hands it back to the model")
}
@Test
fun `a discarded own height shows the stored value again`() {
val s = Session()
s.prefs.setStn_pathAnalysisOwnHeightAslMeters(400.0)
assertNull(s.state.commitOwnHeightAsl("250"))
assertEquals("250", s.state.stn_pathAnalysisOwnHeightAslText)
s.draft.discard()
assertEquals("400.0", s.state.stn_pathAnalysisOwnHeightAslText, "the discarded spelling is still shown")
assertEquals(400.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
}
@Test
fun `an own height that does not parse or is out of range is not staged`() {
val s = Session()
assertNull(s.state.commitOwnHeightAsl("250"))
listOf("25x", "9500", "-600", "NaN").forEach { entry ->
val refusal = s.state.commitOwnHeightAsl(entry)
assertTrue(refusal != null && refusal.contains(entry), "no refusal naming \"$entry\": $refusal")
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "\"$entry\" was staged")
}
s.draft.apply()
assertEquals(250.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
}
@Test
fun `an own height draft is unchanged when it names the stored number`() {
assertTrue(StationTabState.isOwnHeightAslUnchanged("250", "250.0"))
assertTrue(StationTabState.isOwnHeightAslUnchanged("250,0", "250.0"))
assertTrue(StationTabState.isOwnHeightAslUnchanged(" ", ""))
assertFalse(StationTabState.isOwnHeightAslUnchanged("", "250.0"))
assertFalse(StationTabState.isOwnHeightAslUnchanged("250", ""))
assertFalse(StationTabState.isOwnHeightAslUnchanged("12.5", "12.0"))
assertFalse(StationTabState.isOwnHeightAslUnchanged("9500", "250.0"), "an unparsable draft must be committed and refused")
}
@Test
fun `parsing the own height`() {
assertTrue(StationTabState.parseOwnHeightAsl("")!!.isNaN())
assertEquals(-430.0, StationTabState.parseOwnHeightAsl("-430"))
assertEquals(8848.0, StationTabState.parseOwnHeightAsl(" 8848 "))
assertNull(StationTabState.parseOwnHeightAsl("9000.5"))
assertNull(StationTabState.parseOwnHeightAsl("-"))
assertNull(StationTabState.parseOwnHeightAsl("NaN"))
}
@Test
fun `the highest point switch waits in the draft until it is applied`() {
val s = Session()
assertTrue(s.state.stn_pathAnalysisDxHighestPointInSquare, "on by default")
s.state.stn_pathAnalysisDxHighestPointInSquare = false
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare(), "the switch took effect before Apply")
assertFalse(s.state.stn_pathAnalysisDxHighestPointInSquare)
s.draft.apply()
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare())
}
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The corrections made once before the settings window is built, outside any draft. */
class StoredSettingsRepairTest {
@TempDir
lateinit var directory: Path
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
@Test
fun `an unsupported fallback band prefix is written back as 144`() {
val prefs = preferences()
prefs.setNotify_optionalFrequencyPrefix("4711")
repairStoredSettings(prefs) { null }
assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
}
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("192.168.178.255", 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 prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("10.0.0.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { throw IllegalStateException("no interface") }
assertEquals("255.255.255.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
}
@@ -0,0 +1,151 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The three integration tabs, drawn: Log synch, TRX synch and Airscout (#107).
*
* All three draw from a facade with no snapshot state in it and count a revision on every
* write so the control redraws. That device is what these tests watch: a write that
* reached the preferences and not the screen is the class-1 fault of `83e23f55`.
*
* The "Choose..." button of the log file is never pressed here: it opens a modal AWT file
* dialog, which has no place in a headless test.
*/
@OptIn(ExperimentalTestApi::class)
class SynchTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Log synch
private class LogSynch(isolated: ChatPreferences, detected: String? = "192.168.1.255") {
val prefs = isolated.apply {
// Both listeners default to on; start from off so a tick is a visible change.
isLogsynch_ucxUDPWkdCallListenerEnabled = false
isLogsynch_wintestNetworkListenerEnabled = false
}
var restarts = 0
var stops = 0
val state = run {
// What the settings window does once before it builds the tabs.
repairStoredSettings(prefs) { detected }
LogSynchTabState(prefs, ImmediateDraft(), { restarts++ }, { stops++ })
}
}
@Test
fun logSynchShowsItsSectionsAndTheDetectedBroadcastAddress() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
onNodeWithText("File polling for worked callsigns").assertExists()
onNodeWithText("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)").assertExists()
onNodeWithText("Win-Test Network-Listener").assertExists()
onNodeWithText("Choose...").assertExists()
fieldBeside("UDP broadcast address for Win-Test (default = internet interface broadcast)")
.assertTextEquals("192.168.1.255")
}
@Test
fun logSynchStoresItsFieldsAndStartsTheWinTestListener() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
toggle("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net")
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled)
checkBeside("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net").assertIsOn()
type("Shared UDP port for QSO and TRX messages [default 12060]:", "12061")
assertEquals(12061, s.prefs.logsynch_ucxUDPWkdCallListenerPort)
val winTestPort = "UDP-Port for Win-Test listener (default is 9871)"
// The port field is greyed out until the listener is switched on; greyed out, it
// offers no text input, so it is found by the port it shows.
onNodeWithText(s.prefs.logsynch_wintestNetworkPort.toString()).assertIsNotEnabled()
toggle("Receive Win-Test network based UDP log messages")
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled)
assertEquals(1, s.restarts, "switching the listener on did not start it")
type(winTestPort, "9872")
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
// A refused entry is kept on screen as typed, and nothing is written.
type(winTestPort, "98x")
fieldBeside(winTestPort).assertTextEquals("98x")
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
type("KST station name in Win-Test network (src of SKED packets)", " KST2 ")
assertEquals("KST2", s.prefs.logsynch_wintestNetworkStationNameOfKST)
}
// ------------------------------------------------------------------ TRX synch
@Test
fun trxSynchStoresItsFieldsAndSwitchesTheQrgFollower() = runComposeUiTest {
val prefs = preferences()
val follower = mutableListOf<Boolean>()
val state = TrxSynchTabState(prefs, ImmediateDraft()) { follower += it }
showTab { TrxSynchTab(state) }
onNodeWithText("Receive UCXLog TRX info").assertExists()
onNodeWithText("What an enabled source does").assertExists()
toggle("Update MYQRG from RadioInfo messages received on the shared log-sync port")
assertTrue(prefs.isTrxSynch_ucxLogUDPListenerEnabled)
assertEquals(listOf(true), follower)
toggle("Use pass frequency from Win-Test STATUS (instead of own QRG)")
assertTrue(prefs.isLogsynch_wintestUsePassQrg)
type("Win-Test station name filter (e.g. STN1, empty = accept all)", "STN1 ")
assertEquals("STN1", prefs.logsynch_wintestNetworkStationNameOfWintestClient1)
}
// ------------------------------------------------------------------ Airscout
@Test
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
// The link defaults to on; start from off so a tick is a visible change.
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
val state = AirscoutTabState(prefs, ImmediateDraft())
showTab { AirscoutTab(state) }
onNodeWithText("AirScout UDP communication").assertExists()
onNodeWithText("Queried frequency").assertExists()
toggle("Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)")
assertTrue(prefs.isAirScout_asUDPListenerEnabled)
val port = "AirScout UDP port [9872] — reconnect after changing:"
type(port, "9873")
assertEquals(9873, prefs.airScout_asCommunicationPort)
type(port, "70000")
fieldBeside(port).assertTextEquals("70000")
assertEquals(9873, prefs.airScout_asCommunicationPort, "an out-of-range port was written")
type("AirScout server identifier [AS]:", "AS2")
assertEquals("AS2", prefs.airScout_asServerNameString)
}
}
@@ -1,17 +1,39 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TrxSynchTabStateTest {
class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
val draft = SettingsDraft()
val secondRotatorCalls = mutableListOf<Unit>()
val state by lazy {
TrxSynchTabState(prefs, draft, applySecondRotator = { secondRotatorCalls.add(Unit) }) { followerCalls.add(it) }
}
}
@Test
fun `the SO2R switch and the second Win-Test station wait for apply and restart rotator 2 once`() {
val s = Session()
s.state.trxSynch_so2rEnabled = true
s.state.logsynch_wintestNetworkStationNameOfWintestClient2 = " STN2 "
assertFalse(s.prefs.isTrxSynch_so2rEnabled())
s.draft.apply()
assertTrue(s.prefs.isTrxSynch_so2rEnabled())
assertEquals("STN2", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2())
assertEquals(1, s.secondRotatorCalls.size)
}
@Test
@@ -29,45 +51,66 @@ class TrxSynchTabStateTest {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
fun `an edit waits for apply`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = true
s.state.logsynch_wintestUsePassQrg = true
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled(), "an edit took effect before Apply")
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled, "the tab shows the edit")
assertTrue(s.followerCalls.isEmpty(), "the follower moved before Apply")
s.draft.apply()
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
}
@Test
fun `a discarded edit changes nothing`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.draft.discard()
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.followerCalls.isEmpty())
}
@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 "
s.draft.apply()
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
s.draft.apply()
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
}
@Test
fun `the own-QRG follower follows whether any source is enabled`() {
fun `the own-QRG follower follows whether any source is enabled, once per apply`() {
val s = Session()
// Both sources in one apply: one call, with the finished state.
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
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.logsynch_wintestQrgSyncEnabled = true
s.draft.apply()
s.state.trxSynch_ucxLogUDPListenerEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true, false), s.followerCalls)
}
@@ -0,0 +1,123 @@
package kst4contest.view.i18n
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.tabs.BeaconTabState
import kst4contest.view.compose.tabs.GuiOptionsTab
import kst4contest.view.compose.tabs.GuiOptionsTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
/**
* The texts that stayed English after stage 9 now follow the language as well.
*
* One sample from each of the three ways a text reaches the operator: a label read inside a
* composition, a column title built outside one, and a refusal produced by a tab state. All
* three have to switch, and each goes through a different door.
*/
@OptIn(ExperimentalTestApi::class)
class RemainingTextsFollowTheLanguageTest {
/* Never the no-argument ChatPreferences: it would touch the operator's real profile. */
@TempDir
lateinit var directory: Path
@Test
fun theMovedTextsAppearInGermanAfterTheSwitch() = runComposeUiTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, SettingsDraft(), store)
val beacon = BeaconTabState(
prefs,
SettingsDraft(),
validateBeaconTemplate = {},
restartBeaconTimer = {},
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
val score = StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
).single { it.id == "score" }
setContent {
CompositionLocalProvider(LocalStrings provides store.strings) {
GuiOptionsTab(state)
}
}
waitForIdle()
onNodeWithText("Start in the evening design").assertExists()
assertEquals("Score", score.title())
try {
runOnIdle { store.use("de") }
waitForIdle()
onNodeWithText("Im Abenddesign starten").assertExists()
onNodeWithText("Kein Filter").assertExists()
onNodeWithText("Ein gearbeitetes Großfeld markieren").assertExists()
assertEquals("Wertung", score.title(), "the column title stayed in the old language")
assertEquals(
"Einen ganzzahligen Bakenabstand von mindestens 1 Minute eingeben.",
beacon.commitBeaconInterval("0"),
"the refusal from the tab state stayed in the old language",
)
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { store.use("en") }
}
}
/**
* The settings window's notice: its title is read in the composition, its message is
* produced outside one by the save action -- the same split as above, one window further.
*/
@Test
fun theSettingsNoticeFollowsTheLanguage() = runComposeUiTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val notices = SettingsNotices()
setContent {
CompositionLocalProvider(LocalStrings provides store.strings) {
SettingsContent(
tabs = listOf(SettingsTab("Tab") {}),
notices = notices,
buttons = {},
close = {},
)
}
}
try {
runOnIdle {
store.use("de")
notices.problem(CurrentStrings.get().settingsWriteFailed)
}
waitForIdle()
onNodeWithText("Einstellungen").assertExists()
onNodeWithText(
"Die Konfigurationsdatei konnte nicht geschrieben werden. " +
"Die Einstellungen bleiben für diese Sitzung aktiv.",
).assertExists()
} finally {
runOnIdle { store.use("en") }
}
}
}
@@ -5,6 +5,8 @@ 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
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* Nothing that goes to the server or to another radio amateur has got into the texts.
@@ -17,7 +19,7 @@ import java.lang.reflect.Modifier
* 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 {
class TranslationBoundaryTest(@TempDir private val tempDir: Path) {
private val everyText: List<String> =
Translations.BY_LANGUAGE.values.flatMap { it.values }
@@ -63,7 +65,7 @@ class TranslationBoundaryTest {
@Test
fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val defaults = ChatPreferences::class.java.declaredFields
.filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) }
@@ -62,8 +62,10 @@ setStn_on4kstServersPort
setStn_pathAnalysisDefaultTargetAntennaGainDbi
setStn_pathAnalysisDefaultTargetTxPowerWatts
setStn_pathAnalysisDemRootDirectory
setStn_pathAnalysisDxHighestPointInSquare
setStn_pathAnalysisOwnAntennaGainDbi
setStn_pathAnalysisOwnAntennaHeightMeters
setStn_pathAnalysisOwnHeightAslMeters
setStn_pathAnalysisOwnTxPowerWatts
setStn_pstRotatorEnabled
setStn_pstRotatorHost
+72
View File
@@ -68,8 +68,80 @@ subprojects {
}
}
/*
* Tests never see the operator's real home directory. ApplicationFileUtils resolves the
* application folder below user.home, and the no-argument ChatPreferences constructor
* seeds preferences.xml there when it is missing; a test that slipped through would
* create or rewrite files in ~/.praktiKST of whoever runs the build. Each test task's JVM
* gets its own scratch home, build/test-user-home/<task name>, emptied before the task
* runs, so one test task can never wipe another's. Tests that need their own profile
* directory still set user.home themselves and restore it afterwards.
*
* The redirection alone would hide the mistake, so every test task is finalized by
* checkTestUserHome, which fails the build if a preferences.xml was left in any scratch
* home: tests are expected to use ChatPreferences(Path) with a @TempDir. It is a
* finalizer and not a doLast because a doLast is skipped when a test fails, and a
* failing test must not hide the stray file. The check stays deliberately narrow: other
* files land in the scratch .praktiKST by design, for example the sound files
* PlayAudioUtils seeds from the bundled resources, and absorbing those is what the
* redirection is for. Only preferences.xml marks a test that should have used its own file.
*
* The opt-in ON4KST practice tests, which deliberately read the real profile, need
* -Don4kst.live=true, which the test task does not forward; they skip here either way
* and are meant to be run from an IDE.
*/
val testUserHomeRoot = layout.buildDirectory.dir("test-user-home").get().asFile
val checkTestUserHome = tasks.register("checkTestUserHome") {
description = "Fails if a test wrote preferences.xml into its scratch user.home"
group = "verification"
doLast {
val strayPreferences = testUserHomeRoot.listFiles().orEmpty()
.map { it.resolve(".praktiKST/preferences.xml") }
.filter { it.exists() }
if (strayPreferences.isNotEmpty()) {
throw GradleException(
"A test wrote ${strayPreferences.joinToString()}: it used the user " +
"profile instead of ChatPreferences(Path) with a @TempDir"
)
}
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
val testUserHome = testUserHomeRoot.resolve(name)
/*
* The scratch home is a declared output. Test tasks are cacheable, and a cache hit
* skips doFirst; as an output the directory is restored together with the cached
* result, so checkTestUserHome always judges the files that belong to these inputs
* and never a leftover from another run.
*
* Skiko unpacks its native library (about 30 MB) below user.home unless told
* otherwise. It is kept beside the scratch home instead, so it neither bloats every
* cache entry nor is unpacked again on each run.
*
* Both paths are passed through an argument provider rather than systemProperty():
* a system property is a task input, and an absolute path there would make the
* cache key differ between checkouts.
*/
val skikoDataPath = layout.buildDirectory.dir("skiko-data").get().asFile
outputs.dir(testUserHome)
jvmArgumentProviders.add(CommandLineArgumentProvider {
listOf(
"-Duser.home=${testUserHome.absolutePath}",
"-Dskiko.data.path=${skikoDataPath.absolutePath}"
)
})
doFirst {
// deleteRecursively() reports failure instead of throwing. A file left over
// from an earlier run would later be blamed on this one, so stop here.
if (!testUserHome.deleteRecursively()) {
throw GradleException("Could not empty the test user.home $testUserHome")
}
testUserHome.mkdirs()
}
finalizedBy(checkTestUserHome)
}
// XML for CI to read, HTML for people. Gradle does not wire the report after the test
@@ -35,7 +35,6 @@ import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture;
import kst4contest.observe.SimpleValue;
@@ -48,7 +47,7 @@ import kst4contest.observe.SimpleValue;
* <b>SINGLETON</b>
*
*/
public class ChatController implements ThreadStatusCallback, PstRotatorEventListener {
public class ChatController implements ThreadStatusCallback {
/**
* Chat selection ? 50/70 MHz..............1 144/432 MHz............2
@@ -69,7 +68,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private volatile PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
/*
@@ -86,14 +84,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return thread;
});
private ScheduledFuture<?> pendingRotatorRetry;
/*
* Updated directly by the PSTRotator receiver thread. This avoids reading
* a JavaFX property from the command scheduler.
*/
private volatile double lastReportedRotatorAzimuth = Double.NaN;
private StatusUpdateListener statusListener; //update info interface for the threads
@@ -311,151 +301,108 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void initRotor() {
rotatorClient = new PstRotatorClient(
chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(),
this,
this
);
rotator1.start();
}
rotatorClient.start();
/** Kept for existing references; the value lives in RotatorControl. */
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = RotatorControl.REACHED_TOLERANCE_DEGREES;
/** Rotator of radio 1; also the only one without SO2R. */
private final RotatorControl rotator1 = new RotatorControl(
"PSTRotator",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(), "PSTRotator"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF().set(azimuth)));
/** Rotator of radio 2; only with SO2R and its own switch. */
private final RotatorControl rotator2 = new RotatorControl(
"PSTRotator2",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotator2Host(),
chatPreferences.getStn_pstRotator2Port(), "PSTRotator2"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF2().set(azimuth)));
/** Test seam: the rotator of radio 2. */
RotatorControl secondRotator() {
return rotator2;
}
static boolean secondRotatorWanted(ChatPreferences prefs) {
return prefs.isTrxSynch_so2rEnabled() && prefs.isStn_pstRotator2Enabled();
}
/** Turns the antenna of [slot]; radio 2 only does something while its rotator runs. */
public void rotateTo(RadioSlot slot, double azimuth) {
(slot == RadioSlot.SECOND ? rotator2 : rotator1).rotateTo(azimuth);
}
/*
* Not isDisconnected(): that flag defaults to false before the first connect,
* so it would claim a session where none exists.
*/
private boolean isSessionRunning() {
return on4KstConnectionManager != null && sessionRunning(on4KstConnectionManager.getState());
}
static boolean sessionRunning(On4KstConnectionState state) {
return state != null
&& state != On4KstConnectionState.DISCONNECTED
&& state != On4KstConnectionState.STOPPING;
}
/**
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
*
* <p>Some SPID configurations occasionally ignore the first azimuth
* command. KST4Contest therefore checks the latest reported position after
* two seconds. If no movement was reported and the target has not already
* been reached, the original compatibility sequence is sent again.</p>
* Follows the settings for rotator 2 after Apply: always stopped first, so a changed
* host or port is bound anew, and started again only while a session runs. Without it,
* QTF2 shows nothing rather than a stale heading.
*/
public void reconfigureSecondRotator() {
rotator2.stop();
if (secondRotatorWanted(chatPreferences)) {
if (isSessionRunning()) {
rotator2.start();
}
} else {
runOnUi(() -> chatPreferences.getActualQTF2().set(Double.NaN));
}
}
/**
* Opens one PSTRotator connection whose position reports go to [control]. The
* returned link is what RotatorControl drives; stopping it also stops the polling.
*/
private RotatorControl.Link startPstRotatorClient(RotatorControl control, String host, int port, String threadName) {
PstRotatorClient client = new PstRotatorClient(host, port, threadName, new PstRotatorEventListener() {
@Override public void onAzimuthUpdate(double azimuth) { control.onAzimuthUpdate(azimuth); }
@Override public void onElevationUpdate(double elevation) { /* not used */ }
@Override public void onModeUpdate(boolean isTracking) { /* not used */ }
@Override public void onMessageReceived(String raw) { /* not used */ }
}, this);
client.start();
return new RotatorControl.Link() {
@Override public void setTrackingMode(boolean on) { client.setTrackingMode(on); }
@Override public void setAzimuth(double azimuth) { client.setAzimuth(azimuth); }
@Override public void stopRotation() { client.stopRotor(); }
@Override public void stop() { client.stop(); }
};
}
/* Runs on the UI thread, directly when already there. */
private void runOnUi(Runnable update) {
if (uiDispatcher.isUiThread()) {
update.run();
} else {
uiDispatcher.runOnUi(update);
}
}
/**
* Sends a new azimuth to PSTRotator without blocking the UI thread; see RotatorControl.
*
* @param azimuth required antenna azimuth in degrees
*/
public void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid PSTRotator azimuth: {0}",
azimuth
);
return;
}
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
LOGGER.log(
Level.WARNING,
"Cannot rotate antenna to {0} degrees: "
+ "PSTRotator integration is not active.",
azimuth
);
return;
}
double targetAzimuth = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedRotatorAzimuth;
activeClient.setTrackingMode(false);
LOGGER.log(
Level.INFO,
"Sending PSTRotator azimuth requested by the operator: {0}",
targetAzimuth
);
activeClient.setAzimuth(targetAzimuth);
ScheduledFuture<?> previousRetry = pendingRotatorRetry;
if (previousRetry != null) {
previousRetry.cancel(false);
}
pendingRotatorRetry = rotatorCommandScheduler.schedule(
() -> retryRotatorCommandIfRequired(
positionBeforeCommand,
targetAzimuth
),
2,
TimeUnit.SECONDS
);
}
/**
* Performs the delayed SPID compatibility check.
*
* <p>No retry is required when the requested position has already been
* reached or when PSTRotator reported movement after the original command.
* Missing feedback is treated like an unchanged position.</p>
*/
private void retryRotatorCommandIfRequired(
double positionBeforeCommand,
double targetAzimuth
) {
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
return;
}
double currentAzimuth = lastReportedRotatorAzimuth;
if (Double.isFinite(currentAzimuth)
&& angularDistance(currentAzimuth, targetAzimuth) < 0.5) {
LOGGER.log(
Level.FINE,
"PSTRotator reached the requested azimuth without retry: {0}",
targetAzimuth
);
return;
}
boolean noPositionFeedback =
!Double.isFinite(currentAzimuth);
boolean positionUnchanged =
Double.isFinite(positionBeforeCommand)
&& Double.isFinite(currentAzimuth)
&& angularDistance(
positionBeforeCommand,
currentAzimuth
) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(
Level.FINE,
"PSTRotator reported movement towards {0}; no retry required.",
targetAzimuth
);
return;
}
LOGGER.log(
Level.WARNING,
"PSTRotator reported no movement after the command for {0} degrees; "
+ "sending the SPID compatibility retry.",
targetAzimuth
);
activeClient.setAzimuth(0.0);
activeClient.setAzimuth(targetAzimuth);
}
/**
* Returns the smallest angular distance between two azimuth values.
*/
private static double angularDistance(double first, double second) {
double difference = Math.abs(
normalizeAzimuth(first) - normalizeAzimuth(second)
);
return Math.min(difference, 360.0 - difference);
}
/**
* Normalises an azimuth to the range from 0 inclusive to 360 exclusive.
*/
private static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
rotator1.rotateTo(azimuth);
}
/**
@@ -561,69 +508,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void stopRotator() {
ScheduledFuture<?> pendingRetry = pendingRotatorRetry;
if (pendingRetry != null) {
pendingRetry.cancel(false);
pendingRotatorRetry = null;
}
PstRotatorClient activeClient = rotatorClient;
rotatorClient = null;
lastReportedRotatorAzimuth = Double.NaN;
if (activeClient != null) {
activeClient.stop();
}
rotator1.stop();
}
@Override
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid azimuth reported by PSTRotator: {0}",
azimuth
);
return;
}
double normalizedAzimuth = normalizeAzimuth(azimuth);
lastReportedRotatorAzimuth = normalizedAzimuth;
/*
* The callback runs in the PSTRotator receiver thread. JavaFX properties
* must be updated on the JavaFX Application Thread.
*/
Runnable fxUpdate = () ->
chatPreferences.getActualQTF().set(normalizedAzimuth);
if (uiDispatcher.isUiThread()) {
fxUpdate.run();
} else {
uiDispatcher.runOnUi(fxUpdate);
}
}
@Override
public void onElevationUpdate(double elevation) {
// System.out.println("Neue Elevation: " + elevation);
//not used in first version
}
@Override
public void onModeUpdate(boolean isTracking) {
// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell"));
//not used in first version
}
@Override
public void onMessageReceived(String raw) {
// Logging
}
/**
* Helping
* @param targetCallSignRaw
@@ -914,8 +801,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) {
// rotatorClient.
// this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
@@ -964,8 +849,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
dbHandler.closeDBConnection();
rotatorClient.stopRotor();
rotatorClient.stop();
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
try {
@@ -1072,11 +959,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
releaseBackgroundExecutors();
}
@@ -1098,11 +984,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
@@ -3605,6 +3486,9 @@ private SimpleRoster<String>
} else {
System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client.");
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
/**
* DX cluster service running config
@@ -3784,9 +3668,12 @@ private SimpleRoster<String>
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
if (chatPreferences.isStn_pstRotatorEnabled() && !rotator1.isRunning()) {
initRotor();
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
@@ -0,0 +1,30 @@
package kst4contest.controller;
/**
* Side effects a chat frame asks for, decided without touching sound, network or model.
*
* @param playPmSound whether the private message sound ('P') is played
* @param playCwCallsign whether the sender's call is sent in CW
* @param playVoiceCallsign whether the sender's call is spoken
* @param playBell whether the voice bell ("!") is played
* @param sendVersionReply whether the version reply is sent
* @param autoAnswerText the auto answer to send, {@code null} for none; the per-remote cooldown is still checked by the executor
* @param inAngleAndRange the new in-angle flag of the sender, {@code null} to leave it unchanged
* @param playDirectionalTick whether the short directional tick is played
* @param evaluateDxClusterSpot whether a DX Cluster spot is to be evaluated
*/
record ChatMessageEffects(
boolean playPmSound,
boolean playCwCallsign,
boolean playVoiceCallsign,
boolean playBell,
boolean sendVersionReply,
String autoAnswerText,
Boolean inAngleAndRange,
boolean playDirectionalTick,
boolean evaluateDxClusterSpot) {
/** No effect at all. */
static final ChatMessageEffects NONE =
new ChatMessageEffects(false, false, false, false, false, null, null, false, false);
}
@@ -0,0 +1,13 @@
package kst4contest.controller;
/** Where an ON4KST chat message belongs, seen from the local station. */
enum ChatRoute {
/** Receiver field {@code 0}: a message to everyone. */
PUBLIC,
/** Addressed to the local login call. */
TO_ME,
/** Sent by the local station to someone else. */
FROM_ME,
/** Between two other stations. */
BETWEEN_OTHERS
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more