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
Rsclub2_2 6b0c80f71e Add Supporters/Sponsor section to README
Added a Supporters/Sponsor section to acknowledge community contributions.
2026-10-15 09:04:35 +02:00
dependabot[bot] fb38c858bf build(deps): bump actions/setup-node from 4 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:16:50 +02:00
dependabot[bot] 0ee29dc585 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:16:25 +02:00
dependabot[bot] 71b2d3931c build(deps): bump actions/download-artifact
Bumps the minor-and-patch group with 1 update: [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v8.0.1...v8.0.2)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 08:15:17 +02:00
Rsclub2_2andClaude Opus 5.5 dd9f87a767 Build AUR source packages against their own Gradle home, not offline
Both source PKGBUILDs ran "./gradlew --offline" against the builder's
personal ~/.gradle. That only worked as long as the local cache happened to
hold every dependency; the SpotBugs plugin 6.5.12 added in #114 broke
"yay -S kst4contest-git" with "could not resolve plugin artifact".

- drop --offline and point GRADLE_USER_HOME into $srcdir, so the package
  builds the same on any machine
- run with --no-daemon so no Gradle process outlives makepkg
- drop gradle from makedepends: the wrapper fetches its own distribution

Verified with makepkg -f on an empty Gradle home for kst4contest-git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:25:55 +02:00
Rsclub2_2andClaude Fable 5.1 7b44eb0c2e Reference cases for the path-analysis mathematics (#108) (#117)
* test: pin the Maidenhead and great-circle maths of Location against definitions (#108)

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

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

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

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

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

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

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

Fixes #108

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:39:48 +02:00
Rsclub2_2andClaude Fable 5.1 91863fee1f Remove Thread.sleep timing coupling from the tests (#109) (#118)
* test: add bounded awaitUntil helper for timing-sensitive tests (#109)

One small class per module (core and app-desktop share no test sources):
polls a condition every 10 ms and fails with a message when the timeout
elapses, so tests no longer guess with fixed Thread.sleep pauses.

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

* refactor(map): let TileFetcher take its nano clock as a test seam (#109)

A package-private constructor accepts a LongSupplier for the monotonic
clock the retry pause is measured against; the public constructors
delegate with System::nanoTime, so production behaviour is unchanged.
TileFetcherTest advances a fake clock instead of sleeping 20 ms three
times and hoping the 1 ms pause has elapsed.

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

* refactor(view): put CoalescingTrigger on a DelayTimer seam (#109)

DelayTimer is a package-private one-shot timer (restart/stop) with a
Factory; DelayTimer.SWING wraps javax.swing.Timer and is what the public
constructor uses, so production behaviour is unchanged. A package-private
constructor takes the factory. CoalescingTriggerTest fires a FakeDelayTimer
itself: the negative checks (no second run, cancel prevents the run) are
now deterministic and the four Thread.sleep calls are gone. One test keeps
the real Swing timer end to end, waiting with awaitUntil.

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

* refactor(view): put LayoutAutosave on the DelayTimer seam (#109)

The public constructor still builds a javax.swing.Timer via DelayTimer.SWING
with the same 750 ms delay; a package-private constructor takes the factory.
LayoutAutosaveTest fires a FakeDelayTimer on the event thread itself, so
"cancelled means not written" no longer rests on a 1.2 s sleep and the
burst test no longer sleeps 300 ms to prove the absence of a second write.
The foreign-thread test keeps the real Swing timer path with awaitUntil.

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

* refactor(controller): inject scheduler and clock into On4KstConnectionManager (#109)

A package-private constructor takes the ScheduledExecutorService and a
LongSupplier for wall-clock milliseconds; the existing constructor delegates
with the same single-thread supervisor and System::currentTimeMillis, so
production behaviour is unchanged. Session's idle clocks start from the
injected clock instead of a field initializer. The static
parseMessageTimestamp keeps the wall clock: it sanity-checks server
timestamps and is not part of the supervisor's timing.

On4KstConnectionProbeTest drives unansweredProbeUsesExistingReconnectFlow
through a ManualScheduler (immediate work on a real thread, delays and the
five-second tick released on demand) and a fake clock: 90 s of silence
sends the CK probe, which the test reads off the socket, 210 s fails the
session, and the released backoff opens the second connection. The
reflection-based state setup is gone because the state is now reached the
production way; the test takes well under a second instead of seven. The
two ignored await(2 s) results in the server stubs are asserted now.

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

* test: close the profile picker as soon as it shows instead of after 1.5 s (#109)

The two display-bound picker tests disposed the modal dialog from a
Swing timer after a fixed 1.5 s, which was long enough on a fast machine
only and cost 3 s per run. The closer now waits with awaitUntil for the
dialog to appear and disposes it on the event thread; the suite drops
from 3.0 s to 0.26 s.

Remaining fixed sleep: TopPriorityCandidatesWindowTest waits 400 ms for
Compose's double-tap timeout; whether ImageComposeScene's time can
advance it deterministically is still to be checked.

Fixes #109

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:39:21 +02:00
Rsclub2_2andClaude Fable 5.1 f2b0fdd5b4 Write SpotBugs reports and publish static analysis on pull requests (#110) (#114)
* build: write SpotBugs XML and HTML reports (#110)

The SpotBugs tasks had no report configured, so findings went to stdout
only and, with ignoreFailures, scrolled past unread. Every SpotBugsTask
now writes XML (for CI to count) and HTML (for people) to the plugin's
default location, <module>/build/reports/spotbugs/main.{xml,html}.
ignoreFailures stays; the build still passes with findings present.

Fixes #110

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

* ci: run PMD and SpotBugs on pull requests and publish the reports (#110)

The compile job runs pmdMain and spotbugsMain after compiling (report
only: both tools have ignoreFailures, a crash of a tool still fails),
counts <violation> and <BugInstance> elements per module from the report
XML into the job summary, and uploads */build/reports/pmd and
*/build/reports/spotbugs as the static-analysis artifact for 14 days.
A missing report prints "no report" without failing the step.

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

* docs: name the PMD and SpotBugs report files and the CI artifact (#110)

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:49:08 +02:00
Rsclub2_2andClaude Fable 5.1 89d37e62e1 Give beta DEB and RPM packages a pre-release number (#113) (#116)
* build: give beta DEB and RPM packages a pre-release number (#113)

composePackageVersion is the package version of every Compose target
format, so an RPM or DEB built from a beta tag carried exactly the
version of the following stable one. dnf, rpm -U and dpkg refuse to
replace a package by one of equal version, so a beta user could not
move to the stable.

Keep the version and tell the packages apart by the package release
number, following the Fedora pre-release convention: the new Gradle
property linuxAppRelease is passed to jpackage as --linux-app-release
(Compose LinuxPlatformSettings.appRelease) and defaults to 1.

  beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
  beta-v1.50.0-rc02     -> 1.50.0-0.rc02
  v1.50.0               -> 1.50.0-1

Everything starting with "0." sorts below "1" for both rpm and dpkg.
Nightlies and local builds do not set the property and keep release 1.

Fixes #113

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

* ci: derive the Linux release number from the tag and verify the tag version (#113)

The DEB and RPM jobs now compute the package release from the tag
before calling Gradle and pass it as -PlinuxAppRelease: a stable
vX.Y.Z tag gives 1, a beta-vX.Y.Z-<suffix> tag gives 0.<suffix> with
"-" turned into ".". The result must match [A-Za-z0-9.]+, otherwise
the step fails. A workflow_dispatch run from a branch keeps release 1
like the nightlies; nightly-artifacts.yml is unchanged.

The test job, which every packaging job depends on, now checks right
after the checkout that the X.Y.Z in the tag equals
composePackageVersion in gradle.properties and fails with a clear
message otherwise, so a mistagged commit fails before an hour of
packaging rather than shipping packages whose version contradicts
their file name and upgrade order.

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

* docs: describe the pre-release number of beta DEB and RPM packages (#113)

The German and English installation manuals say in the Debian/Ubuntu
and Fedora sections that beta packages carry a pre-release number
below the stable one, so the stable installs as an upgrade over a
beta. The v1.50 changelogs gain the matching "Fixed" entry, and
PROJECT_CONTEXT.md records the rule next to the MSI decision.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:48:49 +02:00
Rsclub2_2andClaude Fable 5.1 2eb1ba22cf Windows MSI installer next to the portable ZIP (#61) (#111)
* build: configure the Windows MSI installer in the Compose windows block (#61)

Add the jpackage/WiX options for a per-user MSI next to the existing
icon: a fixed upgradeUuid so Windows Installer replaces an installed
KST4Contest instead of installing side by side, a Start menu group and
desktop shortcut, no UAC prompt, and a directory chooser. The portable
ZIP (createDistributable) is unaffected; the MSI is produced only by
the tagged-release workflow.

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

* ci: build and attach a Windows MSI installer for tagged releases (#61)

The Windows job of tagged-release.yml now runs packageMsi after the
portable ZIP, renames the result to KST4Contest-<tag>-windows-x64.msi
(consistent with the other platforms; the ZIP keeps its praktiKST name)
and uploads it as the windows-msi artifact, which the release job
downloads and attaches next to the ZIP. The nightly workflow stays
ZIP-only because a nightly MSI would always carry the same version.

The website download data (downloads.js, beta.js) gets an MSI card that
is shown only when the release actually has that asset, so the page
never links to a file that does not exist for an older release.

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

* docs: describe the Windows MSI installer next to the portable ZIP (#61)

The German and English installation manuals list the MSI in the package
table, describe both Windows forms (installer: per user, Start menu
entry, desktop shortcut, chosen directory, in-place upgrade, removal via
the Windows apps list; ZIP: portable) and the shared data directory
%USERPROFILE%\.praktiKST, and add the MSI path to the Windows update
section. Changelog entries for v1.50 and a note in PROJECT_CONTEXT.md
record the fixed upgrade UUID and the tags-only decision.

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

* ci: build the Windows MSI for stable tags only (#61)

A beta MSI would carry the same composePackageVersion as the following
stable release. jpackage's MSI upgrade table only accepts strictly higher
versions, so Windows Installer would refuse the stable over an installed
beta and the tester would have to uninstall first. Betas therefore stay
ZIP-only like nightlies; the build, upload and download steps are skipped
for beta- tags, and the release action tolerates the missing asset path.
Manual, changelog and project context say so.

Raised by the Codex review on #111.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:27:43 +02:00
Rsclub2_2andClaude Fable 5.1 5491cbcf8b Test suite cleanup, JaCoCo coverage report and tests before packaging (#62) (#112)
* docs: add the test-coverage design spec (#62)

Records the state found for issue #62 (JUnit classes under src/main that
never run, test libraries in the shipped app image, dead 2023/2024 scratch
tests, no coverage figure), the decisions taken (delete, move to src/test,
JaCoCo report-only without a threshold, tests before packaging), what is
deliberately left to separate issues, and the definition of done.

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

* test: move the JUnit classes out of src/main and drop the dead 2023 tests (#62)

Gradle's test task scans src/test only, so the six JUnit classes under
src/main compiled on every build and were never executed. They move to
src/test of the module that owns the class under test, keeping their
package names: On4KstProtocolTest, On4KstSocketThreadTest and
ChatMemberDirectionOpportunityTest within core; Utils4KSTFrequencyTest,
FrequencyTextParserTest and ChatPreferencesStationMapVisibilityTest from
app-desktop into core, next to the sibling tests that already live there,
because they test core classes only. All 26 of them run and pass.

The fully commented-out MessageBusManagementThreadFrequencyContextTest is
deleted. Eight files under core/src/test/java/kst4contest/test from
2023/2024 go as well: they carry no assertion, five of them annotate a
static main() with @Test, which Jupiter never runs, and some write the real
preferences file, send real UDP broadcasts or open the real user database
when started by hand. The five main() scratch programs under src/main
(ReadUDPByWintestThreadTest, PatternMatcherTest, mapTest, RegexTester,
locatorUtils/TestLocatorUtils) and MockKstServer, which only an unused
import in ChatController still referenced, are removed too.

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

* build: make JUnit and Mockito test-only dependencies (#62)

With no test class left under src/main, the implementation declarations of
junit-jupiter-api and mockito-core in core and app-desktop lose their only
justification. They put junit-jupiter-api, junit-platform-commons,
opentest4j, mockito-core, byte-buddy, byte-buddy-agent and objenesis on the
runtime classpath and into lib/app of every app image, Flatpak and
installer. The duplicate catalog entries junit-jupiter-api,
mockito-core-compile and the mockitoCompile version go with them.

Verified: `:app-desktop:dependencies --configuration runtimeClasspath` no
longer lists any of the seven artifacts.

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

* docs: state that test failures fail the build and tests live under src/test (#62)

AGENTS.md and docs/PROJECT_CONTEXT.md still said the build configuration
had allowed test failures to pass. That was true of the Maven build; under
Gradle a test failure fails the build, and only PMD and SpotBugs run with
ignoreFailures. Both documents now say so, note that test classes live under
src/test only and that JUnit and Mockito are test-only dependencies, and
keep the rule to read the test-result XML rather than the exit code.

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

* build: add a JaCoCo coverage report (#62)

The jacoco plugin is applied to every module from the root build with the
tool version held in the catalog. jacocoTestReport now depends on test and
writes XML and HTML per module; a root task jacocoAggregatedReport merges
core and app-desktop into build/reports/jacoco/aggregated/ (XML plus HTML).
i18n-generator is excluded: it is a build tool and its coverage says nothing
about the shipped application. Kotlin classes are counted, because the
Kotlin plugin adds its output to the main source set the report reads.

Report only, on purpose. There is no jacocoTestCoverageVerification and no
threshold: a minimum set at today's number would trip on the next unrelated
change, and one set at a target would fail every build until tests exist for
the number's sake. The figure is published so it can be read when deciding
what to test next; the reasoning is in
docs/superpowers/specs/2026-10-11-test-coverage-design.md.

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

* ci: run the tests before packaging and publish coverage (#62)

The nightly and tagged-release workflows built seven package formats from
commits nobody had tested on the way: pr-compile-check.yml runs the suite
for pull requests only, and main can be pushed to directly. Both workflows
now carry a copy of that test job, including the libgl1 step and the
XML-based summary, and every packaging job declares `needs: test`. Copied
rather than factored into a reusable workflow on purpose: the comments in
the job explain decisions the packaging workflows should carry with them,
and three copies read better than one indirection. build-docs-pdf runs
pandoc only and is left alone; publish-flatpak-repo and release-tag are
gated through the jobs they already need.

The PR test job additionally runs jacocoTestReport and
jacocoAggregatedReport once the tests have run (not `if: always()`: a
report from an incomplete run would mislead), prints a line/branch coverage
table per module and aggregated into the job summary from the JaCoCo XML,
and uploads the aggregated HTML report as the coverage-report artifact for
14 days whenever it exists.

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

* docs: describe the coverage report in the project context (#62)

A short paragraph on JaCoCo in docs/PROJECT_CONTEXT.md: report only and
why, where the per-module and aggregated reports land, which Gradle tasks
write them, that i18n-generator is excluded, how the PR job summary and the
coverage-report artifact show the figure, and that the nightly and
tagged-release workflows run the tests before any packaging job.

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

* test: round-trip every ChatPreferences field through the XML file (#62)

Drive all public single-argument setters by reflection to non-default
values, write preferences.xml, read it back and compare every getter.
Fields that are not persisted on purpose are listed with their evidence;
two real reader/writer gaps (second-category locator, sniffer words) are
documented with a BUG marker instead of being fixed here. Two further
tests load the bundled template and a legacy file without configVersion.

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

* test: cover the DBController schema migration from older user_version values (#62)

Open the seeded template database and hand-built release 1.1 and 1.3
schema fixtures with a DBController and verify that PRAGMA user_version
reaches the current level, every column of the current CREATE TABLE
statements exists, legacy callsign keys are merged to base callsigns and
existing rows and flags survive. A further test proves that reopening an
up-to-date database changes neither schema nor rows.

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

* chore: drop the committed JVM crash report and ignore future ones (#62)

hs_err_pid26344.log in the repository root was a HotSpot crash dump that
had been committed by accident. It carries no information about the
project and must not travel with the sources.

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

* fix: drop the dead QSO sniffer word list that corrupted the callsign list on load (#103)

ChatPreferences carried a second sniffer list, lstNotify_QSOSniffer_sniffedWordsList,
that no UI, no manual and no consumer outside ChatPreferences ever touched. It was
still written to preferences.xml as <snifferWords><w>, and the matching reader block
was a copy of the <snifferCallSigns> block: it cleared the word list and then added
every word, normalised as a callsign, to lstNotify_QSOSniffer_sniffedCallSignList.
Any entry that ever reached the word list therefore leaked into QSO monitoring on
every load.

Remove the field, its accessors, the write block and the read block. A <snifferWords>
element in an older preferences file is now ignored like any unknown element; the
callsign list and its persistence are unchanged.

ChatPreferencesSnifferListTest loads a file that carries both lists and asserts that
only the <snifferCallSigns> entry is monitored. The round-trip test loses its
exclusion entry for the removed setter.

Fixes #103

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

* fix: remove the second-category locator that could never be set (#104)

ChatPreferences declared stn_loginLocatorSecondCat with the compiled-in default
"jn49fk". Nothing wrote it to or read it from preferences.xml, no settings tab
exposed it and no caller ever invoked the setter, so the field always held its
default. On4KstConnectionManager.configureMainChat nevertheless compared that
default with the station's locator and, with the second chat enabled, raised the
warning "ON4KST uses one locator per TCP session. The second-chat locator ... is
ignored" whenever the station's locator was not jn49fk, which is practically every
installation.

ON4KST uses one locator per TCP session by design, so a second-category locator can
never become meaningful. Remove the field, its accessors, the comparison and the
warning; the rest of configureMainChat is unchanged. The round-trip test loses its
exclusion entry for the removed setter.

No automated test covers configureMainChat: it is private, works on the manager's
private Session and needs a mocked ChatController plus a live socket handshake, so a
harness would far exceed the scope of this fix. The compile of core and app-desktop
and the shorter exclusion list are the evidence that nothing references the member.

Fixes #104

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 12:13:31 +02:00
Rsclub2_2andClaude Fable 5.1 4a90ae7d30 docs: describe worked-state check for automatic DX cluster spots (#91)
Add the new spot condition to the DX Cluster Server manual pages and the
forwarding section of the Features page in German and English, record
the fix in the v1.50 changelog, and note the rule in PROJECT_CONTEXT.

Fixes #91

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:12:11 +02:00
Rsclub2_2andClaude Fable 5.1 78de8b0a9c fix: suppress automatic DX cluster spots for worked stations (#91)
Automatic spots from directional opportunities were generated without
looking at the worked state, so stations already in the log kept being
spotted to the connected loggers.

The spot block in MessageBusManagementThread now asks
isAlreadyWorkedForDxClusterSpot(sender) before broadcasting. The rule:

- the band is taken from the sender's known frequency via
  FrequencyTextParser; an unknown band keeps the previous behaviour;
- the per-band worked flag of that band suppresses the spot
  (ChatMember.isWorkedOnBand maps B_1296/B_2320/B_5760 to the
  historically named worked1240/worked2300/worked5600 flags);
- the global worked flag suppresses the spot only when no per-band
  flag exists at all (Simplelogfile source without band information);
- the global flag together with per-band flags for other bands does
  not suppress, because the per-band information is authoritative.

Suppressed spots are logged at FINE. The direction check, the
inAngleAndRange marker, the tick sound and manual spots from the map
window and the settings test spot are unchanged.

Fixes #91

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:12:02 +02:00
Rsclub2_2andClaude Fable 5.1 ab28216280 fix: sort the AP column numerically instead of by its text (#87)
The "AP [minutes / pot%]" column of the station list and of the directed
messages had no comparator, so DataTableState fell back to the rendered
text and "13 (75%)" sorted above "3 (50%)".

AirplaneCell now owns a shared comparator, AirplaneCell.order, that both
column definitions use. The sort key is the first aeroplane of the cell,
which is the one the operator reads and the one the colour marks:
ascending by its arriving minutes, then by the better potential first for
equal minutes. A null info (nothing computed) and an empty list ("nil")
carry Int.MAX_VALUE, so they sort after every real aeroplane in ascending
order, consistent with the qrb and qtf sentinels. The second aeroplane of
a cell does not influence the order. Cell text, colours, column ids,
titles and weights are unchanged, as are DataTableState and the sort
cycle.

Fixes #87

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 01:09:49 +02:00
dependabot[bot] 456957808d build(deps): bump the minor-and-patch group across 1 directory with 5 updates
Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [gradle-wrapper](https://github.com/gradle/gradle) | `9.7.1` | `9.8.1` |
| [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) | `3.43.2.2` | `3.53.4.0` |
| [org.mockito:mockito-core](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| [org.mockito:mockito-core](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| [org.mockito:mockito-junit-jupiter](https://github.com/mockito/mockito) | `5.7.0` | `5.24.0` |
| com.github.spotbugs | `6.0.26` | `6.5.12` |

Updates `gradle-wrapper` from 9.7.1 to 9.8.1
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.7.1...v9.8.1)

Updates `org.xerial:sqlite-jdbc` from 3.43.2.2 to 3.53.4.0
- [Release notes](https://github.com/xerial/sqlite-jdbc/releases)
- [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG)
- [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.43.2.2...3.53.4.0)

Updates `org.mockito:mockito-core` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `org.mockito:mockito-core` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `org.mockito:mockito-junit-jupiter` from 5.7.0 to 5.24.0
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.7.0...v5.24.0)

Updates `com.github.spotbugs` from 6.0.26 to 6.5.12

---
updated-dependencies:
- dependency-name: com.github.spotbugs
  dependency-version: 6.5.12
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: gradle-wrapper
  dependency-version: 9.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.mockito:mockito-junit-jupiter
  dependency-version: 5.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: org.xerial:sqlite-jdbc
  dependency-version: 3.53.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] e67e2db2aa build(deps): bump junit from 5.10.1 to 6.1.3
Bumps `junit` from 5.10.1 to 6.1.3.

Updates `org.junit.jupiter:junit-jupiter` from 5.10.1 to 6.1.3
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r5.10.1...r6.1.3)

Updates `org.junit.jupiter:junit-jupiter-api` from 5.10.1 to 6.1.3
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r5.10.1...r6.1.3)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: org.junit.jupiter:junit-jupiter-api
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] 93c1423dcd build(deps): bump org.jetbrains:annotations from 24.0.1 to 26.1.0
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.0.1 to 26.1.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/24.0.1...26.1.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  dependency-version: 26.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] eddcbc82e8 build(deps): bump actions/setup-java from 4.1.0 to 6.0.1
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4.1.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4.1.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] ab42c6ef35 build(deps): bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:09 +02:00
dependabot[bot] 0039ec98e3 build(deps): bump actions/download-artifact from 4.1.3 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.3 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.1.3...v8.0.1)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
dependabot[bot] 294a978c3a build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
dependabot[bot] 19b9bf0fca build(deps): bump Andrew-Chen-Wang/github-wiki-action from 4 to 5
Bumps [Andrew-Chen-Wang/github-wiki-action](https://github.com/andrew-chen-wang/github-wiki-action) from 4 to 5.
- [Release notes](https://github.com/andrew-chen-wang/github-wiki-action/releases)
- [Commits](https://github.com/andrew-chen-wang/github-wiki-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: Andrew-Chen-Wang/github-wiki-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 00:37:08 +02:00
Rsclub2_2andClaude Sonnet 5.5 5b9fe0d3c1 chore: add Dependabot configuration for Gradle and GitHub Actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 00:37:08 +02:00
235 changed files with 19629 additions and 4424 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Gradle dependencies, including the version catalog in gradle/libs.versions.toml
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
target-branch: "main"
ignore:
# Kotlin and Compose Multiplatform are coupled; bump them manually together
- dependency-name: "org.jetbrains.kotlin*"
- dependency-name: "org.jetbrains.compose*"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Actions used in .github/workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
target-branch: "main"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
useradd -m builder
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
with:
fetch-depth: 0
ssh-key: ${{ secrets.WEBSITE_DEPLOY_KEY }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve version string
run: |
@@ -102,7 +102,7 @@ jobs:
github_docs/de-Changelog.md
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: docs-pdf
path: dist/KST4Contest-*-manual-*.pdf
+2 -2
View File
@@ -14,8 +14,8 @@ jobs:
publish-wiki:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Andrew-Chen-Wang/github-wiki-action@v4
- uses: actions/checkout@v7
- uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
path: github_docs
disable-empty-commits: true
+128 -27
View File
@@ -25,13 +25,108 @@ permissions:
packages: write
jobs:
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
# would be one more indirection to read, and the comments belong next to the steps they
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
# no artifact (#62). Edit all three copies together.
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Resolve nightly version info
shell: pwsh
@@ -43,7 +138,7 @@ jobs:
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -71,7 +166,7 @@ jobs:
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: windows-zip
path: dist/praktiKST-*-windows-x64.zip
@@ -80,10 +175,11 @@ jobs:
build-linux-appimage:
name: Build Linux AppImage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -92,7 +188,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -144,7 +240,7 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
- name: Upload Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-appimage
path: dist/KST4Contest-*-linux-x86_64.AppImage
@@ -153,10 +249,11 @@ jobs:
build-linux-deb:
name: Build Debian package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -165,7 +262,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -189,7 +286,7 @@ jobs:
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
- name: Upload Debian artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-debian
path: dist/KST4Contest-*-debian-amd64.deb
@@ -198,10 +295,11 @@ jobs:
build-linux-rpm:
name: Build Fedora package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -210,7 +308,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -234,7 +332,7 @@ jobs:
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
- name: Upload Fedora artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-fedora
path: dist/KST4Contest-*-fedora-x86_64.rpm
@@ -243,10 +341,11 @@ jobs:
build-linux-arch:
name: Build Arch Linux package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -257,7 +356,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -345,7 +444,7 @@ jobs:
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-arch
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
@@ -354,10 +453,11 @@ jobs:
build-flatpak:
name: Build Flatpak
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -368,7 +468,7 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -463,13 +563,13 @@ jobs:
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpakref
path: dist/de.x08.KST4Contest.nightly.flatpakref
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
@@ -494,13 +594,13 @@ jobs:
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
- name: Checkout existing flatpak-repo branch
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
with:
ref: flatpak-repo
path: existing-flatpak-repo
@@ -538,6 +638,7 @@ jobs:
build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
@@ -545,7 +646,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Resolve nightly version info
run: |
@@ -558,7 +659,7 @@ jobs:
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -602,7 +703,7 @@ jobs:
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: macos-dmg-${{ matrix.os }}
path: dist/KST4Contest-*-macos-*.dmg
+115 -5
View File
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -41,6 +41,64 @@ jobs:
- name: Compile
run: ./gradlew -S classes testClasses
# Report only: both tools run with ignoreFailures, so a finding cannot fail this step
# -- a crash of the tool itself still does, which is wanted. The findings used to exist
# only in the console log; since #110 they are files, and the table and the artifact
# below are what makes them readable from a pull request.
- name: Static analysis (report only)
run: ./gradlew -S pmdMain spotbugsMain
# Counted from the report XML, never from Gradle's output or exit code. A missing
# report prints "no report" and does not fail the step: that case is already visible
# in the step above.
- name: Summarise the static analysis
if: always()
run: |
set -euo pipefail
shopt -s nullglob
count() {
local file="$1" element="$2"
if [ -f "$file" ]; then
# grep exits 1 on zero matches, which pipefail would turn into a failed step.
{ grep -o "<$element " "$file" || true; } | wc -l | tr -d ' '
else
echo "no report"
fi
}
{
echo "### Static analysis"
echo ""
echo "| module | PMD violations | SpotBugs bugs |"
echo "|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
for module in core app-desktop i18n-generator; do
pmd=$(count "$module/build/reports/pmd/main.xml" violation)
spotbugs=$(count "$module/build/reports/spotbugs/main.xml" BugInstance)
echo "$module: pmd=$pmd spotbugs=$spotbugs"
echo "| \`$module\` | $pmd | $spotbugs |" >> "$GITHUB_STEP_SUMMARY"
done
{
echo ""
echo "Report only: nothing fails on a finding. The XML and HTML reports are the"
echo "static-analysis artifact of this run. i18n-generator is Kotlin only, so PMD"
echo "has no Java source there and writes no report."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the static analysis reports
if: always()
uses: actions/upload-artifact@v7
with:
name: static-analysis
path: |
*/build/reports/pmd/
*/build/reports/spotbugs/
if-no-files-found: ignore
retention-days: 14
# What a translation PR is actually about, put where its author will look. The counts
# come from the files rather than from Gradle's output, so an up-to-date task cannot
# make the table disappear.
@@ -100,10 +158,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -141,6 +199,14 @@ jobs:
- name: Test
run: ./gradlew -S test
# Coverage is a report, not a gate: no threshold, nothing in it can fail on a
# percentage. Deliberately not `if: always()` -- without a completed test run the
# report would be partial, and after a failed test the result XML below is what matters.
# jacocoTestReport writes the per-module XML the summary reads; the aggregate merges
# core and app-desktop (i18n-generator is build tooling and stays out).
- name: Coverage report
run: ./gradlew -S jacocoTestReport jacocoAggregatedReport
- name: Summarise the test results
if: always()
run: |
@@ -173,9 +239,53 @@ jobs:
exit 1
fi
- name: Summarise the coverage
if: always()
run: |
set -euo pipefail
# The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the
# per-package and per-class counters come before it.
percent() {
local file="$1" type="$2"
{ grep -o "<counter type=\"$type\"[^>]*/>" "$file" || true; } | tail -1 \
| sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \
| awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }'
}
row() {
local label="$1" file="$2"
if [ -f "$file" ]; then
echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |"
else
echo "| $label | no report | no report |"
fi
}
{
echo ""
echo "### Coverage"
echo ""
echo "| module | lines | branches |"
echo "|---|---|---|"
row "core" core/build/reports/jacoco/test/jacocoTestReport.xml
row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml
row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml
echo ""
echo "Report only: nothing fails on a percentage. The aggregated HTML report is the"
echo "coverage-report artifact of this run. i18n-generator is build tooling and is"
echo "not counted."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: build/reports/jacoco/aggregated/html/
if-no-files-found: ignore
retention-days: 14
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
+291 -43
View File
@@ -16,16 +16,139 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
# would be one more indirection to read, and the comments belong next to the steps they
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
# no artifact (#62). Edit all three copies together -- except for the tag check
# right after the checkout, which only makes sense here.
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
# composePackageVersion is the package version of every Compose target format,
# and the Linux package release below is derived from the tag alone. A tag that
# names a different X.Y.Z than gradle.properties would therefore ship packages
# whose version contradicts their file name and upgrade order (#113). Fail
# before the tests rather than after an hour of packaging. Skipped for
# workflow_dispatch, where the ref is a branch and carries no version.
- name: Verify the tag matches composePackageVersion
if: github.ref_type == 'tag'
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ "$REF_NAME" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
TAG_VERSION="${BASH_REMATCH[1]}"
elif [[ "$REF_NAME" =~ ^beta-v([0-9]+\.[0-9]+\.[0-9]+)-.+$ ]]; then
TAG_VERSION="${BASH_REMATCH[1]}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so it carries no package version"
exit 1
fi
PROPERTY_VERSION="$(sed -n 's/^composePackageVersion=//p' gradle.properties)"
if [[ "$TAG_VERSION" != "$PROPERTY_VERSION" ]]; then
echo "::error::Tag $REF_NAME names version $TAG_VERSION, but gradle.properties has composePackageVersion=$PROPERTY_VERSION. Bump composePackageVersion (and version) before tagging, or move the tag."
exit 1
fi
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
build-windows-zip:
name: Build Windows ZIP and MSI
runs-on: windows-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -53,21 +176,48 @@ jobs:
Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: windows-zip
path: dist/praktiKST-${{ github.ref_name }}-windows-x64.zip
# Stable tags only (issue #61): the MSI carries composePackageVersion
# from gradle.properties, so a nightly or beta MSI would have the same
# version as the following stable one. Windows Installer refuses to
# install an equal version over the installed one (jpackage's upgrade
# table only accepts strictly higher versions), so a beta tester could
# not move to the stable without uninstalling first. Betas and nightlies
# therefore stay ZIP-only. jpackage needs WiX 3.x, installed above. The
# ZIP is unchanged and stays alongside.
- name: Build Windows MSI installer
if: ${{ !startsWith(github.ref_name, 'beta-') }}
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:packageMsi
$msi = Get-ChildItem -Path app-desktop/build/compose/binaries/main/msi -Filter *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $msi) {
throw "No Windows MSI produced by packageMsi"
}
Copy-Item $msi.FullName dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
- name: Upload Windows MSI artifact
if: ${{ !startsWith(github.ref_name, 'beta-') }}
uses: actions/upload-artifact@v7
with:
name: windows-msi
path: dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
build-linux-appimage:
name: Build Linux AppImage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -119,7 +269,7 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
- name: Upload Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-appimage
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
@@ -127,13 +277,14 @@ jobs:
build-linux-deb:
name: Build Debian package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -146,9 +297,41 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
# rpm and dpkg refuse to replace a package by one of equal version, and the
# version is composePackageVersion for beta and stable alike. The package
# RELEASE number tells them apart, following the Fedora pre-release convention:
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
# stable installs as an upgrade over any beta (#113). The beta part is the tag
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
# does not set the property and keeps release 1, as do local builds.
- name: Derive the Linux package release from the tag
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
if [[ "$REF_TYPE" != "tag" ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
RELEASE="0.${BASH_REMATCH[1]//-/.}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
exit 1
fi
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
exit 1
fi
echo "Linux package release for $REF_NAME: $RELEASE"
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
- name: Build Debian package
run: |
./gradlew -S :app-desktop:packageDeb
./gradlew -S :app-desktop:packageDeb -PlinuxAppRelease="$LINUX_APP_RELEASE"
mkdir -p dist
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
@@ -157,7 +340,7 @@ jobs:
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
- name: Upload Debian artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-debian
path: dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb
@@ -165,13 +348,14 @@ jobs:
build-linux-rpm:
name: Build Fedora package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -184,9 +368,41 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
# rpm and dpkg refuse to replace a package by one of equal version, and the
# version is composePackageVersion for beta and stable alike. The package
# RELEASE number tells them apart, following the Fedora pre-release convention:
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
# stable installs as an upgrade over any beta (#113). The beta part is the tag
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
# does not set the property and keeps release 1, as do local builds.
- name: Derive the Linux package release from the tag
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
if [[ "$REF_TYPE" != "tag" ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
RELEASE="0.${BASH_REMATCH[1]//-/.}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
exit 1
fi
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
exit 1
fi
echo "Linux package release for $REF_NAME: $RELEASE"
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
- name: Build Fedora package
run: |
./gradlew -S :app-desktop:packageRpm
./gradlew -S :app-desktop:packageRpm -PlinuxAppRelease="$LINUX_APP_RELEASE"
mkdir -p dist
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
@@ -195,7 +411,7 @@ jobs:
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
- name: Upload Fedora artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-fedora
path: dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm
@@ -203,13 +419,14 @@ jobs:
build-linux-arch:
name: Build Arch Linux package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -297,7 +514,7 @@ jobs:
tar --zstd --transform 's|^\./||' -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: linux-arch
path: dist/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst
@@ -305,13 +522,14 @@ jobs:
build-flatpak:
name: Build Flatpak
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -416,13 +634,13 @@ jobs:
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpakref
path: dist/*.flatpakref
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
@@ -430,6 +648,7 @@ jobs:
build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
@@ -437,10 +656,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v4.1.0
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
@@ -485,7 +704,7 @@ jobs:
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: macos-dmg-${{ matrix.os }}
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
@@ -496,7 +715,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Install pandoc and LaTeX toolchain
run: |
@@ -562,7 +781,7 @@ jobs:
github_docs/de-Changelog.md
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: docs-pdf
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
@@ -587,13 +806,13 @@ jobs:
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
- name: Checkout existing flatpak-repo branch
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
with:
ref: flatpak-repo
path: existing-flatpak-repo
@@ -642,13 +861,15 @@ jobs:
steps:
- name: Checkout release source
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
with:
# Full history and tags, needed to list the commits since the previous release.
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)
@@ -673,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.
@@ -694,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
@@ -713,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:
@@ -729,50 +966,57 @@ jobs:
cat release-notes.md
- name: Download Windows artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: windows-zip
path: release-assets/windows
- name: Download Windows MSI artifact
if: ${{ !startsWith(github.ref_name, 'beta-') }}
uses: actions/download-artifact@v8.0.2
with:
name: windows-msi
path: release-assets/windows
- name: Download Linux artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: linux-appimage
path: release-assets/linux
- name: Download macOS artifacts
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
pattern: macos-dmg-*
merge-multiple: true
path: release-assets/macos
- name: Download Debian artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: linux-debian
path: release-assets/debian
- name: Download Fedora artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: linux-fedora
path: release-assets/fedora
- name: Download Arch Linux artifact
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: linux-arch
path: release-assets/archlinux
- name: Download flatpakref
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: flatpakref
path: release-assets/flatpakref
- name: Download PDF manuals
uses: actions/download-artifact@v4.1.3
uses: actions/download-artifact@v8.0.2
with:
name: docs-pdf
path: release-assets/docs
@@ -789,8 +1033,12 @@ jobs:
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
bodyFile: release-notes.md
generateReleaseNotes: false
# The MSI entry is absent for beta tags (the Windows job skips it);
# artifactErrorsFailBuild is left at its default, so a missing path
# only warns and the release still gets every other asset.
artifacts: >-
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
release-assets/windows/KST4Contest-${{ github.ref_name }}-windows-x64.msi,
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
@@ -804,7 +1052,7 @@ jobs:
# release. Otherwise the Releases API cannot return the release notes
# belonging to the tag which triggered this workflow.
- name: Set up Node.js for website build
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
@@ -835,7 +1083,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload verified website artifact
uses: actions/upload-artifact@v4.3.4
uses: actions/upload-artifact@v7
with:
name: kst4contest-website-${{ github.ref_name }}
path: website/_site/
@@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.1.7
uses: actions/checkout@v7
- name: Generate kst4ContestVersionInfo.xml from GitHub releases + history
env:
+3
View File
@@ -65,3 +65,6 @@ scratch/
# Kotlin incremental build data
.kotlin/
# JVM crash reports
hs_err_pid*.log
+3 -3
View File
@@ -124,12 +124,12 @@ Read the current `gradle.properties` and `gradle/libs.versions.toml` before rely
At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore:
Important: a test failure fails the build, but PMD and SpotBugs run with `ignoreFailures`, so static-analysis findings do not. Test classes live under `src/test` only (since #62; before that a few sat under `src/main` and were never run), and JUnit/Mockito are test-only dependencies. Therefore:
- inspect the Gradle test summary;
- inspect the test-results XML under `*/build/test-results/` when needed;
- do not infer "all tests passed" from exit code 0;
- report PMD/SpotBugs findings that are visible in the relevant build.
- do not infer "all tests passed" from exit code 0 -- read the XML;
- report PMD/SpotBugs findings that are visible in the relevant build. Both tools write XML and HTML: `<module>/build/reports/pmd/main.{xml,html}` and `<module>/build/reports/spotbugs/main.{xml,html}` (since #110; before that SpotBugs printed to the console only). Count from the XML (`<violation`, `<BugInstance`), not from the console. The PR workflow runs `pmdMain spotbugsMain`, prints the counts per module into the job summary and publishes the reports as the `static-analysis` artifact.
Run focused tests first, then normally the relevant broader test/build command for the scope.
+10 -1
View File
@@ -93,4 +93,13 @@ Windows:
## License
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
## Supporters/Sponsor
This Project is possible due to donations of the community.
You can sponsor KST4Contest here: [Support on Ko-Fi](https://ko-fi.com/praktimarc). If you add an Display Name there you will be mentioned here.
The following people are sponsoring KST4Contest:
- Marco (DJ5KW)
+50 -8
View File
@@ -10,10 +10,8 @@ kotlin {
}
dependencies {
// Mirrors the compile-scope entries in pom.xml: a few test classes still live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
// JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
// and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
implementation(compose.desktop.currentOs)
@@ -68,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)
})
}
@@ -145,8 +146,49 @@ compose.desktop {
"jdk.xml.dom", "jdk.unsupported",
)
linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
linux {
iconFile.set(rootProject.file("packaging/icons/kst4contest.png"))
/*
* Package release number behind the version (1.50.0-<release>).
* The version itself is composePackageVersion for every target
* format, so a beta DEB/RPM would otherwise carry exactly the
* version of the following stable one -- and dnf, rpm -U and
* dpkg refuse to replace a package by one of equal version, so
* a beta user could not move to the stable (#113). The tagged
* release workflow therefore passes -PlinuxAppRelease following
* the Fedora pre-release convention:
*
* beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
* beta-v1.50.0-rc02 -> 1.50.0-0.rc02
* v1.50.0 -> 1.50.0-1
*
* Everything starting with "0." sorts below "1" for both rpm
* and dpkg. Nightlies and local builds do not set the property
* and keep release 1.
*/
appRelease = providers.gradleProperty("linuxAppRelease").orElse("1").get()
}
windows {
iconFile.set(rootProject.file("packaging/icons/kst4contest.ico"))
/*
* The MSI installer is built for tagged releases only; nightlies ship
* the portable ZIP alone, because an MSI always carries the plain
* composePackageVersion and two nightly MSIs could not be told apart.
* It installs per user, so it needs neither administrator rights nor
* a UAC prompt, and it adds a Start menu group and a desktop shortcut.
*
* upgradeUuid must NEVER change: Windows Installer uses it to
* recognise an installed KST4Contest and replace it in place when a
* newer MSI runs. A different value would install the new release
* side by side with the old one instead of upgrading it.
*/
upgradeUuid = "3375071a-25ba-468e-9ae5-8296bc535462"
menu = true
menuGroup = "KST4Contest"
shortcut = true
perUserInstall = true
dirChooser = true
}
macOS {
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
@@ -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,131 +0,0 @@
package kst4contest.test;
import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PatternMatcherTest {
/**
* Tests if pattern matches with the given String.
*
* @param testString
* @param regExPattern
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Normalizes a chatmembers frequency-string for cluster usage<br/>
* <b>returns a frequency String in KHz like = "144300" or "144300.0" to match DXC protocol needs</b>
*
* @param optionalPrefix: if there is a value like ".300", it have to be decided, wich ".300": 144.300, 432.300, 1296.300 .... prefix means for example "144."
*/
private static String normalizeFrequencyString(String qrgString, String optionalPrefix) {
// final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
final String PTRN_QRG_CAT2_wholeQRGMHz4Digits = "(([0-9]{4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)"; //1296.300.3 etc
final String PTRN_QRG_CAT2_wholeQRGMHz3Digits = "(([0-9]{3}[\\.|,| ]?[0-9]{3})([\\.][\\d]{1,2})?)"; //144.300.3 etc
final String PTRN_QRG_CAT2_QRGwithoutPrefix = "((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; //144.300.3 etc
String predefinedPrefixInMHz = optionalPrefix;
String stringAggregation = "";
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz4Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 9) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
} else
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz3Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 7) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
}
else
if (testPattern(qrgString, PTRN_QRG_CAT2_QRGwithoutPrefix)) { //case ".050 or .300 or something like that"
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".", "");
stringAggregation = stringAggregation.replace(",", "");
if (stringAggregation.length() == 3) { // like 050 or 300
String stringAggregationNew = optionalPrefix + stringAggregation;
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 4) { //like 050.2 --> 0502
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 1) + "." + stringAggregation.substring(stringAggregation.length() - 1, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 5) { //like 050.20 --> 05020
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 2) + "." + stringAggregation.substring(stringAggregation.length() - 2, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
}
}
return qrgString;
}
public static void main(String[] args) {
int i = 0;
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144")));
}
}
@@ -1,6 +1,5 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
@@ -14,16 +13,24 @@ import java.util.Objects;
*/
public final class CoalescingTrigger {
private final Timer timer;
private final DelayTimer timer;
/**
* @param delayMs how long to wait after the last request
* @param action runs on the user-interface thread once the delay elapses
*/
public CoalescingTrigger(final int delayMs, final Runnable action) {
this(delayMs, action, DelayTimer.SWING);
}
/**
* Test seam: the same trigger on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
CoalescingTrigger(final int delayMs, final Runnable action, final DelayTimer.Factory timers) {
Objects.requireNonNull(action, "action");
this.timer = new Timer(delayMs, event -> action.run());
this.timer.setRepeats(false);
this.timer = timers.create(delayMs, action);
}
/** Requests a run, restarting the delay. */
@@ -0,0 +1,58 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
* A one-shot delay that can be restarted and stopped.
*
* <p>The thin seam between the two coalescing classes in this package and the
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
* implementation; tests substitute one whose firing they control, so "nothing ran" can
* be asserted without waiting for a delay that might not have elapsed yet.</p>
*/
interface DelayTimer {
/** Starts the delay, or starts it over if it is already running. */
void restart();
/** Drops a pending run. Idempotent. */
void stop();
/** Creates one-shot timers for a fixed delay and action. */
interface Factory {
/**
* @param delayMs how long the timer waits after a {@link #restart()}
* @param onFire what runs once the delay elapses
*/
DelayTimer create(int delayMs, Runnable onFire);
}
/**
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
* thread where the user interface lives.
*/
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
/** {@code javax.swing.Timer} behind the interface. */
final class Swing implements DelayTimer {
private final Timer timer;
Swing(final int delayMs, final Runnable onFire) {
Objects.requireNonNull(onFire, "onFire");
this.timer = new Timer(delayMs, event -> onFire.run());
this.timer.setRepeats(false);
}
@Override
public void restart() {
timer.restart();
}
@Override
public void stop() {
timer.stop();
}
}
}
@@ -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.
*
@@ -2,7 +2,6 @@ package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import javax.swing.Timer;
import java.util.Objects;
/**
@@ -22,7 +21,7 @@ public final class LayoutAutosave {
private final Runnable writeLayout;
private final UiDispatcher uiDispatcher;
private final Timer saveDelay;
private final DelayTimer saveDelay;
private boolean pending;
/**
@@ -30,16 +29,28 @@ public final class LayoutAutosave {
* @param uiDispatcher confines the pending state to one thread
*/
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
/*
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
* AWT event thread, which is where the user interface lives now, and restart()
* has the same meaning playFromStart() had. A PauseTransition would need the
* JavaFX toolkit to be running, which it no longer is.
*/
this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending());
this.saveDelay.setRepeats(false);
this(writeLayout, uiDispatcher, DelayTimer.SWING);
}
/**
* Test seam: the same debounce on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
LayoutAutosave(
final Runnable writeLayout,
final UiDispatcher uiDispatcher,
final DelayTimer.Factory timers
) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
this.saveDelay = timers.create(SAVE_DELAY_MS, this::flushPending);
}
/** Asks for a save, restarting the delay. Safe to call from any thread. */
@@ -1,6 +1,7 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
/**
@@ -34,6 +35,27 @@ object AirplaneCell {
*/
private const val NOTHING_COMING = "nil"
// ---- the order ----------------------------------------------------------
/** Unknown or no aeroplane sorts after every real one. */
private const val NO_PLANE = Int.MAX_VALUE
/** The aeroplane the cell shows first; the parser already put the best one there. */
private fun first(info: AirPlaneReflectionInfo?): AirPlane? = info?.risingAirplanes?.firstOrNull()
/**
* Ascending: the minutes the operator reads first, then the better potential first.
*
* Issue #87: without a comparator the table fell back to the rendered text, which put
* "13 (75%)" above "3 (50%)". Only the first aeroplane counts, as it is the one the
* operator reads and the one the colour marks; the second is in the cell for context.
* Empty and "nil" cells carry [NO_PLANE]: like qrb and qtf, an absent value gets a
* sentinel instead of a null check, here the maximum so the cell sorts last ascending.
*/
val order: Comparator<AirPlaneReflectionInfo?> =
compareBy<AirPlaneReflectionInfo?> { first(it)?.arrivingDurationMinutes ?: NO_PLANE }
.thenByDescending { first(it)?.potential ?: 0 }
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
val PURPLE_100 = Color(0xFFF98AFF)
@@ -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,14 +54,15 @@ 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 },
weight = 1.4f,
),
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,15 +65,16 @@ 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 },
weight = 1.4f,
))
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,
@@ -99,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 }
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
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
}
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,70 @@
package kst4contest.testing;
import java.time.Duration;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
/**
* Bounded polling for tests that wait on another thread.
*
* <p>Replaces fixed {@code Thread.sleep} pauses: a condition that is already true returns
* at once, and one that never becomes true fails with a message instead of letting the
* test pass or fail on the speed of the machine.</p>
*/
public final class Await {
private static final long POLL_INTERVAL_MS = 10L;
private Await() {
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, BooleanSupplier condition) {
awaitUntil(timeout, "condition not met within " + timeout, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message what the failure should say when the timeout elapses
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, String message, BooleanSupplier condition) {
awaitUntil(timeout, () -> message, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message builds the failure message at the moment of failure, so it can
* describe the state that was actually seen
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, Supplier<String> message, BooleanSupplier condition) {
long deadline = System.nanoTime() + timeout.toNanos();
while (true) {
if (condition.getAsBoolean()) {
return;
}
if (System.nanoTime() >= deadline) {
throw new AssertionError(message.get());
}
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("interrupted while waiting: " + message.get(), interrupted);
}
}
}
}
@@ -3,12 +3,13 @@ package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.awt.EventQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static kst4contest.testing.Await.awaitUntil;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -17,85 +18,89 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
* wrong are invisible in a build: too eager and the table redraws on every message under
* contest load, never and the table stops updating altogether.</p>
*
* <p>All but one test run on a {@link FakeDelayTimer} the test fires itself, so "it did
* not run" is a fact and not a 400 ms guess. The one exception goes through the real
* Swing timer so the default wiring stays covered.</p>
*/
class CoalescingTriggerTest {
@Test
void aBurstOfTriggersProducesOneRun() throws Exception {
void aBurstOfTriggersProducesOneRun() {
AtomicInteger runs = new AtomicInteger();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
runs.incrementAndGet();
ran.countDown();
});
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
// What a busy chat looks like: many updates, one intended redraw.
for (int i = 0; i < 25; i++) {
trigger.trigger();
}
assertEquals(0, runs.get(), "nothing may run before the delay elapses");
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
Thread.sleep(300);
timers.timer().fire();
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
timers.timer().fire();
assertEquals(1, runs.get(), "the timer is one-shot; a burst must not queue a second run");
}
/** The real Swing timer, end to end: the action has to arrive on the event thread. */
@Test
void theActionRunsOnTheEventThread() throws Exception {
void theActionRunsOnTheEventThread() {
AtomicBoolean onEventThread = new AtomicBoolean();
CountDownLatch ran = new CountDownLatch(1);
AtomicBoolean ran = new AtomicBoolean();
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
onEventThread.set(EventQueue.isDispatchThread());
ran.countDown();
ran.set(true);
});
trigger.trigger();
assertTrue(ran.await(5, TimeUnit.SECONDS));
awaitUntil(Duration.ofSeconds(5), "the Swing timer never fired", ran::get);
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
}
@Test
void aLaterTriggerPostponesTheRun() throws Exception {
void aLaterTriggerPostponesTheRun() {
AtomicInteger runs = new AtomicInteger();
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet, timers);
trigger.trigger();
Thread.sleep(150);
trigger.trigger();
Thread.sleep(150);
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
assertEquals(0, runs.get(),
assertEquals(2, timers.timer().restarts(),
"a new trigger must restart the delay, not let the old one fire");
assertEquals(0, runs.get(), "nothing has elapsed yet, so nothing may have run");
assertEquals(250, timers.delayMs());
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
void cancellingBeforeTheDelayElapsesSuppressesTheRun() {
AtomicInteger runs = new AtomicInteger();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
trigger.trigger();
trigger.cancel();
Thread.sleep(400);
assertFalse(timers.timer().isArmed(), "cancel must stop the timer");
timers.timer().fire();
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
}
@Test
void triggeringAgainAfterACancelStillWorks() throws Exception {
void triggeringAgainAfterACancelStillWorks() {
AtomicInteger runs = new AtomicInteger();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
runs.incrementAndGet();
ran.countDown();
});
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(80, runs::incrementAndGet, timers);
trigger.trigger();
trigger.cancel();
trigger.trigger();
assertTrue(timers.timer().isArmed(), "a cancel must not disable the trigger for good");
assertTrue(ran.await(5, TimeUnit.SECONDS),
"a cancel must not disable the trigger for good");
timers.timer().fire();
assertEquals(1, runs.get());
}
}
@@ -0,0 +1,73 @@
package kst4contest.view;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* A {@link DelayTimer} that fires only when the test says so.
*
* <p>Models exactly what the Swing timer promises and nothing about wall time: a
* {@code restart()} arms it, a {@code stop()} disarms it, and {@link #fire()} runs the
* action once if it is armed. That turns "the run did not happen" from a wait-and-hope
* into an assertion.</p>
*/
final class FakeDelayTimer implements DelayTimer {
private final Runnable onFire;
private boolean armed;
private int restarts;
FakeDelayTimer(final Runnable onFire) {
this.onFire = onFire;
}
@Override
public void restart() {
armed = true;
restarts++;
}
@Override
public void stop() {
armed = false;
}
/** What the Swing timer does when the delay elapses: run once, then disarm. */
void fire() {
if (!armed) {
return;
}
armed = false;
onFire.run();
}
boolean isArmed() {
return armed;
}
int restarts() {
return restarts;
}
/** Hands out one fake timer and keeps it for the test to fire. */
static final class Factory implements DelayTimer.Factory {
private FakeDelayTimer created;
private int createdDelayMs;
@Override
public DelayTimer create(final int delayMs, final Runnable onFire) {
created = new FakeDelayTimer(onFire);
createdDelayMs = delayMs;
return created;
}
FakeDelayTimer timer() {
assertNotNull(created, "the class under test did not create its timer");
return created;
}
int delayMs() {
return createdDelayMs;
}
}
}
@@ -4,11 +4,13 @@ import kst4contest.observe.UiDispatcher;
import org.junit.jupiter.api.Test;
import java.awt.EventQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static kst4contest.testing.Await.awaitUntil;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -17,59 +19,72 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
* silent: a timer that never fires means the operator's column widths are simply not
* saved, with nothing to see until the next start.</p>
*
* <p>The negative checks run on a {@link FakeDelayTimer} the test fires itself, on the
* event thread as the Swing timer would, so "nothing was written" is a fact rather than a
* 1.2 s guess. The first test goes through the real Swing timer so the default wiring
* stays covered.</p>
*/
class LayoutAutosaveTest {
private final UiDispatcher dispatcher = new AwtUiDispatcher();
/** The real Swing timer, end to end, from a thread that is not the event thread. */
@Test
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
void aRequestFromAForeignThreadEventuallyWrites() {
AtomicBoolean written = new AtomicBoolean();
LayoutAutosave autosave = new LayoutAutosave(() -> written.set(true), dispatcher);
autosave.requestSave();
assertTrue(written.await(5, TimeUnit.SECONDS),
"a save requested off the user-interface thread must still happen");
awaitUntil(Duration.ofSeconds(5),
"a save requested off the user-interface thread must still happen",
written::get);
}
@Test
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(() -> {
writes.incrementAndGet();
written.countDown();
}, dispatcher);
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
// What dragging a column divider looks like: many events, one intended save.
for (int i = 0; i < 20; i++) {
autosave.requestSave();
}
EventQueue.invokeAndWait(() -> {
for (int i = 0; i < 20; i++) {
autosave.requestSave();
}
});
assertEquals(20, timers.timer().restarts(), "every request must restart the delay");
assertEquals(0, writes.get(), "nothing may be written before the delay elapses");
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
Thread.sleep(300);
EventQueue.invokeAndWait(timers.timer()::fire);
assertEquals(1, writes.get(), "a burst must collapse into a single write");
EventQueue.invokeAndWait(timers.timer()::fire);
assertEquals(1, writes.get(), "the timer is one-shot; a burst must not queue a second write");
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
autosave.cancelPending();
});
assertFalse(timers.timer().isArmed(), "cancelling must stop the timer");
Thread.sleep(1_200);
EventQueue.invokeAndWait(timers.timer()::fire);
assertEquals(0, writes.get(), "a cancelled save must not be written");
}
@Test
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
AtomicInteger writes = new AtomicInteger();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
@@ -79,5 +94,7 @@ class LayoutAutosaveTest {
});
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
assertFalse(timers.timer().isArmed(), "a flush must also stop the pending timer");
assertTrue(timers.timer().restarts() > 0, "the request must have armed the timer first");
}
}
@@ -4,6 +4,7 @@ import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
/**
@@ -97,6 +98,70 @@ class AirplaneCellTest {
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
}
// ---- the order ----------------------------------------------------------
/**
* Issue #87: the column was sorted by its rendered text, so "13 (75%)" came before
* "3 (50%)". These are the thirteen cells of the screenshot in the issue, in the order
* they were shown; sorted, the minutes have to read as numbers.
*/
@Test
fun `the minutes sort as numbers, not as text`() {
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
assertEquals(
listOf(1, 1, 3, 3, 6, 6, 8, 8, 9, 9, 13, 14, 19),
sorted.map { it.risingAirplanes.first().arrivingDurationMinutes },
)
}
/** Same minutes: the better potential first, which is also the one the colour marks. */
@Test
fun `equal minutes put the better potential first`() {
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
assertEquals(
listOf("3 (75%)", "3 (50%)"),
sorted.map { AirplaneCell.format(it) }.filter { it.startsWith("3 ") },
"the screenshot had 3 (50%) above 3 (75%); the order is not just stable",
)
}
/**
* An empty cell (nothing computed) and "nil" (no aeroplane coming) both carry the
* maximum sentinel, so ascending puts them after the last real aeroplane.
*/
@Test
fun `no aeroplane sorts after the last real one`() {
val nothingComputed: AirPlaneReflectionInfo? = null
val nothingComing = reflectionInfo()
val cells = listOf(nothingComputed, nothingComing) + screenshotCells()
val sorted = cells.sortedWith(AirplaneCell.order)
assertEquals(19, sorted[sorted.size - 3]?.risingAirplanes?.first()?.arrivingDurationMinutes)
assertSame(nothingComputed, sorted[sorted.size - 2])
assertSame(nothingComing, sorted[sorted.size - 1])
}
/** Only the first aeroplane, the one the operator reads first, decides. */
@Test
fun `the second aeroplane of a cell does not influence the order`() {
val nineThenOne = reflectionInfo(plane(9, 75), plane(1, 100))
val threeThenTwenty = reflectionInfo(plane(3, 50), plane(20, 50))
val sorted = listOf(nineThenOne, threeThenTwenty).sortedWith(AirplaneCell.order)
assertSame(threeThenTwenty, sorted[0])
assertSame(nineThenOne, sorted[1])
}
/** The AP column of the issue's screenshot, top to bottom: (minutes, potential). */
private fun screenshotCells(): List<AirPlaneReflectionInfo> = listOf(
1 to 75, 1 to 75, 13 to 75, 14 to 50, 19 to 50, 3 to 50, 3 to 75,
6 to 75, 6 to 75, 8 to 75, 8 to 75, 9 to 75, 9 to 75,
).map { (minutes, potential) -> reflectionInfo(plane(minutes, potential)) }
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
@@ -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")
}
}
/**
@@ -6,6 +6,7 @@ import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -98,6 +99,33 @@ class DirectedMessageColumnsTest {
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
}
/**
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
* comparator sorts by it, not by its text", with the real columns.
*/
@Test
fun `airscout sorts numerically`() {
assertNotNull(columns.single { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
val table = DataTableState(columns = columns, rowKey = RowKeys.byReference<ChatMessage>())
table.replaceRows(listOf(
message(sender = memberWithAeroplane("DL13ABC", minutes = 13)),
message(sender = memberWithAeroplane("DL9ABC", minutes = 9)),
))
table.toggleSort("airscout")
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.sender?.callSign },
"9 before 13 numerically; by text it would be the other way round")
}
private fun memberWithAeroplane(call: String, minutes: Int): ChatMember = member(call).apply {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
}
}
@Test
fun `the category names the chat the message arrived in`() {
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
@@ -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()
@@ -1,6 +1,7 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import kst4contest.testing.Await.awaitUntil
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
@@ -8,9 +9,10 @@ import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.awt.Window
import java.time.Duration
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import javax.swing.Timer
/**
* The picker must be openable from the user-interface thread.
@@ -29,31 +31,45 @@ class OperatorProfilePickerWindowTest {
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
)
/** Closes whatever window the picker opened, the way the operator's X would. */
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
Timer(delayMs) {
Window.getWindows()
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
.forEach { it.dispose() }
}.apply { isRepeats = true }
/** The picker's dialog, once it is on screen, or null while it is not. */
private fun showingPickerDialog(): Window? =
Window.getWindows().firstOrNull { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
/**
* Closes the picker the way the operator's X would, as soon as it is showing.
*
* Runs on its own thread because both callers block the test thread: one inside
* `invokeAndWait`, the other inside the modal `showAndSelect` itself. The dispose is
* handed to the event thread, which a modal dialog keeps pumping. Waiting for the dialog
* to appear replaces a fixed 1.5 s timer that was long enough on a fast machine only.
*/
private fun disposePickerOnceShowing(): Future<*> =
closer.submit {
awaitUntil(Duration.ofSeconds(10), "the picker's dialog never appeared") {
showingPickerDialog() != null
}
EventQueue.invokeLater { showingPickerDialog()?.dispose() }
}
private val closer = Executors.newSingleThreadExecutor()
@Test
fun opensAndReturnsWhenCalledOnTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
val worker = Executors.newSingleThreadExecutor()
try {
closer.start()
val closing = disposePickerOnceShowing()
// invokeAndWait so the call really happens ON the event thread, as the menu does.
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
// The deadlock had no timeout of its own: without a fix this never completes.
call.get(20, TimeUnit.SECONDS)
closing.get(1, TimeUnit.SECONDS)
} finally {
closer.stop()
worker.shutdownNow()
closer.shutdownNow()
}
}
@@ -61,17 +77,16 @@ class OperatorProfilePickerWindowTest {
fun opensAndReturnsWhenCalledOffTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
try {
closer.start()
val closing = disposePickerOnceShowing()
// The startup path: main() resolves the profile before any window exists.
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
// Dismissed rather than confirmed, so the caller is told to quit.
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
closing.get(1, TimeUnit.SECONDS)
} finally {
closer.stop()
closer.shutdownNow()
}
}
@@ -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")
}
}
@@ -1,9 +1,12 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -120,6 +123,32 @@ class StationColumnsTest {
assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember()))
}
/**
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
* comparator sorts by it, not by its text", with the real columns.
*/
@Test
fun `airscout sorts numerically`() {
val columns = columns()
assertNotNull(columns.first { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
val table = DataTableState(columns = columns, rowKey = RowKeys.byValue<ChatMember> { it.callSign.orEmpty() })
table.replaceRows(listOf(stationWithAeroplane("DL13ABC", minutes = 13), stationWithAeroplane("DL9ABC", minutes = 9)))
table.toggleSort("airscout")
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.callSign },
"9 before 13 numerically; by text it would be the other way round")
}
private fun stationWithAeroplane(call: String, minutes: Int): ChatMember = ChatMember().apply {
setCallSign(call)
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
}
}
@Test
fun `tropo and score come from outside the station`() {
val columns = StationColumns.all(
@@ -133,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")
}
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more