Compare commits

...
9 Commits
Author SHA1 Message Date
github-actions[bot] 7ee02fb029 chore: update AUR packages to v1.44.0 [skip ci] 2026-10-16 18:17:06 +00:00
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
122 changed files with 9597 additions and 1507 deletions

No files matched your search

@@ -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
@@ -254,6 +275,7 @@ 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.
@@ -323,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
@@ -366,6 +390,8 @@ 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.
@@ -386,6 +412,7 @@ 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
@@ -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
@@ -263,6 +284,7 @@ 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.
@@ -332,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
@@ -375,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.
@@ -395,6 +421,7 @@ 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
@@ -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();
@@ -3072,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 -> {
@@ -3137,9 +3167,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
return resolveDefaultSkedBand(member, enabledBands);
},
azimuth -> {
chatcontroller.rotateTo(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);
@@ -3751,10 +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,
this::settingsConnectionState),
this::settingsConnectionState, draft),
settingsNotices,
draft,
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore,
@@ -3767,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));
}
/*
@@ -3839,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();
}
@@ -3956,6 +4001,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
@Override
public void refreshSo2rState() {
runOnUi(() -> {
if (composeMainWindowState == null) {
return;
}
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
});
}
/**
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
*
@@ -158,6 +158,8 @@ fun ChatInputRow(
ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>,
antennaQtf2: ObservedValue<Double>,
so2rEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@@ -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
@@ -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
@@ -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
@@ -124,12 +126,12 @@ object Form {
* 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 stored right now, read when the field commits. The draft
* 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 recomposes the caller after a commit, so
* after Enter it still names the old value, and once the preference is reset
* behind the field it names the new one by coincidence only.
* @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(
@@ -166,6 +168,24 @@ object Form {
}
}
/*
* 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,
@@ -304,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)
}
@@ -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()
}
}
@@ -188,6 +188,12 @@ fun SelectedStationPanel(
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())
}
@@ -56,6 +56,10 @@ enum class SelectedMessageFilter(val label: String) {
* 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,
@@ -66,6 +70,8 @@ class SelectedStationState(
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)
@@ -116,6 +122,20 @@ class SelectedStationState(
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 }
@@ -37,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.
@@ -57,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()
@@ -118,6 +126,8 @@ interface SettingsHost {
* @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,
@@ -126,33 +136,41 @@ fun buildSettingsTabs(
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) },
@@ -160,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),
@@ -174,9 +194,9 @@ fun buildSettingsTabs(
val logSynchState = LogSynchTabState(
prefs,
draft,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
@@ -211,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.
@@ -227,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.
@@ -291,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)
@@ -311,19 +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(kst4contest.view.i18n.CurrentStrings.get().settingsWriteFailed)
} else {
notices.info(kst4contest.view.i18n.CurrentStrings.get().settingsWrittenTo(target))
}
},
onApplyAndClose = close,
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
@@ -332,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,6 +312,15 @@ 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 = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
darkMode = darkMode,
@@ -173,68 +330,101 @@ 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,
@@ -252,6 +442,46 @@ internal fun SettingsContent(
}
}
/**
* 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.
*
@@ -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.*
@@ -61,7 +62,13 @@ object StationMapWindow {
onResized = onResized,
) { _ ->
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() }
/*
@@ -129,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
@@ -140,7 +159,7 @@ object StationMapWindow {
}
builder.buildSnapshots(
mainWindowState.stations.rows,
mainWindowState.stations.visibleRows,
selectedMember,
bandSet
)
@@ -152,17 +171,29 @@ object StationMapWindow {
// Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
LaunchedEffect(selectedMember) {
/*
* 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 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) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
if (selectedSnapshot.hasUsablePosition()) {
/*
* Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only
@@ -183,8 +214,8 @@ object StationMapWindow {
pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat,
selectedSnapshot?.locator6() ?: "",
selectedSnapshot?.callSignRaw() ?: ""
selectedSnapshot.locator6(),
selectedSnapshot.callSignRaw()
)
analysis.request(selectedMember, selectedSnapshot) { 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 }
}
/**
@@ -3,6 +3,8 @@ 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.
@@ -10,34 +12,57 @@ import kst4contest.view.i18n.CurrentStrings
* 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.
@@ -50,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 {
@@ -78,9 +103,9 @@ 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()
@@ -89,16 +114,7 @@ class BeaconTabState(
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
}
}
@@ -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(),
)
}
}
@@ -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) }
}
@@ -10,121 +10,129 @@ 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 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)
@@ -133,13 +141,12 @@ class NotificationTabState(
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.
*/
@@ -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) },
)
}
/**
@@ -211,4 +223,11 @@ class NotificationTabState(
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"
}
}
@@ -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 }
}
}
@@ -3,23 +3,30 @@ 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,
@@ -32,6 +39,11 @@ class StationTabState(
* 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?
@@ -43,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()
@@ -103,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.
@@ -170,9 +228,7 @@ 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,
@@ -222,4 +278,40 @@ class StationTabState(
loginChatCategorySecond = category
applySecondCategory(category)
}
companion object {
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
const val SECOND_ROTATOR: String = "secondRotator"
/**
* Whether two PSTRotator ports collide: the same port, or one apart because
* PSTRotator answers on port + 1, which would be the other client's local port.
*/
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
/**
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
* number in the accepted range is taken, anything else is refused with null.
* A comma is read as the decimal separator, as in the other number fields.
*/
fun parseOwnHeightAsl(text: String): Double? {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return Double.NaN
}
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
}
/**
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
* a draft that does not parse is never unchanged, so leaving the field refuses it.
*/
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
val typed = parseOwnHeightAsl(draft) ?: return false
val now = parseOwnHeightAsl(stored) ?: return false
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
}
}
}
@@ -0,0 +1,41 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
/**
* Corrects stored settings the settings window cannot show as they are, once, before the
* window is built.
*
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
* did while building its panes. They used to happen inside the tab states -- one during a
* read in composition, one in a constructor -- where a write to the preferences is easy to
* miss and runs whenever Compose decides to read. Here they run at one known moment.
*
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
* while it is still the catch-all default 255.255.255.255. An address the operator or a
* previous detection chose is left alone, and a failed detection keeps the default:
* guessing an address would send SKED packets somewhere nobody is listening.
*
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
}
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (!detected.isNullOrBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
}
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current".
*
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did.
* The controls stage their edits in the window's draft (see TrxSynchTabState).
*/
@Composable
fun TrxSynchTab(state: TrxSynchTabState) {
@@ -63,6 +62,24 @@ fun TrxSynchTab(state: TrxSynchTabState) {
)
}
Form.section(strings.trxSynchSo2rSection) {
Form.check(
strings.trxSynchSo2rEnabled,
state.trxSynch_so2rEnabled,
) { state.trxSynch_so2rEnabled = it; revision++ }
GuardedTextField(
label = strings.trxSynchWinTestStationRadio2,
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
enabled = state.trxSynch_so2rEnabled,
onAccepted = {
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
revision++
},
)
Text(strings.trxSynchSo2rHint)
}
Form.section(strings.trxSynchHintSection) {
Text(strings.trxSynchHint)
}
@@ -1,12 +1,14 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The TRX synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*
* The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
@@ -18,34 +20,42 @@ import kst4contest.model.ChatPreferences
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
* cannot read a checked box as "my QRG is being kept current".
*
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
* follower as a trailing lambda keep binding to it.
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller.
*/
class TrxSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applySecondRotator: () -> Unit = {},
private val applyOwnQrgFollower: (Boolean) -> Unit,
) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
set(value) {
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
var logsynch_wintestQrgSyncEnabled: Boolean
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
set(value) {
prefs.setLogsynch_wintestQrgSyncEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean
get() = prefs.isLogsynch_wintestUsePassQrg()
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
{ prefs.isLogsynch_wintestUsePassQrg() },
) { prefs.setLogsynch_wintestUsePassQrg(it) }
// Staged trimmed, so the draft holds what will be stored.
private var stationFilter: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
/**
* Win-Test station name filter. An empty filter accepts every station, which is
@@ -53,8 +63,29 @@ class TrxSynchTabState(
* way the JavaFX field committed it.
*/
var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
get() = stationFilter
set(value) { stationFilter = value.trim() }
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
var trxSynch_so2rEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_so2rEnabled() },
effectKey = StationTabState.SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setTrxSynch_so2rEnabled(it) }
private var stationRadio2: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
var logsynch_wintestNetworkStationNameOfWintestClient2: String
get() = stationRadio2
set(value) { stationRadio2 = value.trim() }
/* Read from the stored values: it runs on apply, after both switches are written. */
private fun applyFollower() {
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
}
/**
* True when at least one source may update the own QRG. One source switched off is
@@ -74,5 +105,8 @@ class TrxSynchTabState(
* would read whichever one the tab happened to use.
*/
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
/** The effect key both QRG sources share. */
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
}
}
@@ -0,0 +1,69 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
@OptIn(ExperimentalTestApi::class)
class ChatInputRowSo2rScreenTest {
@TempDir
lateinit var tempDir: Path
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
getActualQTF().set(145.0)
getActualQTF2().set(270.0)
}
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
})
@Test
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
val prefs = prefs()
var so2r by mutableStateOf(false)
setContent {
ChatInputRow(
state = chatInputStateFor(prefs),
prefs = prefs,
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
antennaQtf = ObservedValue(prefs.getActualQTF()),
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
so2rEnabled = so2r,
)
}
onAllNodesWithText("270.0").assertCountEquals(0)
so2r = true
waitForIdle()
onNodeWithText("270.0").assertExists()
onNodeWithText("270.0").assertIsNotEnabled()
}
}
@@ -322,6 +322,26 @@ class DataTableStateTest {
assertSame(kept, table.selectedRow)
}
/**
* The filter reads fields of the row objects, which change in place: a station's QRB,
* its worked flags, its last activity. forceRedraw is how the application says so, and
* the cached visible rows must be filtered again after it (#21).
*/
@Test
fun `a redraw filters rows again whose fields changed in place`() {
val table = stationTable()
val moving = Station("DN9APW", 100)
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
table.rowFilter = { it.qrb > 300 }
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
moving.qrb = 500
table.forceRedraw()
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
"the cached rows from before the change were handed back")
}
}
/**
@@ -56,7 +56,7 @@ class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
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}" }
}
}
@@ -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") },
)
}
}
@@ -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)))
}
}
@@ -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"))
}
}
@@ -259,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")
}
}
@@ -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()
@@ -195,4 +195,30 @@ class StationListScreenTest {
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))
}
}
@@ -4,7 +4,9 @@ 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
@@ -381,4 +383,91 @@ class StationMapWindowScreenTest {
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,6 +1,8 @@
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
@@ -10,9 +12,13 @@ import java.nio.file.Path
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* 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) }
val state by lazy { AirscoutTabState(prefs, draft) }
}
@Test
@@ -34,7 +40,39 @@ class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
}
@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,6 +2,8 @@ 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
@@ -13,13 +15,21 @@ import java.nio.file.Path
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session(private val templateRefusal: String? = null) {
/*
* 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) }
@@ -31,7 +41,46 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the enable flags write through at once`() {
fun `beacon edits wait for apply, and the timer restarts only then`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
assertEquals("CQ MYCALL", state.beaconTextMainCat)
s.draft.apply()
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
assertEquals(1, s.timerRestarts)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
val state = s.state()
state.commitBeaconTextMainCat("CQ MYCALL MYQRG")
state.commitBeaconInterval("9")
s.draft.discard()
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(0, s.timerRestarts)
}
@Test
fun `the enable flags reach the preferences`() {
val s = Session()
val state = s.state()
@@ -0,0 +1,227 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.Locale
/**
* Every drafted setting of every tab, with a real [SettingsDraft]: unchanged in the
* preferences after the edit, changed after apply, unchanged after discard.
*
* The tab tests pin each tab's rules with a draft that applies at once, so they cannot tell
* a setting that goes through the draft from one that writes ChatPreferences directly. This
* table can, setter by setter. A setting added to a tab belongs in it; one that is meant to
* stay immediate (login credentials, language, colours, actions) belongs in the spec instead.
*/
class DraftedSettingsTableTest {
@TempDir
lateinit var directory: Path
/** All drafted tabs over one preferences file and one draft. */
private class Fixture(val prefs: ChatPreferences, val monitored: SimpleRoster<String> = SimpleRoster()) {
val draft = SettingsDraft()
val station = StationTabState(prefs, draft, { }, { })
val logSynch = LogSynchTabState(prefs, draft, { }, { })
val trx = TrxSynchTabState(prefs, draft) { }
val airscout = AirscoutTabState(prefs, draft)
val notification = NotificationTabState(prefs, draft, monitored, { }, { null })
val shortcuts = ShortcutsTabState(prefs, draft, { }, { })
val beacon = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
val messages = MessageHandlingTabState(prefs, draft) { }
val gui = GuiOptionsTabState(prefs, draft, LanguageStore({ "en" }, { }, Locale.UK))
}
/** One drafted setting: how the tab changes it, and how the stored value is read. */
private class Case(val name: String, val change: (Fixture) -> Unit, val stored: (Fixture) -> Any?)
private fun flag(name: String, read: (Fixture) -> Boolean, write: (Fixture, Boolean) -> Unit) =
Case(name, { f -> write(f, !read(f)) }, read)
private fun cases(): List<Case> = listOf(
// Station, all but the login credentials
Case("station beam width", { it.station.stn_antennaBeamWidthDeg += 1.5 }, { it.prefs.getStn_antennaBeamWidthDeg() }),
flag("station 10G", { it.station.stn_bandActive10G }) { f, v -> f.station.stn_bandActive10G = v },
flag("station 1240", { it.station.stn_bandActive1240 }) { f, v -> f.station.stn_bandActive1240 = v },
flag("station 144", { it.station.stn_bandActive144 }) { f, v -> f.station.stn_bandActive144 = v },
flag("station 2300", { it.station.stn_bandActive2300 }) { f, v -> f.station.stn_bandActive2300 = v },
flag("station 3400", { it.station.stn_bandActive3400 }) { f, v -> f.station.stn_bandActive3400 = v },
flag("station 432", { it.station.stn_bandActive432 }) { f, v -> f.station.stn_bandActive432 = v },
flag("station 50", { it.station.stn_bandActive50 }) { f, v -> f.station.stn_bandActive50 = v },
flag("station 5600", { it.station.stn_bandActive5600 }) { f, v -> f.station.stn_bandActive5600 = v },
flag("station 70", { it.station.stn_bandActive70 }) { f, v -> f.station.stn_bandActive70 = v },
Case("station max QRB", { it.station.stn_maxQRBDefault += 1.5 }, { it.prefs.getStn_maxQRBDefault() }),
Case("station server host", { it.station.stn_on4kstServersDns += "x" }, { it.prefs.getStn_on4kstServersDns() }),
Case("station server port", { it.station.stn_on4kstServersPort += 1 }, { it.prefs.getStn_on4kstServersPort() }),
Case("station target gain", { it.station.stn_pathAnalysisDefaultTargetAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }),
Case("station target power", { it.station.stn_pathAnalysisDefaultTargetTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }),
Case("station DEM directory", { it.station.stn_pathAnalysisDemRootDirectory += "x" },
{ it.prefs.getStn_pathAnalysisDemRootDirectory() }),
Case("station own gain", { it.station.stn_pathAnalysisOwnAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaGainDbi() }),
Case("station own height", { it.station.stn_pathAnalysisOwnAntennaHeightMeters += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
// Through the field's commit, which validates and then stages.
Case("station own ground height ASL", { it.station.commitOwnHeightAsl("312") },
{ it.prefs.getStn_pathAnalysisOwnHeightAslMeters() }),
flag("station target on highest point", { it.station.stn_pathAnalysisDxHighestPointInSquare }) { f, v ->
f.station.stn_pathAnalysisDxHighestPointInSquare = v
},
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
flag("station second chat", { it.station.loginToSecondChatEnabled }) { f, v -> f.station.loginToSecondChatEnabled = v },
// Log synch, all but the file chooser
flag("log file polling", { it.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled }) { f, v ->
f.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled = v
},
flag("log UCX listener", { it.logSynch.logsynch_ucxUDPWkdCallListenerEnabled }) { f, v ->
f.logSynch.logsynch_ucxUDPWkdCallListenerEnabled = v
},
Case("log UCX port", { it.logSynch.logsynch_ucxUDPWkdCallListenerPort += 1 },
{ it.prefs.getLogsynch_ucxUDPWkdCallListenerPort() }),
flag("log Win-Test listener", { it.logSynch.logsynch_wintestNetworkListenerEnabled }) { f, v ->
f.logSynch.logsynch_wintestNetworkListenerEnabled = v
},
Case("log Win-Test port", { it.logSynch.logsynch_wintestNetworkPort += 1 }, { it.prefs.getLogsynch_wintestNetworkPort() }),
Case("log Win-Test station name", { it.logSynch.logsynch_wintestNetworkStationNameOfKST += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfKST() }),
Case("log Win-Test broadcast", { it.logSynch.logsynch_wintestNetworkBroadcastAddress = "10.9.8.255" },
{ it.prefs.getLogsynch_wintestNetworkBroadcastAddress() }),
// TRX synch
flag("trx UCX QRG", { it.trx.trxSynch_ucxLogUDPListenerEnabled }) { f, v -> f.trx.trxSynch_ucxLogUDPListenerEnabled = v },
flag("trx Win-Test QRG", { it.trx.logsynch_wintestQrgSyncEnabled }) { f, v -> f.trx.logsynch_wintestQrgSyncEnabled = v },
flag("trx pass QRG", { it.trx.logsynch_wintestUsePassQrg }) { f, v -> f.trx.logsynch_wintestUsePassQrg = v },
Case("trx station filter", { it.trx.logsynch_wintestNetworkStationNameOfWintestClient1 += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() }),
// Airscout
flag("airscout listener", { it.airscout.airScout_asUDPListenerEnabled }) { f, v -> f.airscout.airScout_asUDPListenerEnabled = v },
Case("airscout server", { it.airscout.airScout_asServerNameString += "X" }, { it.prefs.getAirScout_asServerNameString() }),
Case("airscout client", { it.airscout.airScout_asClientNameString += "X" }, { it.prefs.getAirScout_asClientNameString() }),
Case("airscout port", { it.airscout.airScout_asCommunicationPort += 1 }, { it.prefs.getAirScout_asCommunicationPort() }),
flag("airscout auto band", { it.airscout.airScout_autoBandSelectionEnabled }) { f, v ->
f.airscout.airScout_autoBandSelectionEnabled = v
},
Case("airscout band", { f -> f.airscout.airScout_asBandString = ((f.airscout.airScout_asBandString.toLong()) + 1).toString() },
{ it.prefs.getAirScout_asBandString() }),
// Notification, all but the test spot
flag("sound simple", { it.notification.notify_playSimpleSounds }) { f, v -> f.notification.notify_playSimpleSounds = v },
flag("sound CW", { it.notification.notify_playCWCallsignsOnRxedPMs }) { f, v -> f.notification.notify_playCWCallsignsOnRxedPMs = v },
flag("sound voice", { it.notification.notify_playVoiceCallsignsOnRxedPMs }) { f, v ->
f.notification.notify_playVoiceCallsignsOnRxedPMs = v
},
flag("band upgrade hint", { it.notification.notify_bandUpgradeHintOnLogEnabled }) { f, v ->
f.notification.notify_bandUpgradeHintOnLogEnabled = v
},
flag("band upgrade boost", { it.notification.notify_bandUpgradePriorityBoostEnabled }) { f, v ->
f.notification.notify_bandUpgradePriorityBoostEnabled = v
},
Case("cluster server", { f -> f.notification.enableDxClusterServer(!f.notification.notify_dxClusterServerEnabled) },
{ it.prefs.isNotify_dxClusterServerEnabled() }),
Case("cluster port", { f -> f.notification.commitDxClusterServerPort((f.notification.notify_dxclusterServerPort + 1).toString()) },
{ it.prefs.getNotify_dxclusterServerPort() }),
Case("cluster spotter", { f ->
f.notification.commitSpotterCallSign(if (f.notification.notify_DXCSrv_SpottersCallSign == "DL1ABC") "DO5AMF" else "DL1ABC")
}, { it.prefs.getNotify_DXCSrv_SpottersCallSign().get() }),
Case("cluster fallback band", { f ->
f.notification.notify_optionalFrequencyPrefix =
if (f.notification.notify_optionalFrequencyPrefix == Band.B_432) Band.B_144 else Band.B_432
}, { it.prefs.getNotify_optionalFrequencyPrefix().get() }),
Case("monitoring list", { it.notification.addMonitoredCallSign("DL1ABC") }, { it.monitored.snapshot() }),
// Shortcuts
Case("shortcut buttons", { f ->
f.shortcuts.shortcuts.addAtTop("cq")
f.shortcuts.commitShortcuts()
}, { it.prefs.getLst_txtShortCutBtnList().snapshot() }),
Case("text snippets", { f ->
f.shortcuts.snippets.addAtTop("73")
f.shortcuts.commitSnippets()
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
{ it.prefs.getBcn_beaconTextSecondCat() }),
Case("beacon interval", { it.beacon.commitBeaconInterval((it.beacon.beaconIntervalMinutes + 1).toString()) },
{ it.prefs.getBcn_beaconIntervalInMinutesMainCat() }),
// Message handling
flag("auto answer", { it.messages.autoAnswerEnabled }) { f, v -> f.messages.autoAnswerEnabled = v },
Case("auto answer text", { it.messages.autoAnswerText += " x" }, { it.prefs.getMessageHandling_autoAnswerTextMainCat() }),
flag("QRG answer", { it.messages.autoAnswerToQRGRequestEnabled }) { f, v -> f.messages.autoAnswerToQRGRequestEnabled = v },
flag("debug to file", { it.messages.debugModeToFileEnabled }) { f, v -> f.messages.debugModeToFileEnabled = v },
// GUI, all but the language
flag("gui dark by default", { it.gui.gUI_darkModeActiveByDefault }) { f, v -> f.gui.gUI_darkModeActiveByDefault = v },
flag("gui filter nothing", { it.gui.guiOptions_defaultFilterNothing }) { f, v -> f.gui.guiOptions_defaultFilterNothing = v },
flag("gui filter pm to me", { it.gui.guiOptions_defaultFilterPmToMe }) { f, v -> f.gui.guiOptions_defaultFilterPmToMe = v },
flag("gui filter pm to other", { it.gui.guiOptions_defaultFilterPmToOther }) { f, v ->
f.gui.guiOptions_defaultFilterPmToOther = v
},
flag("gui filter public", { it.gui.guiOptions_defaultFilterPublicMsgs }) { f, v -> f.gui.guiOptions_defaultFilterPublicMsgs = v },
flag("gui fresh call hint", { it.gui.guiOptions_showFreshCallHintInBandColumns }) { f, v ->
f.gui.guiOptions_showFreshCallHintInBandColumns = v
},
flag("gui gross field hint", { it.gui.guiOptions_showGrossFieldWorkedHintInBandColumns }) { f, v ->
f.gui.guiOptions_showGrossFieldWorkedHintInBandColumns = v
},
)
@TestFactory
fun everyDraftedSettingWaitsForApply(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
assertEquals(before, storedNow(case, f), "${case.name} took effect before Apply")
f.draft.apply()
assertNotEquals(before, storedNow(case, f), "${case.name} did not take effect with Apply")
}
}
@TestFactory
fun everyDraftedSettingIsLeftAloneByDiscard(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
f.draft.discard()
assertEquals(before, storedNow(case, f), "${case.name} changed although discarded")
}
}
/*
* The stored value, read past the draft: a flag case reads through the tab, which shows
* the draft, so it reads from tab states over the same preferences with a draft of their own.
*/
private fun storedNow(case: Case, f: Fixture): Any? = case.stored(Fixture(f.prefs, f.monitored))
private fun fresh(): Path = Files.createTempDirectory(directory, "case")
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LanguageStoreFactory
import kst4contest.view.i18n.SYSTEM_LANGUAGE
@@ -36,12 +38,14 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
assertEquals("de", state.language)
assertEquals("de", prefs.guiOptions_language)
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
assertFalse(draft.hasPendingChanges)
}
@@ -50,16 +54,29 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
}
@Test
fun `writing reaches the preferences at once`() {
fun `an edit waits for apply, and discarding drops it`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, languageStore())
state.gUI_darkModeActiveByDefault = !prefs.isGUI_darkModeActiveByDefault()
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
assertFalse(prefs.isGuiOptions_defaultFilterPublicMsgs(), "an edit took effect before Apply")
assertTrue(state.guiOptions_defaultFilterPublicMsgs, "the tab shows the edit")
val darkBefore = prefs.isGUI_darkModeActiveByDefault()
draft.discard()
assertEquals(darkBefore, prefs.isGUI_darkModeActiveByDefault())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
draft.apply()
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
@@ -71,7 +88,7 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_defaultFilterPmToMe = true
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -54,7 +55,7 @@ class ListTabsScreenTest {
}
var shortcutRefreshes = 0
var snippetRefreshes = 0
val state = ShortcutsTabState(prefs, { shortcutRefreshes++ }, { snippetRefreshes++ })
val state = ShortcutsTabState(prefs, ImmediateDraft(), { shortcutRefreshes++ }, { snippetRefreshes++ })
}
@Test
@@ -1,6 +1,7 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -10,17 +11,17 @@ import java.nio.file.Path
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session(detected: String? = null) {
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var restarts = 0
var stops = 0
val detectedBroadcast = detected
val draft = SettingsDraft()
fun state() = LogSynchTabState(
prefs,
draft,
restartWintestListener = { restarts++ },
stopWintestListener = { stops++ },
detectWintestBroadcastAddress = { detectedBroadcast },
)
}
@@ -44,23 +45,44 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
fun `an edit waits for apply, the chosen log file does not`() {
val s = Session()
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(false)
val state = s.state()
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
state.logsynch_ucxUDPWkdCallListenerEnabled = true
state.logsynch_ucxUDPWkdCallListenerPort = 12060
state.logsynch_wintestNetworkPort = 9871
state.logsynch_ucxUDPWkdCallListenerPort = 12061
state.logsynch_wintestNetworkPort = 9879
// The file chooser is an action: the chosen path is written when chosen.
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertFalse(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled(), "an edit took effect before Apply")
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(12061, state.logsynch_ucxUDPWkdCallListenerPort, "the tab shows the edit")
s.draft.apply()
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(12061, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9879, s.prefs.getLogsynch_wintestNetworkPort())
}
@Test
fun `a discarded edit changes nothing and touches no listener`() {
val s = Session()
val state = s.state()
val enabled = s.prefs.isLogsynch_wintestNetworkListenerEnabled()
state.logsynch_wintestNetworkListenerEnabled = !enabled
state.logsynch_wintestNetworkPort = 9879
s.draft.discard()
assertEquals(enabled, s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(0, s.restarts + s.stops)
}
@Test
@@ -68,91 +90,84 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
// The JavaFX fields committed getText().trim(); a stray space in a station
// name or a broadcast address is a protocol value, not decoration.
val s = Session()
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
val state = s.state()
state.logsynch_wintestNetworkStationNameOfKST = " KST "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
state.logsynch_wintestNetworkStationNameOfKST = "KST "
assertFalse(s.draft.hasPendingChanges, "a trailing space is no change")
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
state.logsynch_wintestNetworkStationNameOfKST = " KST2 "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
s.draft.apply()
assertEquals("KST2", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `enabling the Win-Test listener starts it and disabling stops it`() {
fun `applying the Win-Test switch starts or stops the listener`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkListenerEnabled = true
assertEquals(0, s.restarts, "the listener followed the box before Apply")
s.draft.apply()
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.restarts, "the running listener must follow the setting")
state.logsynch_wintestNetworkListenerEnabled = false
s.draft.apply()
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.stops)
}
@Test
fun `a committed port change rebinds the listener only while it is enabled`() {
// Deliberately not on every keystroke: the port write goes through at once,
// but rebinding a UDP socket is bound to the commit, as the JavaFX field's
// focus-lost handler did.
fun `an applied port change rebinds the listener only while it is enabled`() {
val s = Session()
val state = s.state()
/*
* Switched off explicitly: ChatPreferences ships
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
* a fresh configuration already listens.
*/
state.logsynch_wintestNetworkListenerEnabled = false
val restartsAfterDisable = s.restarts
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkPort = 9872
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(0, s.restarts, "a disabled listener has nothing to rebind")
state.commitWintestNetworkPort()
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
state.logsynch_wintestNetworkListenerEnabled = true
val restartsAfterEnable = s.restarts
s.prefs.setLogsynch_wintestNetworkListenerEnabled(true)
state.logsynch_wintestNetworkPort = 9873
state.commitWintestNetworkPort()
assertEquals(0, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(restartsAfterEnable + 1, s.restarts)
assertEquals(1, s.restarts)
}
/*
* Switch and port in one apply: one restart, on the new port. Two restarts in a row can
* fail on the port the first has not released yet.
*/
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
fun `switch and port applied together rebind once, on the new port`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
var portAtRestart = -1
val state = LogSynchTabState(
s.prefs, s.draft,
restartWintestListener = { s.restarts++; portAtRestart = s.prefs.getLogsynch_wintestNetworkPort() },
stopWintestListener = { s.stops++ },
)
s.state()
state.logsynch_wintestNetworkListenerEnabled = true
state.logsynch_wintestNetworkPort = 9874
s.draft.apply()
assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
"the JavaFX window auto-detected the subnet broadcast while the stored "
+ "value was still the catch-all default")
}
@Test
fun `a broadcast address the operator chose is never overwritten`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
s.state()
assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val s = Session(detected = null)
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
s.state()
assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
assertEquals(1, s.restarts)
assertEquals(9874, portAtRestart)
}
}
@@ -1,6 +1,8 @@
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
@@ -17,19 +19,53 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
assertTrue(state.autoAnswerEnabled)
assertTrue(state.autoAnswerToQRGRequestEnabled)
assertEquals("qrv 144.300", state.autoAnswerText)
}
@Test
fun `an edit waits for apply, and the recording follows only then`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs, draft) { recording += it }
state.autoAnswerText = "qrv 432.200"
state.debugModeToFileEnabled = !prefs.isMessageHandling_debugModeToFileEnabled()
val wanted = state.debugModeToFileEnabled
assertEquals("qrv 432.200", state.autoAnswerText, "the tab shows the edit")
assertTrue(recording.isEmpty(), "the recording followed before Apply")
draft.apply()
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
assertEquals(listOf(wanted), recording)
}
@Test
fun `a discarded edit changes nothing`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(false)
val draft = SettingsDraft()
val state = MessageHandlingTabState(prefs, draft)
state.autoAnswerEnabled = true
draft.discard()
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
assertFalse(state.autoAnswerEnabled)
}
@Test
fun `one checkbox drives both category flags`() {
// The JavaFX window offered a single checkbox and wrote both preferences from
// it; keeping only the main one would leave the second chat on its old value.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerEnabled = true
@@ -45,7 +81,7 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerText = "qrv 432.200"
@@ -54,9 +90,9 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the QRG reply flag writes through on its own`() {
fun `the QRG reply flag is stored on its own`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerToQRGRequestEnabled = true
@@ -15,6 +15,8 @@ import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.observe.SimpleRoster
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
@@ -60,7 +62,7 @@ class MessagingTabsScreenTest {
isMessageHandling_debugModeToFileEnabled = false
}
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs) { recording += it }
val state = MessageHandlingTabState(prefs, ImmediateDraft()) { recording += it }
showTab { MessageHandlingTab(state) }
onNodeWithText("Automatic answer to private messages").assertExists()
@@ -87,7 +89,7 @@ class MessagingTabsScreenTest {
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
var storedLanguage = "en"
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
val state = GuiOptionsTabState(prefs, languages)
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languages)
showTab { GuiOptionsTab(state) }
onNodeWithText("Startup design").assertExists()
@@ -117,11 +119,21 @@ class MessagingTabsScreenTest {
// ------------------------------------------------------------------ Beacon
private class Beacon(val prefs: ChatPreferences, val refuse: Boolean = false) {
/*
* A draft that applies at once by default, so the tests below keep pinning the field's
* commit rules (#128) against the stored value; the drafted behaviour is pinned by the
* tests that pass a real draft.
*/
private class Beacon(
val prefs: ChatPreferences,
val refuse: Boolean = false,
val draft: SettingsDraft = ImmediateDraft(),
) {
var restarts = 0
val refusals = mutableListOf<String>()
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = { restarts++ },
mainCategoryName = "Microwave",
@@ -226,15 +238,15 @@ class MessagingTabsScreenTest {
}
/*
* The stored value can change under a field without the field's own key changing: the
* field commits 9 over 5, nothing recomposes the tab, then a profile switch puts 5 back
* and the tab recomposes with 5 -- the same `stored` it was first drawn with. The field
* still shows 9, so leaving it must commit 9 again; judged against what the field itself
* last committed, it looked unchanged and the shown value silently differed from the
* stored one.
* The stored value can change under a field -- a profile switch puts 5 back after the field
* committed 9. The field used to keep showing 9 then, because nothing recomposed the tab
* after a commit, and #128 made leaving it commit the shown 9 again. Since the edits go
* through the settings draft, which is Compose state, a commit recomposes the tab: the field
* follows the stored value, shows the 5, and leaving it commits nothing. Either way what the
* field shows is what is stored once the operator has left it.
*/
@Test
fun aValueResetBehindTheFieldIsCommittedAgainWhenTheFieldIsLeft() = runComposeUiTest {
fun aValueResetBehindTheFieldIsShownAndNotCommittedAgain() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val generation = mutableStateOf(0)
@@ -251,13 +263,13 @@ class MessagingTabsScreenTest {
generation.value++
}
waitForIdle()
fieldBeside("Interval in whole minutes").assertTextEquals("9")
fieldBeside("Interval in whole minutes").assertTextEquals("5")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the shown 9 was not committed again")
assertEquals(2, s.restarts)
assertEquals(5, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the field committed a value it no longer showed")
assertEquals(1, s.restarts)
}
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
@@ -281,7 +293,7 @@ class MessagingTabsScreenTest {
// ------------------------------------------------------------------ Notification
private class Notification(val prefs: ChatPreferences) {
private class Notification(val prefs: ChatPreferences, val draft: SettingsDraft = ImmediateDraft()) {
init {
prefs.isNotify_dxClusterServerEnabled = false
}
@@ -292,15 +304,19 @@ class MessagingTabsScreenTest {
val refusals = mutableListOf<String>()
val state = NotificationTabState(
prefs,
draft,
monitored,
applyDxClusterServerEnabled = { serverEnabled += it },
applyDxClusterServerPort = { ports += it },
// What the server is brought to on apply: its switch, and the port it binds.
applyDxClusterServer = {
serverEnabled += prefs.isNotify_dxClusterServerEnabled
ports += prefs.notify_dxclusterServerPort
},
broadcastTestSpot = { spots += it; "no logger connected" },
)
}
@Test
fun notificationWritesThroughAndStartsTheClusterServer() = runComposeUiTest {
fun notificationStoresItsFieldsAndStartsTheClusterServer() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
@@ -314,6 +330,7 @@ class MessagingTabsScreenTest {
toggle("Run the DX Cluster server")
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
assertEquals(listOf(true), s.serverEnabled)
s.ports.clear()
typeAndCommit("Spotter callsign", "dn9apw")
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
@@ -4,6 +4,8 @@ import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -16,25 +18,99 @@ import java.nio.file.Path
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
var serverApplies = 0
var enabledAtApply: Boolean? = null
var portAtApply: Int? = null
var spotSent: ChatMember? = null
var spotRefusal: String? = null
fun state() = NotificationTabState(
prefs,
draft,
monitored,
applyDxClusterServerEnabled = { serverEnabledApplied = it },
applyDxClusterServerPort = { portApplied = it },
applyDxClusterServer = {
serverApplies++
enabledAtApply = prefs.isNotify_dxClusterServerEnabled()
portAtApply = prefs.getNotify_dxclusterServerPort()
},
broadcastTestSpot = { spotSent = it; spotRefusal },
)
}
@Test
fun `the sound settings read from and write to the preferences at once`() {
fun `an edit waits for apply, and discarding drops it`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
state.notify_playSimpleSounds = false
state.notify_optionalFrequencyPrefix = Band.B_432
assertNull(state.commitSpotterCallSign("DO5AMF"))
assertTrue(s.prefs.isNotify_playSimpleSounds(), "an edit took effect before Apply")
assertFalse(state.notify_playSimpleSounds, "the tab shows the edit")
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
s.draft.discard()
assertTrue(s.prefs.isNotify_playSimpleSounds())
assertTrue(state.notify_playSimpleSounds)
state.notify_playSimpleSounds = false
s.draft.apply()
assertFalse(s.prefs.isNotify_playSimpleSounds())
}
@Test
fun `the server is touched on apply only, and once for switch and port together`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_dxClusterServerEnabled(false)
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
state.enableDxClusterServer(true)
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(0, s.serverApplies, "the server moved before Apply")
s.draft.apply()
assertEquals(1, s.serverApplies, "two restarts in a row can fail on the port")
assertEquals(true, s.enabledAtApply)
assertEquals(7300, s.portAtApply, "the server must come up on the new port")
}
@Test
fun `the monitoring list reaches the roster on apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.addMonitoredCallSign("DN9APW"))
assertTrue(s.monitored.snapshot().isEmpty(), "the list took effect before Apply")
assertTrue(s.draft.hasPendingChanges)
s.draft.apply()
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
}
@Test
fun `the test spot is an action and needs no apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.sendTestSpot())
assertNotNull(s.spotSent)
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `the sound settings read from and write to the preferences`() {
val s = Session()
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
@@ -51,7 +127,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the band-upgrade hints write through`() {
fun `the band-upgrade hints reach the preferences`() {
val s = Session()
val state = s.state()
@@ -65,12 +141,13 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `enabling the DX cluster server also tells the running session`() {
val s = Session()
s.prefs.setNotify_dxClusterServerEnabled(false)
val state = s.state()
state.enableDxClusterServer(true)
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
assertEquals(true, s.serverEnabledApplied,
assertEquals(true, s.enabledAtApply,
"the JavaFX checkbox started or stopped the server as well, and the "
+ "settings coverage test cannot see that call")
}
@@ -83,7 +160,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
assertEquals(7300, s.portApplied)
assertEquals(7300, s.portAtApply)
}
@Test
@@ -96,7 +173,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
assertNull(s.portApplied, "an refused port must not restart the server")
assertEquals(0, s.serverApplies, "an refused port must not restart the server")
}
@Test
@@ -117,7 +194,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitDxClusterServerPort("8000"))
assertNull(s.portApplied,
assertEquals(0, s.serverApplies,
"the JavaFX field only restarted the server when the port actually changed")
}
@@ -155,15 +232,15 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `an unsupported stored prefix falls back to 144 and is written back`() {
val s = Session()
fun `an unsupported stored prefix is shown as 144 without writing anything`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_optionalFrequencyPrefix("4711")
val state = s.state()
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
assertEquals("4711", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"reading the tab wrote the preferences; repairStoredSettings does that, once")
}
@Test
@@ -184,7 +261,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
"monitoring works on the base call, so every SSID maps to the same entry")
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
"the JavaFX button added to the roster at once")
"applied, the roster holds the entry")
}
@Test
@@ -0,0 +1,579 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.Form
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.SettingsQuestion
import kst4contest.view.compose.runOnce
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotice
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.applyAndSave
import kst4contest.view.compose.SettingsWindow
import kst4contest.view.compose.applyChecked
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.inDialog
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The settings window around its tabs: edits wait in the draft, Apply/Close applies them,
* Save applies before it writes, and the X asks about what is still waiting.
*
* Drawn with the Beacon tab because it has all three kinds of control that matter here: a
* checkbox, a field that commits on Enter or focus loss, and an effect (the timer restart).
* The button bar is a stand-in: the real one needs a ChatController, which opens a database;
* it calls the same [applyChecked] and [applyAndSave] this one does.
*/
@OptIn(ExperimentalTestApi::class)
class SettingsApplyScreenTest {
@TempDir
lateinit var directory: Path
private inner class Window(refuse: Boolean = false, timerFails: Boolean = false) {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
bcn_beaconIntervalInMinutesMainCat = 5
isBcn_beaconsEnabledMainCat = false
}
val draft = SettingsDraft()
val notices = SettingsNotices()
val question = SettingsQuestion()
var closed = false
var restarts = 0
/** The title bar's X. */
fun x() = question.settleClose(draft, notices)
/** A profile switch or the exit, as SettingsWindow.settleThen hands it on. */
fun switchOrExit(action: () -> Unit) = question.settle(draft, notices, then = action, nothingPending = action)
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = {
restarts++
if (timerFails) throw IllegalStateException("timer gone")
},
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
val station = StationTabState(prefs, draft, { }, { })
val airscout = AirscoutTabState(prefs, draft)
val tabs = listOf(
SettingsTab("Beacon") {
androidx.compose.foundation.layout.Column {
BeaconTab(state, notices::problem)
StationTab(station)
}
},
)
}
private fun androidx.compose.ui.test.ComposeUiTest.show(w: Window, tabs: List<SettingsTab> = w.tabs) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = tabs,
notices = w.notices,
buttons = { close ->
Form.button(APPLY_AND_CLOSE) { if (applyChecked(w.draft, w.notices)) close() }
},
close = { w.closed = true },
draft = w.draft,
question = w.question,
)
}
}
waitForIdle()
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/*
* A click on a button leaves the focus in the text field, so the field has not committed
* when the button runs. Apply commits it first; otherwise the entry would reach the draft
* only when the closing window removed the field -- after the apply, into nothing.
*/
@Test
fun applyAndCloseTakesTheEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/* A refused entry stops the button, so the reason is not closed away with the window. */
@Test
fun aRefusedEntryKeepsTheWindowOpenAndShowsWhy() = runComposeUiTest {
val w = Window(refuse = true)
show(w)
val field = fieldBeside("Beacon message")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("cq %foo%")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed over a refused entry")
assertTrue(w.notices.current is SettingsNotice.Problem)
}
/*
* A refusal stops the apply before anything goes into effect: the valid edit beside the
* refused one is still pending, so the operator can still discard everything.
*/
@Test
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
/*
* A number field showing text that does not parse holds the last valid value in the draft.
* Applying that value would put something into effect the operator no longer sees.
*/
@Test
fun anInvalidNumberRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
val storedPort = w.prefs.stn_on4kstServersPort
show(w)
type("Server port", "23005")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed with the old port applied")
assertEquals(storedPort, w.prefs.stn_on4kstServersPort)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("Server port") &&
notice.message.contains("23x"), "the notice does not name the field: $notice")
}
/* The same for the guarded fields of the external interfaces. */
@Test
fun anInvalidGuardedEntryRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
show(w, listOf(SettingsTab("Airscout") { AirscoutTab(w.airscout) }))
type(AIRSCOUT_PORT, "0")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains(AIRSCOUT_PORT),
"the notice does not name the field: $notice")
}
/* A failing effect is reported, the window stays open, and the other changes are in. */
@Test
fun aFailingEffectIsReportedAndTheRestIsApplied() = runComposeUiTest {
val w = Window(timerFails = true)
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the failure was closed away")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone"), "$notice")
}
@Test
fun theXClosesAtOnceWhenNothingIsPending() = runComposeUiTest {
val w = Window()
show(w)
runOnIdle { w.x() }
waitForIdle()
assertTrue(w.closed)
}
@Test
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed, "the window closed without asking")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.closed)
}
@Test
fun discardingOnTheXChangesNothing() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
assertFalse(w.draft.hasPendingChanges)
}
@Test
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
/* An entry typed just before the X counts as pending, although no Enter committed it. */
@Test
fun theXAsksAboutAnEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
}
/* A list row still being edited counts too: the lists commit a row on Enter or focus loss. */
@Test
fun theXAsksAboutARowStillBeingEdited() = runComposeUiTest {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
}
val draft = SettingsDraft()
val question = SettingsQuestion()
val notices = SettingsNotices()
var closed = false
val state = ShortcutsTabState(prefs, draft, { }, { })
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = listOf(SettingsTab("Shortcuts") { ShortcutsTab(state) { } }),
notices = notices,
buttons = { },
close = { closed = true },
draft = draft,
question = question,
)
}
}
waitForIdle()
val row = onNode(hasSetTextAction() and hasText("pse sked"))
row.performScrollTo().performClick()
waitForIdle()
row.performTextReplacement("pse sked 432")
waitForIdle()
runOnIdle { question.settleClose(draft, notices) }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertEquals(listOf("MYQRG", "pse sked 432"), prefs.lst_txtShortCutBtnList.snapshot())
assertTrue(closed)
}
/*
* A profile switch or the exit asks the same question. Apply applies the edits to the
* profile still in force and goes on; the window itself is left to the teardown.
*/
@Test
fun aSwitchOrExitAsksAndApplyGoesOnWithTheEditsApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@Test
fun goingBackCancelsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "the switch or exit went ahead after Back")
assertTrue(w.draft.hasPendingChanges)
}
@Test
fun discardingGoesOnWithNothingApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
@Test
fun aRefusalAtTheQuestionStopsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
type("Server port", "23x")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
}
@Test
fun withNothingPendingTheSwitchOrExitGoesOnAtOnce() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
runOnIdle { w.switchOrExit { wentOn = true } }
waitForIdle()
assertTrue(wentOn)
assertFalse(w.closed)
}
/*
* An X while the exit or switch question is open must not replace its continuation:
* Apply would then only close the window, and the exit would be dropped without a word.
*/
@Test
fun theXDoesNotReplaceAnOpenSwitchOrExitQuestion() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
runOnIdle { w.x() }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn, "the exit or switch was dropped")
assertFalse(w.closed)
}
/*
* Invalid text in a field refuses Apply, but must not trap the operator: the question is
* shown anyway, with the reason, and Discard closes.
*/
@Test
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
awaitNode(inDialog("Apply")).assertIsNotEnabled()
onNode(hasText("23x", substring = true) and hasAnyAncestor(isDialog())).assertExists()
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@Test
fun anInvalidFieldStillLetsASwitchOrExitDiscard() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
type("Server port", "23x")
runOnIdle { w.switchOrExit { wentOn = true } }
assertFalse(wentOn, "went on past a field that refuses its text")
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
}
@Test
fun aContinuationRunsOnlyOnce() {
var runs = 0
val once = runOnce { runs++ }
once.run()
once.run()
assertEquals(1, runs)
}
/* Without a settings window there is nothing to ask: the action runs at once, synchronously. */
@Test
fun settleThenRunsAtOnceWithoutAnOpenWindow() {
var ran = false
SettingsWindow.settleThen { ran = true }
assertTrue(ran)
}
/* A failed effect does not keep the file from being written; its setting was stored. */
@Test
fun saveStillWritesWhenOnlyAnEffectFailed() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { throw IllegalStateException("timer gone") }, "Microwave", "144/432 MHz")
var saved = false
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
saved = true
"preferences.xml"
}
assertTrue(saved)
val notice = notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone") &&
notice.message.contains("preferences.xml"), "$notice")
}
/* Save applies first, so the file holds what the window shows; the window stays open. */
@Test
fun saveAppliesBeforeItWrites() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
var intervalWhenSaved = -1
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
intervalWhenSaved = prefs.bcn_beaconIntervalInMinutesMainCat
"preferences.xml"
}
assertEquals(9, intervalWhenSaved, "the file was written before the draft was applied")
assertFalse(draft.hasPendingChanges)
assertTrue(notices.current is SettingsNotice.Info)
}
private companion object {
const val APPLY_AND_CLOSE = "Apply/Close prefs"
const val AIRSCOUT_PORT = "AirScout UDP port [9872] — reconnect after changing:"
const val PENDING_QUESTION =
"Some settings have been changed but not applied yet. Apply them first?"
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
@@ -10,13 +12,18 @@ import java.nio.file.Path
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var shortcutRefreshes = 0
var snippetRefreshes = 0
fun state() = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = { shortcutRefreshes++ },
refreshTextSnippetContextMenus = { snippetRefreshes++ },
)
@@ -35,7 +42,42 @@ class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `a new shortcut lands at the top and reaches the roster at once`() {
fun `a list edit waits for apply and rebuilds the buttons once`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
state.shortcuts.addAtTop("cq")
state.commitShortcuts()
state.shortcuts.addAtTop("tnx")
state.commitShortcuts()
assertEquals(listOf("alt"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
"the list took effect before Apply")
assertEquals(0, s.shortcutRefreshes)
s.draft.apply()
assertEquals(listOf("tnx", "cq", "alt"), s.prefs.getLst_txtShortCutBtnList().snapshot())
assertEquals(1, s.shortcutRefreshes)
}
@Test
fun `a discarded list edit leaves the roster alone`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtSnipList().setAll(listOf("73"))
val state = s.state()
state.snippets.commitAt(0, "")
state.commitSnippets()
s.draft.discard()
assertEquals(listOf("73"), s.prefs.getLst_txtSnipList().snapshot())
assertEquals(0, s.snippetRefreshes)
}
@Test
fun `a new shortcut lands at the top and reaches the roster`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
@@ -12,6 +12,12 @@ import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.runtime.mutableStateOf
import kst4contest.controller.On4KstConnectionState
@@ -19,6 +25,7 @@ import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.feed.ConnectionStateFeed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -60,7 +67,8 @@ class StationTabScreenTest {
}
val mainApplied = mutableListOf<ChatCategory?>()
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
val state = StationTabState(prefs, { mainApplied += it }, { }, { link.value })
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { mainApplied += it }, { }, { link.value })
}
@Test
@@ -77,9 +85,11 @@ class StationTabScreenTest {
checkBeside("10 GHz").assertIsOff()
}
/* The credentials write through; every other field waits for Apply. */
@Test
fun editingTheTextFieldsWritesThroughToThePreferences() = runComposeUiTest {
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
val s = Session(preferences())
val storedHost = s.prefs.stn_on4kstServersDns
showTab { StationTab(s.state) }
type("Callsign", "DO5AMF")
@@ -88,6 +98,10 @@ class StationTabScreenTest {
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
assertEquals(storedHost, s.prefs.stn_on4kstServersDns, "the host took effect before Apply")
fieldBeside("Server host name").assertTextEquals("www.on4kst.info")
runOnIdle { s.draft.apply() }
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
fieldBeside("Callsign").assertTextEquals("DO5AMF")
}
@@ -99,28 +113,77 @@ class StationTabScreenTest {
showTab { StationTab(s.state) }
type("Server port", "23000")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort)
type("Server port", "23x")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
type("QTF", "135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
/*
* The draft is Compose state, so a staged number comes straight back to its field. The
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
* before the next key would make "135,5" impossible to type.
*/
@Test
fun aDecimalCanBeTypedKeyByKey() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val field = fieldBeside("QTF")
field.performScrollTo().performClick()
field.performTextReplacement("")
"135,5".forEach { key ->
field.performTextInput(key.toString())
waitForIdle()
}
field.assertTextEquals("135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
@Test
fun aBandCheckboxWritesThrough() = runComposeUiTest {
fun aBandCheckboxIsStagedAndApplied() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
toggle("10 GHz")
assertTrue(s.prefs.isStn_bandActive10G)
checkBeside("10 GHz").assertIsOn()
assertFalse(s.prefs.isStn_bandActive10G, "the box took effect before Apply")
toggle("144 MHz")
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertFalse(s.prefs.isStn_bandActive144)
}
/*
* In a contest the session is up. Everything but the credentials stays editable then,
* and an edit still waits for Apply.
*/
@Test
fun theDraftedFieldsCanBeEditedWhileConnected() = runComposeUiTest {
val s = Session(preferences())
s.link.value = On4KstConnectionState.ONLINE
showTab { StationTab(s.state) }
fieldBeside("Callsign").assertIsNotEnabled()
fieldBeside("Server port").assertIsEnabled()
toggle("10 GHz")
type("Server port", "23005")
assertFalse(s.prefs.isStn_bandActive10G)
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertEquals(23005, s.prefs.stn_on4kstServersPort)
}
@Test
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
val s = Session(preferences())
@@ -181,7 +244,7 @@ class StationTabScreenTest {
override fun isUiThread(): Boolean = true
}
val feed = ConnectionStateFeed(surroundings, immediately)
val state = StationTabState(preferences(), { }, { }, { surroundings.connectionState })
val state = StationTabState(preferences(), SettingsDraft(), { }, { }, { surroundings.connectionState })
showTab { StationTab(state) }
fieldBeside("Callsign").assertIsEnabled()
@@ -195,4 +258,169 @@ class StationTabScreenTest {
waitForIdle()
fieldBeside("Callsign").assertIsEnabled()
}
private val ownHeightLabel = "Own ground height ASL, metres"
private val antennaHeightLabel = "Own antenna height, metres"
/** Far down the tab: the row lookup compares on-screen bounds, so bring it into view. */
private fun androidx.compose.ui.test.ComposeUiTest.scrollToOwnHeight() {
onNodeWithText(ownHeightLabel).performScrollTo()
waitForIdle()
}
/** Types [keys] one by one into the cleared own-height field, as an operator would. */
private fun androidx.compose.ui.test.ComposeUiTest.typeKeysIntoOwnHeight(keys: String) {
val field = fieldBeside(ownHeightLabel)
field.performClick()
field.performTextClearance()
keys.forEach { field.performTextInput(it.toString()) }
waitForIdle()
}
/** Leaves the own-height field by clicking the antenna height field above it. */
private fun androidx.compose.ui.test.ComposeUiTest.leaveOwnHeight() {
fieldBeside(antennaHeightLabel).performClick()
waitForIdle()
}
/*
* Typed key by key, nothing is staged until the field is left, nothing reaches the
* preferences before Apply, and the typed text is never replaced by the value read back
* ("2" did turn into "2.0" while typing "250").
*/
@Test
fun theOwnHeightIsTypedKeyByKeyAndCommittedOnLeavingTheField() = runComposeUiTest {
val s = Session(preferences())
val refusals = mutableListOf<String>()
showTab { StationTab(s.state) { refusals += it } }
scrollToOwnHeight()
fieldBeside(ownHeightLabel).assertTextEquals("")
typeKeysIntoOwnHeight("250")
fieldBeside(ownHeightLabel).assertTextEquals("250")
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "staged before the field was left")
leaveOwnHeight()
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
typeKeysIntoOwnHeight("12,5")
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
leaveOwnHeight()
assertEquals(12.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "the height took effect before Apply")
runOnIdle { s.draft.apply() }
waitForIdle()
assertEquals(12.5, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
assertEquals(listOf<String>(), refusals)
}
/*
* After Enter the field keeps the operator's draft while the stored value reads back
* differently ("250" against "250.0"). A recomposition of the tab must not swap the
* draft for the stored text and drop what was typed since. The tab is forced to
* recompose down to the field: the refusal callback captures a counter, so a new
* counter value hands the path-analysis section a new lambda and it re-reads the
* stored text. A connection change, the tab's own state, is driven as well.
*/
@Test
fun aRecompositionAfterACommitKeepsTheKeysTypedSince() = runComposeUiTest {
val s = Session(preferences())
val tick = mutableStateOf(0)
val refusals = mutableListOf<String>()
showTab {
val seen = tick.value
StationTab(s.state) { refusals += "$it ($seen)" }
}
scrollToOwnHeight()
typeKeysIntoOwnHeight("25")
fieldBeside(ownHeightLabel).performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals(25.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).performTextInput("0")
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
runOnIdle { tick.value++ }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("250")
fieldBeside(ownHeightLabel).performTextInput("1")
waitForIdle()
runOnIdle { tick.value++ }
waitForIdle()
fieldBeside(ownHeightLabel).assertTextEquals("2501")
leaveOwnHeight()
assertEquals(2501.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertEquals(listOf<String>(), refusals)
// Applying changes the stored spelling to "2501.0"; the field must keep "2501".
runOnIdle { s.draft.apply() }
waitForIdle()
assertEquals(2501.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
fieldBeside(ownHeightLabel).assertTextEquals("2501")
}
@Test
fun anOutOfRangeOwnHeightIsRefusedAndFallsBackToTheStoredValue() = runComposeUiTest {
val s = Session(preferences())
val refusals = mutableListOf<String>()
showTab { StationTab(s.state) { refusals += it } }
scrollToOwnHeight()
typeAndCommit(ownHeightLabel, "250")
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
typeKeysIntoOwnHeight("9500")
leaveOwnHeight()
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "an out-of-range height was written")
assertEquals(1, refusals.size, "the refusal was not reported")
assertTrue(refusals.single().contains("9500"))
fieldBeside(ownHeightLabel).assertTextEquals("250")
}
@Test
fun clearingTheOwnHeightHandsItBackToTheElevationModel() = runComposeUiTest {
val s = Session(preferences())
s.prefs.stn_pathAnalysisOwnHeightAslMeters = 312.0
showTab { StationTab(s.state) }
scrollToOwnHeight()
fieldBeside(ownHeightLabel).assertTextEquals("312.0")
typeAndCommit(ownHeightLabel, "")
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "clearing did not hand back to the model")
assertEquals(312.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "cleared before Apply")
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN())
}
@Test
fun theHighestPointCheckboxIsOnAndTakesEffectOnApply() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val label = "Target station on the highest point of its locator square"
checkBeside(label).assertIsOn()
toggle(label)
checkBeside(label).assertIsOff()
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare, "the switch took effect before Apply")
runOnIdle { s.draft.apply() }
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare)
}
}
@@ -3,7 +3,10 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
@@ -16,13 +19,106 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val secondRotatorCalls = mutableListOf<Unit>()
val draft = SettingsDraft()
val state = StationTabState(
prefs,
draft,
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
applySecondRotator = { secondRotatorCalls.add(Unit) },
)
}
@Test
fun `rotator 2 settings restart it once per apply however many changed`() {
val s = Session()
s.state.stn_pstRotator2Enabled = true
s.state.stn_pstRotator2Host = "10.0.0.7"
s.state.stn_pstRotator2Port = 12020
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(12020, s.prefs.getStn_pstRotator2Port())
assertEquals(1, s.secondRotatorCalls.size)
}
@Test
fun `rotator 2 on the port of rotator 1 is refused and stays in the draft`() {
val s = Session()
s.state.stn_pstRotatorPort = 12040
s.state.stn_pstRotator2Port = 12040
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size)
assertTrue(outcome.failedWrites.single().contains("12040"), outcome.failedWrites.toString())
assertEquals(12010, s.prefs.getStn_pstRotator2Port())
assertEquals(12040, s.state.stn_pstRotator2Port, "the refused value is still shown")
}
@Test
fun `rotator 1 onto the port of rotator 2 is refused while SO2R and rotator 2 are on`() {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, outcome.failedWrites.toString())
assertTrue(outcome.failedWrites.single().contains("12010"), outcome.failedWrites.toString())
assertNotEquals(12010, s.prefs.getStn_pstRotatorPort())
}
@Test
fun `rotator 1 onto the port of rotator 2 is accepted without SO2R`() {
val s = Session()
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(12010, s.prefs.getStn_pstRotatorPort())
}
@Test
fun `rotator 1 onto the port of rotator 2 is accepted while rotator 2 is off`() {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.state.stn_pstRotatorPort = 12010
val outcome = s.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
}
@Test
fun `adjacent rotator ports are refused in both directions and distant ones accepted`() {
for (p2 in intArrayOf(12041, 12039)) {
val s = Session()
s.state.stn_pstRotatorPort = 12040
s.state.stn_pstRotator2Port = p2
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, "rotor 2 on $p2: ${outcome.failedWrites}")
}
for (p1 in intArrayOf(12011, 12009)) {
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = p1
val outcome = s.draft.apply()
assertEquals(1, outcome.failedWrites.size, "rotor 1 on $p1: ${outcome.failedWrites}")
}
val s = Session()
s.prefs.setTrxSynch_so2rEnabled(true)
s.prefs.setStn_pstRotator2Enabled(true)
s.state.stn_pstRotatorPort = 12030
assertTrue(s.draft.apply().isClean)
}
@Test
fun `reading comes straight from the preferences`() {
val s = Session()
@@ -34,18 +130,55 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
fun `a station setting waits in the draft until it is applied`() {
val s = Session()
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_on4kstServersPort = 23002
assertFalse(s.prefs.isStn_pstRotatorEnabled(), "an edit took effect before Apply")
assertNotEquals(23002, s.prefs.getStn_on4kstServersPort())
assertTrue(s.state.stn_pstRotatorEnabled, "the tab shows the edited value")
assertEquals(135.0, s.state.stn_qtfDefault, 0.001)
s.draft.apply()
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
}
@Test
fun `a discarded station setting leaves the preferences alone`() {
val s = Session()
s.prefs.setStn_bandActive144(true)
s.state.stn_bandActive144 = false
s.state.stn_bandActive432 = true
s.draft.discard()
assertTrue(s.prefs.isStn_bandActive144())
assertFalse(s.prefs.isStn_bandActive432())
assertTrue(s.state.stn_bandActive144, "after discarding the tab shows the stored value")
}
/*
* The credentials stay immediate: they are locked while a session exists, and without
* one Connect must use what the tab shows.
*/
@Test
fun `the login credentials still write through at once`() {
val s = Session()
s.state.stn_loginCallSign = "DO5AMF"
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_loginLocatorMainCat = "JO51IJ"
s.state.stn_loginPassword = "secret"
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals("JO51IJ", s.prefs.getStn_loginLocatorMainCat())
assertEquals("secret", s.prefs.getStn_loginPassword())
assertFalse(s.draft.hasPendingChanges, "a credential went into the draft")
}
@Test
@@ -54,6 +187,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
s.state.stn_bandActive144 = true
s.state.stn_bandActive432 = false
s.draft.apply()
assertTrue(s.prefs.isStn_bandActive144())
assertEquals(false, s.prefs.isStn_bandActive432())
@@ -83,6 +217,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(s.prefs.getLoginChatCategorySecond(),
"the JavaFX window wrote null for 'no second chat'")
assertTrue(s.secondWasApplied, "the running session must learn about it too")
assertFalse(s.draft.hasPendingChanges, "a chat category went into the draft")
}
@Test
@@ -90,11 +225,96 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
// next to the category pickers, and it decides what the Connect button does.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = StationTabState(prefs, { }, { })
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { }, { })
state.loginToSecondChatEnabled = true
draft.apply()
assertTrue(prefs.isLoginToSecondChatEnabled())
assertEquals(true, state.loginToSecondChatEnabled)
}
@Test
fun `the own height above sea level reads empty while the elevation model decides`() {
val s = Session()
assertEquals("", s.state.stn_pathAnalysisOwnHeightAslText)
assertNull(s.state.commitOwnHeightAsl("312,5"))
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "a commit took effect before Apply")
assertEquals(312.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText, "the field keeps the committed spelling")
s.draft.apply()
assertEquals(312.5, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText)
assertNull(s.state.commitOwnHeightAsl(" "))
s.draft.apply()
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "clearing hands it back to the model")
}
@Test
fun `a discarded own height shows the stored value again`() {
val s = Session()
s.prefs.setStn_pathAnalysisOwnHeightAslMeters(400.0)
assertNull(s.state.commitOwnHeightAsl("250"))
assertEquals("250", s.state.stn_pathAnalysisOwnHeightAslText)
s.draft.discard()
assertEquals("400.0", s.state.stn_pathAnalysisOwnHeightAslText, "the discarded spelling is still shown")
assertEquals(400.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
}
@Test
fun `an own height that does not parse or is out of range is not staged`() {
val s = Session()
assertNull(s.state.commitOwnHeightAsl("250"))
listOf("25x", "9500", "-600", "NaN").forEach { entry ->
val refusal = s.state.commitOwnHeightAsl(entry)
assertTrue(refusal != null && refusal.contains(entry), "no refusal naming \"$entry\": $refusal")
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "\"$entry\" was staged")
}
s.draft.apply()
assertEquals(250.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
}
@Test
fun `an own height draft is unchanged when it names the stored number`() {
assertTrue(StationTabState.isOwnHeightAslUnchanged("250", "250.0"))
assertTrue(StationTabState.isOwnHeightAslUnchanged("250,0", "250.0"))
assertTrue(StationTabState.isOwnHeightAslUnchanged(" ", ""))
assertFalse(StationTabState.isOwnHeightAslUnchanged("", "250.0"))
assertFalse(StationTabState.isOwnHeightAslUnchanged("250", ""))
assertFalse(StationTabState.isOwnHeightAslUnchanged("12.5", "12.0"))
assertFalse(StationTabState.isOwnHeightAslUnchanged("9500", "250.0"), "an unparsable draft must be committed and refused")
}
@Test
fun `parsing the own height`() {
assertTrue(StationTabState.parseOwnHeightAsl("")!!.isNaN())
assertEquals(-430.0, StationTabState.parseOwnHeightAsl("-430"))
assertEquals(8848.0, StationTabState.parseOwnHeightAsl(" 8848 "))
assertNull(StationTabState.parseOwnHeightAsl("9000.5"))
assertNull(StationTabState.parseOwnHeightAsl("-"))
assertNull(StationTabState.parseOwnHeightAsl("NaN"))
}
@Test
fun `the highest point switch waits in the draft until it is applied`() {
val s = Session()
assertTrue(s.state.stn_pathAnalysisDxHighestPointInSquare, "on by default")
s.state.stn_pathAnalysisDxHighestPointInSquare = false
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare(), "the switch took effect before Apply")
assertFalse(s.state.stn_pathAnalysisDxHighestPointInSquare)
s.draft.apply()
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare())
}
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The corrections made once before the settings window is built, outside any draft. */
class StoredSettingsRepairTest {
@TempDir
lateinit var directory: Path
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
@Test
fun `an unsupported fallback band prefix is written back as 144`() {
val prefs = preferences()
prefs.setNotify_optionalFrequencyPrefix("4711")
repairStoredSettings(prefs) { null }
assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
}
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("192.168.178.255", prefs.getLogsynch_wintestNetworkBroadcastAddress(),
"the JavaFX window auto-detected the subnet broadcast while the stored "
+ "value was still the catch-all default")
}
@Test
fun `a broadcast address the operator chose is never overwritten`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("10.0.0.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { throw IllegalStateException("no interface") }
assertEquals("255.255.255.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -46,7 +47,11 @@ class SynchTabsScreenTest {
}
var restarts = 0
var stops = 0
val state = LogSynchTabState(prefs, { restarts++ }, { stops++ }, { detected })
val state = run {
// What the settings window does once before it builds the tabs.
repairStoredSettings(prefs) { detected }
LogSynchTabState(prefs, ImmediateDraft(), { restarts++ }, { stops++ })
}
}
@Test
@@ -63,7 +68,7 @@ class SynchTabsScreenTest {
}
@Test
fun logSynchWritesThroughAndStartsTheWinTestListener() = runComposeUiTest {
fun logSynchStoresItsFieldsAndStartsTheWinTestListener() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
@@ -97,10 +102,10 @@ class SynchTabsScreenTest {
// ------------------------------------------------------------------ TRX synch
@Test
fun trxSynchWritesThroughAndSwitchesTheQrgFollower() = runComposeUiTest {
fun trxSynchStoresItsFieldsAndSwitchesTheQrgFollower() = runComposeUiTest {
val prefs = preferences()
val follower = mutableListOf<Boolean>()
val state = TrxSynchTabState(prefs) { follower += it }
val state = TrxSynchTabState(prefs, ImmediateDraft()) { follower += it }
showTab { TrxSynchTab(state) }
onNodeWithText("Receive UCXLog TRX info").assertExists()
@@ -123,7 +128,7 @@ class SynchTabsScreenTest {
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
// The link defaults to on; start from off so a tick is a visible change.
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
val state = AirscoutTabState(prefs)
val state = AirscoutTabState(prefs, ImmediateDraft())
showTab { AirscoutTab(state) }
onNodeWithText("AirScout UDP communication").assertExists()
@@ -1,6 +1,7 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -13,7 +14,26 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
val draft = SettingsDraft()
val secondRotatorCalls = mutableListOf<Unit>()
val state by lazy {
TrxSynchTabState(prefs, draft, applySecondRotator = { secondRotatorCalls.add(Unit) }) { followerCalls.add(it) }
}
}
@Test
fun `the SO2R switch and the second Win-Test station wait for apply and restart rotator 2 once`() {
val s = Session()
s.state.trxSynch_so2rEnabled = true
s.state.logsynch_wintestNetworkStationNameOfWintestClient2 = " STN2 "
assertFalse(s.prefs.isTrxSynch_so2rEnabled())
s.draft.apply()
assertTrue(s.prefs.isTrxSynch_so2rEnabled())
assertEquals("STN2", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2())
assertEquals(1, s.secondRotatorCalls.size)
}
@Test
@@ -31,45 +51,66 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
fun `an edit waits for apply`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = true
s.state.logsynch_wintestUsePassQrg = true
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled(), "an edit took effect before Apply")
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled, "the tab shows the edit")
assertTrue(s.followerCalls.isEmpty(), "the follower moved before Apply")
s.draft.apply()
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
}
@Test
fun `a discarded edit changes nothing`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.draft.discard()
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.followerCalls.isEmpty())
}
@Test
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
// Empty means "accept all" — the JavaFX label said so explicitly.
val s = Session()
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
s.draft.apply()
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
s.draft.apply()
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
}
@Test
fun `the own-QRG follower follows whether any source is enabled`() {
fun `the own-QRG follower follows whether any source is enabled, once per apply`() {
val s = Session()
// Both sources in one apply: one call, with the finished state.
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
assertEquals(listOf(true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = true
assertEquals(listOf(true, true), s.followerCalls)
// One source off is not all sources off: the follower must stay attached.
s.state.logsynch_wintestQrgSyncEnabled = true
s.draft.apply()
s.state.trxSynch_ucxLogUDPListenerEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true, false), s.followerCalls)
}
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.StationColumns
@@ -37,9 +38,10 @@ class RemainingTextsFollowTheLanguageTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, store)
val state = GuiOptionsTabState(prefs, SettingsDraft(), store)
val beacon = BeaconTabState(
prefs,
SettingsDraft(),
validateBeaconTemplate = {},
restartBeaconTimer = {},
mainCategoryName = "Microwave",
@@ -62,8 +62,10 @@ setStn_on4kstServersPort
setStn_pathAnalysisDefaultTargetAntennaGainDbi
setStn_pathAnalysisDefaultTargetTxPowerWatts
setStn_pathAnalysisDemRootDirectory
setStn_pathAnalysisDxHighestPointInSquare
setStn_pathAnalysisOwnAntennaGainDbi
setStn_pathAnalysisOwnAntennaHeightMeters
setStn_pathAnalysisOwnHeightAslMeters
setStn_pathAnalysisOwnTxPowerWatts
setStn_pstRotatorEnabled
setStn_pstRotatorHost
@@ -35,7 +35,6 @@ import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture;
import kst4contest.observe.SimpleValue;
@@ -48,7 +47,7 @@ import kst4contest.observe.SimpleValue;
* <b>SINGLETON</b>
*
*/
public class ChatController implements ThreadStatusCallback, PstRotatorEventListener {
public class ChatController implements ThreadStatusCallback {
/**
* Chat selection ? 50/70 MHz..............1 144/432 MHz............2
@@ -69,7 +68,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private volatile PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
/*
@@ -86,14 +84,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return thread;
});
private ScheduledFuture<?> pendingRotatorRetry;
/*
* Updated directly by the PSTRotator receiver thread. This avoids reading
* a JavaFX property from the command scheduler.
*/
private volatile double lastReportedRotatorAzimuth = Double.NaN;
private StatusUpdateListener statusListener; //update info interface for the threads
@@ -311,158 +301,108 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void initRotor() {
rotatorClient = new PstRotatorClient(
chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(),
this,
this
);
rotator1.start();
}
rotatorClient.start();
/** Kept for existing references; the value lives in RotatorControl. */
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = RotatorControl.REACHED_TOLERANCE_DEGREES;
/** Rotator of radio 1; also the only one without SO2R. */
private final RotatorControl rotator1 = new RotatorControl(
"PSTRotator",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(), "PSTRotator"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF().set(azimuth)));
/** Rotator of radio 2; only with SO2R and its own switch. */
private final RotatorControl rotator2 = new RotatorControl(
"PSTRotator2",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotator2Host(),
chatPreferences.getStn_pstRotator2Port(), "PSTRotator2"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF2().set(azimuth)));
/** Test seam: the rotator of radio 2. */
RotatorControl secondRotator() {
return rotator2;
}
static boolean secondRotatorWanted(ChatPreferences prefs) {
return prefs.isTrxSynch_so2rEnabled() && prefs.isStn_pstRotator2Enabled();
}
/** Turns the antenna of [slot]; radio 2 only does something while its rotator runs. */
public void rotateTo(RadioSlot slot, double azimuth) {
(slot == RadioSlot.SECOND ? rotator2 : rotator1).rotateTo(azimuth);
}
/*
* Not isDisconnected(): that flag defaults to false before the first connect,
* so it would claim a session where none exists.
*/
private boolean isSessionRunning() {
return on4KstConnectionManager != null && sessionRunning(on4KstConnectionManager.getState());
}
static boolean sessionRunning(On4KstConnectionState state) {
return state != null
&& state != On4KstConnectionState.DISCONNECTED
&& state != On4KstConnectionState.STOPPING;
}
/**
* How close the reported position must be to count as "reached". The target carries a
* decimal, while many rotor controllers report whole degrees: with 0.5 a target of 145.7
* and a report of 145 would count as missed and could trigger the retry.
* Follows the settings for rotator 2 after Apply: always stopped first, so a changed
* host or port is bound anew, and started again only while a session runs. Without it,
* QTF2 shows nothing rather than a stale heading.
*/
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = 1.0;
public void reconfigureSecondRotator() {
rotator2.stop();
if (secondRotatorWanted(chatPreferences)) {
if (isSessionRunning()) {
rotator2.start();
}
} else {
runOnUi(() -> chatPreferences.getActualQTF2().set(Double.NaN));
}
}
/**
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
*
* <p>Some SPID configurations occasionally ignore the first azimuth
* command. KST4Contest therefore checks the latest reported position after
* two seconds. If no movement was reported and the target has not already
* been reached, the original compatibility sequence is sent again.</p>
* Opens one PSTRotator connection whose position reports go to [control]. The
* returned link is what RotatorControl drives; stopping it also stops the polling.
*/
private RotatorControl.Link startPstRotatorClient(RotatorControl control, String host, int port, String threadName) {
PstRotatorClient client = new PstRotatorClient(host, port, threadName, new PstRotatorEventListener() {
@Override public void onAzimuthUpdate(double azimuth) { control.onAzimuthUpdate(azimuth); }
@Override public void onElevationUpdate(double elevation) { /* not used */ }
@Override public void onModeUpdate(boolean isTracking) { /* not used */ }
@Override public void onMessageReceived(String raw) { /* not used */ }
}, this);
client.start();
return new RotatorControl.Link() {
@Override public void setTrackingMode(boolean on) { client.setTrackingMode(on); }
@Override public void setAzimuth(double azimuth) { client.setAzimuth(azimuth); }
@Override public void stopRotation() { client.stopRotor(); }
@Override public void stop() { client.stop(); }
};
}
/* Runs on the UI thread, directly when already there. */
private void runOnUi(Runnable update) {
if (uiDispatcher.isUiThread()) {
update.run();
} else {
uiDispatcher.runOnUi(update);
}
}
/**
* Sends a new azimuth to PSTRotator without blocking the UI thread; see RotatorControl.
*
* @param azimuth required antenna azimuth in degrees
*/
public void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid PSTRotator azimuth: {0}",
azimuth
);
return;
}
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
LOGGER.log(
Level.WARNING,
"Cannot rotate antenna to {0} degrees: "
+ "PSTRotator integration is not active.",
azimuth
);
return;
}
double targetAzimuth = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedRotatorAzimuth;
activeClient.setTrackingMode(false);
LOGGER.log(
Level.INFO,
"Sending PSTRotator azimuth requested by the operator: {0}",
targetAzimuth
);
activeClient.setAzimuth(targetAzimuth);
ScheduledFuture<?> previousRetry = pendingRotatorRetry;
if (previousRetry != null) {
previousRetry.cancel(false);
}
pendingRotatorRetry = rotatorCommandScheduler.schedule(
() -> retryRotatorCommandIfRequired(
positionBeforeCommand,
targetAzimuth
),
2,
TimeUnit.SECONDS
);
}
/**
* Performs the delayed SPID compatibility check.
*
* <p>No retry is required when the requested position has already been
* reached or when PSTRotator reported movement after the original command.
* Missing feedback is treated like an unchanged position.</p>
*/
private void retryRotatorCommandIfRequired(
double positionBeforeCommand,
double targetAzimuth
) {
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
return;
}
double currentAzimuth = lastReportedRotatorAzimuth;
if (Double.isFinite(currentAzimuth)
&& angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) {
LOGGER.log(
Level.FINE,
"PSTRotator reached the requested azimuth without retry: {0}",
targetAzimuth
);
return;
}
boolean noPositionFeedback =
!Double.isFinite(currentAzimuth);
boolean positionUnchanged =
Double.isFinite(positionBeforeCommand)
&& Double.isFinite(currentAzimuth)
&& angularDistance(
positionBeforeCommand,
currentAzimuth
) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(
Level.FINE,
"PSTRotator reported movement towards {0}; no retry required.",
targetAzimuth
);
return;
}
LOGGER.log(
Level.WARNING,
"PSTRotator reported no movement after the command for {0} degrees; "
+ "sending the SPID compatibility retry.",
targetAzimuth
);
activeClient.setAzimuth(0.0);
activeClient.setAzimuth(targetAzimuth);
}
/**
* Returns the smallest angular distance between two azimuth values.
*/
private static double angularDistance(double first, double second) {
double difference = Math.abs(
normalizeAzimuth(first) - normalizeAzimuth(second)
);
return Math.min(difference, 360.0 - difference);
}
/**
* Normalises an azimuth to the range from 0 inclusive to 360 exclusive.
*/
private static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
rotator1.rotateTo(azimuth);
}
/**
@@ -568,69 +508,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void stopRotator() {
ScheduledFuture<?> pendingRetry = pendingRotatorRetry;
if (pendingRetry != null) {
pendingRetry.cancel(false);
pendingRotatorRetry = null;
}
PstRotatorClient activeClient = rotatorClient;
rotatorClient = null;
lastReportedRotatorAzimuth = Double.NaN;
if (activeClient != null) {
activeClient.stop();
}
rotator1.stop();
}
@Override
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid azimuth reported by PSTRotator: {0}",
azimuth
);
return;
}
double normalizedAzimuth = normalizeAzimuth(azimuth);
lastReportedRotatorAzimuth = normalizedAzimuth;
/*
* The callback runs in the PSTRotator receiver thread. JavaFX properties
* must be updated on the JavaFX Application Thread.
*/
Runnable fxUpdate = () ->
chatPreferences.getActualQTF().set(normalizedAzimuth);
if (uiDispatcher.isUiThread()) {
fxUpdate.run();
} else {
uiDispatcher.runOnUi(fxUpdate);
}
}
@Override
public void onElevationUpdate(double elevation) {
// System.out.println("Neue Elevation: " + elevation);
//not used in first version
}
@Override
public void onModeUpdate(boolean isTracking) {
// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell"));
//not used in first version
}
@Override
public void onMessageReceived(String raw) {
// Logging
}
/**
* Helping
* @param targetCallSignRaw
@@ -921,8 +801,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) {
// rotatorClient.
// this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
@@ -971,8 +849,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
dbHandler.closeDBConnection();
rotatorClient.stopRotor();
rotatorClient.stop();
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
try {
@@ -1079,11 +959,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
releaseBackgroundExecutors();
}
@@ -1105,11 +984,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
@@ -3612,6 +3486,9 @@ private SimpleRoster<String>
} else {
System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client.");
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
/**
* DX cluster service running config
@@ -3791,9 +3668,12 @@ private SimpleRoster<String>
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
if (chatPreferences.isStn_pstRotatorEnabled() && !rotator1.isRunning()) {
initRotor();
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
@@ -0,0 +1,30 @@
package kst4contest.controller;
/**
* Side effects a chat frame asks for, decided without touching sound, network or model.
*
* @param playPmSound whether the private message sound ('P') is played
* @param playCwCallsign whether the sender's call is sent in CW
* @param playVoiceCallsign whether the sender's call is spoken
* @param playBell whether the voice bell ("!") is played
* @param sendVersionReply whether the version reply is sent
* @param autoAnswerText the auto answer to send, {@code null} for none; the per-remote cooldown is still checked by the executor
* @param inAngleAndRange the new in-angle flag of the sender, {@code null} to leave it unchanged
* @param playDirectionalTick whether the short directional tick is played
* @param evaluateDxClusterSpot whether a DX Cluster spot is to be evaluated
*/
record ChatMessageEffects(
boolean playPmSound,
boolean playCwCallsign,
boolean playVoiceCallsign,
boolean playBell,
boolean sendVersionReply,
String autoAnswerText,
Boolean inAngleAndRange,
boolean playDirectionalTick,
boolean evaluateDxClusterSpot) {
/** No effect at all. */
static final ChatMessageEffects NONE =
new ChatMessageEffects(false, false, false, false, false, null, null, false, false);
}
@@ -0,0 +1,13 @@
package kst4contest.controller;
/** Where an ON4KST chat message belongs, seen from the local station. */
enum ChatRoute {
/** Receiver field {@code 0}: a message to everyone. */
PUBLIC,
/** Addressed to the local login call. */
TO_ME,
/** Sent by the local station to someone else. */
FROM_ME,
/** Between two other stations. */
BETWEEN_OTHERS
}
@@ -0,0 +1,21 @@
package kst4contest.controller;
/**
* The fields of an ON4KST chat frame ({@code CH} live, {@code CR} history), as split on
* {@code |}: {@code opcode|category|epoch|senderCall|senderName|0|text|receiver|}.
*
* <p>Read exactly as the frame branches always did: no trimming and no validation, so a
* short frame fails where it failed before.</p>
*/
record InboundChatFrame(
String categoryNumber,
String epoch,
String senderCall,
String senderName,
String text,
String receiverField) {
static InboundChatFrame of(String[] fields) {
return new InboundChatFrame(fields[1], fields[2], fields[3], fields[4], fields[6], fields[7]);
}
}
@@ -694,6 +694,319 @@ public class MessageBusManagementThread extends Thread {
+ ", fieldCount=" + (fields == null ? 0 : fields.length));
}
/**
* Processes one ON4KST chat frame: {@code CH} (live) or {@code CR} (history).
*
* <p>Both share sender and receiver resolution, routing and publishing. The
* differences are kept exactly as they were in the two former branches:
* a live SERVER sender gets an empty AirScout info, a historic one does not;
* live frames detect the sender's frequency before routing, historic frames
* after publishing and the diagnostic log; historic frames never play sounds,
* answer, tick or spot. Which side effects apply is decided by
* {@link #decideChatFrameEffects}; this method only executes them.</p>
*
* <p>The fields are read in the same order as before, so a truncated frame
* fails at the same point, after the same side effects.</p>
*
* @param fields the frame split on {@code |}
* @param live {@code true} for {@code CH}, {@code false} for {@code CR}
*/
private void processChatFrame(String[] fields, boolean live) {
ChatPreferences prefs = this.client.getChatPreferences();
ChatMessage newMessageArrived = new ChatMessage();
ChatCategory chategoryForMessageAndMessageSender;
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1]));
chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory();
newMessageArrived.setMessageGeneratedTime(fields[2]);
newMessageArrived.setMessageSenderName(fields[4]);
newMessageArrived.setMessageText(fields[6]);
if (fields[3].equals("SERVER")) {
ChatMember dummy = new ChatMember();
if (live) {
dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
}
dummy.setCallSign("SERVER");
dummy.setName("Sysop");
newMessageArrived.setSender(dummy);
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1]));
dummy.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1]));
} else {
newMessageArrived.setSender(resolveInboundSender(
fields[3],
chategoryForMessageAndMessageSender,
newMessageArrived));
}
if (live) {
/*
* Detect and store the sender's QRG before any directional-opportunity
* or DX-Cluster processing. A frequency first mentioned in this directed
* message is then immediately available for the resulting spot.
*
* The parser needs only the message text, sender and preferences. The
* receiver does not have to be resolved yet.
*/
smartFrequencyExtraction(newMessageArrived, prefs);
}
// Built only now: the receiver field has always been read after the sender.
InboundChatFrame frame = InboundChatFrame.of(fields);
if (frame.receiverField().equals("0")) {
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
} else {
//message is directed to another chatmember, process as such!
newMessageArrived.setReceiver(resolveInboundReceiver(
frame.receiverField(),
chategoryForMessageAndMessageSender));
try {
ChatRoute route = classifyRoute(
frame.receiverField(),
newMessageArrived.getSender().getCallSign(),
newMessageArrived.getReceiver().getCallSign(),
prefs.getStn_loginCallSign());
DirectionalOpportunity opportunity = null;
boolean senderAlreadyInAngleAndRange = false;
if (route == ChatRoute.BETWEEN_OTHERS) {
/*
* With explicit fallback sender/receiver objects, this path may also see
* stations that already left the chat or arrived before their user-list
* entry. Their locators may be missing, so angle/range analysis and DX
* spotting are skipped while the chat message itself is still published.
*/
opportunity = classifyDirectionalOpportunity(
prefs.getStn_loginLocatorMainCat(),
newMessageArrived.getSender().getQra(),
newMessageArrived.getReceiver().getQra(),
prefs.getStn_maxQRBDefault(),
prefs.getStn_antennaBeamWidthDeg());
senderAlreadyInAngleAndRange = newMessageArrived.getSender().isInAngleAndRange();
}
// Built only where it was built before: it logs when no reply is possible.
String autoAnswerTextCandidate = null;
if (route == ChatRoute.TO_ME && live && !isAutoMessage(newMessageArrived.getMessageText())) {
boolean qrgRequested =
prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
&& matchesQrgQuestion(newMessageArrived.getMessageText(), QRG_QUESTION_TEXTS);
ChatMember incomingSender = newMessageArrived.getSender();
autoAnswerTextCandidate = buildAutoAnswerMessageText(
incomingSender == null ? null : incomingSender.getCallSign(),
// Diagnostics only: same fallback as autoAnswerCooldownKey.
newMessageArrived.getChatCategory() != null || incomingSender == null
? newMessageArrived.getChatCategory()
: incomingSender.getChatCategory(),
qrgRequested,
selectAutoAnswerQrg(
newMessageArrived.getChatCategory(),
this.client.getChatCategorySecondChat(),
prefs.getMYQRGFirstCat().get(),
prefs.getMYQRGSecondCat().get()
),
prefs.isMsgHandling_autoAnswerEnabled(),
prefs.getMessageHandling_autoAnswerTextMainCat()
);
}
ChatMessageEffects effects = decideChatFrameEffects(
route,
live,
newMessageArrived.getMessageText(),
opportunity,
senderAlreadyInAngleAndRange,
new ChatFrameSettings(
prefs.isNotify_playSimpleSounds(),
prefs.isNotify_playCWCallsignsOnRxedPMs(),
prefs.isNotify_playVoiceCallsignsOnRxedPMs(),
prefs.isNotify_dxClusterServerEnabled(),
autoAnswerTextCandidate));
if (route == ChatRoute.TO_ME) {
/**
* message is directed to me, will be put in the "to me" messagelist
*/
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
if (effects.playPmSound()) {
this.client.getPlayAudioUtils().playNoiseLauncher('P');
}
if (effects.playCwCallsign()) {
this.client.getPlayAudioUtils().playCWLauncher(" " + " " + newMessageArrived.getSender().getCallSign().toUpperCase());
}
if (effects.playVoiceCallsign()) {
this.client.getPlayAudioUtils().playVoiceLauncher( "?" + newMessageArrived.getSender().getCallSign().toUpperCase());
}
if (effects.playBell()) {
this.client.getPlayAudioUtils().playVoiceLauncher("!");
}
if (effects.sendVersionReply()) {
ChatMessage versionInfo = new ChatMessage();
ChatMember itsMe = new ChatMember();
itsMe.setCallSign(prefs.getStn_loginCallSign());
versionInfo.setSender(itsMe);
versionInfo.setReceiver(newMessageArrived.getSender());
versionInfo.setChatCategory(newMessageArrived.getChatCategory());
versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign()
+ " " + AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF");
this.client.getMessageTXBus().add(versionInfo);
}
/*
* Unified auto-answer (generic + QRG) with ping-pong guard and
* per-remote cooldown. Invalid or incomplete replies are rejected
* before the cooldown is checked or updated. A missing QRG must
* therefore not suppress a later valid reply.
*/
if (effects.autoAnswerText() != null
&& isAutoAnswerAllowedNow(newMessageArrived)) {
ChatMessage automaticAnswer = new ChatMessage();
ChatMember itsMe = new ChatMember();
itsMe.setCallSign(prefs.getStn_loginCallSign());
automaticAnswer.setSender(itsMe);
automaticAnswer.setReceiver(newMessageArrived.getSender());
automaticAnswer.setChatCategory(newMessageArrived.getChatCategory());
automaticAnswer.setMessageText(effects.autoAnswerText());
this.client.getMessageTXBus().add(automaticAnswer);
/*
* Record the cooldown only after a complete and locally
* validated reply has been placed in the transmit queue.
*/
markLocalAutoAnswerSent(newMessageArrived);
}
LOGGER.fine((live
? "Message directed to local callsign: "
: "Historic message directed to local callsign: ")
+ newMessageArrived.getReceiver().getCallSign());
} else if (route == ChatRoute.FROM_ME) {
/**
* message sent by me!
* message from me will appear in the PM window, too, with (>CALLSIGN) before
*/
String originalMessage = newMessageArrived.getMessageText();
newMessageArrived
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// if you sent the message to another station, it will be sorted in to
// the "to me message list" with modified messagetext, added rxers callsign
} else {
// Message sent from one other station to another other station.
//play only tick sound if the sender was not set directedtome before
if (effects.playDirectionalTick()) {
this.client.getPlayAudioUtils().playNoiseLauncher('-');
}
// The log statements keep their former position relative to the flag change.
if (live && opportunity == DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE) {
LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(prefs.getStn_loginLocatorMainCat())));
}
newMessageArrived.getSender().setInAngleAndRange(effects.inAngleAndRange());
if (effects.evaluateDxClusterSpot()) {
try {
ChatMember sender = newMessageArrived.getSender();
DxClusterSpotDecision spotDecision = decideDxClusterSpot(sender);
if (spotDecision == DxClusterSpotDecision.SPOT) {
/*
* The DX Cluster spot must not depend on AirScout data. A known
* frequency and a valid directional opportunity are sufficient.
* Available AP information is added only as an optional comment.
*/
this.client
.getDxClusterServer()
.broadcastSingleDXClusterEntryToLoggers(
buildDxClusterSpotEntry(sender)
);
} else if (spotDecision == DxClusterSpotDecision.ALREADY_WORKED) {
// Only reached when the worked check was the reason (issue #91).
LOGGER.fine("DX Cluster spot suppressed, station already worked on this band: "
+ sender.getCallSign());
}
} catch (Exception exception) {
LOGGER.log(Level.WARNING,
"DXCluster message-server error while processing spot for "
+ newMessageArrived.getSender().getCallSign(),
exception);
}
}
if (live && opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) {
LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(prefs.getStn_loginLocatorMainCat())));
} else if (live && opportunity == DirectionalOpportunity.MISSING_LOCATOR) {
LOGGER.info("Skipping angle/range analysis for message with missing locator: "
+ newMessageArrived.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().getCallSign());
}
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
LOGGER.log(Level.WARNING,
live
? "Referenced user disappeared while processing a message"
: "Referenced user disappeared while processing a historic message",
referenceDeletedByUserLeftChatDuringMessageprocessing);
}
}
try {
LOGGER.fine((live ? "Processed message: " : "Processed historic message: ")
+ newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
LOGGER.log(Level.WARNING,
live
? "Could not format processed-message diagnostic"
: "Could not format historic-message diagnostic",
exceptionOccured);
}
if (!live) {
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
smartFrequencyExtraction(newMessageArrived, prefs);
}
}
/**
* Processes received messages via port 23001 (improved telnet Interface)
@@ -886,353 +1199,7 @@ public class MessageBusManagementThread extends Thread {
* CH|2|1663966535|DM5M|dm5m-team|0|kst4contest.test|0|
*/
if (splittedMessageLine[0].contains(CHATCHANNELMESSAGE)) {
//experimental 1.26: multi channel messages
ChatMessage newMessageArrived = new ChatMessage();
ChatCategory chategoryForMessageAndMessageSender;
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory();
newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]);
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[3].equals("SERVER")) {
ChatMember dummy = new ChatMember();
dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
dummy.setCallSign("SERVER");
dummy.setName("Sysop");
newMessageArrived.setSender(dummy);
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
dummy.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
// System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> servers cat " + newMessageArrived.getChatCategory());
} else {
newMessageArrived.setSender(resolveInboundSender(
splittedMessageLine[3],
chategoryForMessageAndMessageSender,
newMessageArrived));
}
/*
* Detect and store the sender's QRG before any directional-opportunity
* or DX-Cluster processing. A frequency first mentioned in this directed
* message is then immediately available for the resulting spot.
*
* The parser needs only the message text, sender and preferences. The
* receiver does not have to be resolved yet.
*/
smartFrequencyExtraction(
newMessageArrived,
this.client.getChatPreferences());
// TODO: Next: get frequency infos out of name?
if (splittedMessageLine[7].equals("0")) {
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
} else {
//message is directed to another chatmember, process as such!
newMessageArrived.setReceiver(resolveInboundReceiver(
splittedMessageLine[7],
chategoryForMessageAndMessageSender));
// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase());
try {
/**
* message is directed to me, will be put in the "to me" messagelist
*/
if (newMessageArrived.getReceiver().getCallSign()
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
this.client.getPlayAudioUtils().playNoiseLauncher('P');
}
if (this.client.getChatPreferences().isNotify_playCWCallsignsOnRxedPMs()) {
this.client.getPlayAudioUtils().playCWLauncher(" " + " " + newMessageArrived.getSender().getCallSign().toUpperCase());
}
if (this.client.getChatPreferences().isNotify_playVoiceCallsignsOnRxedPMs()) {
this.client.getPlayAudioUtils().playVoiceLauncher( "?" + newMessageArrived.getSender().getCallSign().toUpperCase());
}
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
if (newMessageArrived.getMessageText().toUpperCase().contains("//BELL")) {
this.client.getPlayAudioUtils().playVoiceLauncher("!");
}
}
if (newMessageArrived.getMessageText().toUpperCase().contains("//VER")) {
ChatMessage versionInfo = new ChatMessage();
ChatMember itsMe = new ChatMember();
itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign());
versionInfo.setSender(itsMe);
versionInfo.setReceiver(newMessageArrived.getSender());
versionInfo.setChatCategory(newMessageArrived.getChatCategory());
versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign()
+ " " + AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF");
this.client.getMessageTXBus().add(versionInfo);
}
// if (this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled()) {
//
// ChatMessage automaticAnswer = new ChatMessage();
// ChatMember itsMe = new ChatMember();
// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign());
//
// automaticAnswer.setSender(itsMe);
// automaticAnswer.setReceiver(newMessageArrived.getSender());
// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " " + this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat());
//
// this.client.getMessageTXBus().add(automaticAnswer);
//
// }
/**
* auto reply/answer to QRG requests is here
*/
// if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) {
//
// for (String lookForQRGString : qrgQuestionTexts) {
// if (newMessageArrived.getMessageText().contains(lookForQRGString)) {
//
// ChatMessage automaticAnswer = new ChatMessage();
// ChatMember itsMe = new ChatMember();
// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign());
//
// automaticAnswer.setSender(itsMe);
// automaticAnswer.setReceiver(newMessageArrived.getSender());
// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get());
//
// if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) {
// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().get() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().get());
// } else {
// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get());
// }
//
// this.client.getMessageTXBus().add(automaticAnswer);
//
// }
// }
// }
// ==== Unified auto-answer (generic + QRG) with ping-pong guard
// and per-remote cooldown ====
final String incomingText = newMessageArrived.getMessageText();
// Never answer another automatically generated message.
if (!isAutoMessage(incomingText)) {
ChatPreferences autoAnswerPrefs =
this.client.getChatPreferences();
boolean qrgRequested =
autoAnswerPrefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
&& matchesQrgQuestion(incomingText, QRG_QUESTION_TEXTS);
boolean genericEnabled =
autoAnswerPrefs.isMsgHandling_autoAnswerEnabled();
ChatMember incomingSender = newMessageArrived.getSender();
String automaticAnswerText = buildAutoAnswerMessageText(
incomingSender == null ? null : incomingSender.getCallSign(),
// Diagnostics only: same fallback as autoAnswerCooldownKey.
newMessageArrived.getChatCategory() != null || incomingSender == null
? newMessageArrived.getChatCategory()
: incomingSender.getChatCategory(),
qrgRequested,
selectAutoAnswerQrg(
newMessageArrived.getChatCategory(),
this.client.getChatCategorySecondChat(),
autoAnswerPrefs.getMYQRGFirstCat().get(),
autoAnswerPrefs.getMYQRGSecondCat().get()
),
genericEnabled,
autoAnswerPrefs.getMessageHandling_autoAnswerTextMainCat()
);
/*
* Invalid or incomplete replies are rejected before the cooldown
* is checked or updated. A missing QRG must therefore not suppress
* a later valid reply.
*/
if (automaticAnswerText != null
&& isAutoAnswerAllowedNow(newMessageArrived)) {
ChatMessage automaticAnswer = new ChatMessage();
ChatMember itsMe = new ChatMember();
itsMe.setCallSign(
this.client.getChatPreferences()
.getStn_loginCallSign()
);
automaticAnswer.setSender(itsMe);
automaticAnswer.setReceiver(
newMessageArrived.getSender()
);
automaticAnswer.setChatCategory(
newMessageArrived.getChatCategory()
);
automaticAnswer.setMessageText(automaticAnswerText);
this.client.getMessageTXBus().add(automaticAnswer);
/*
* Record the cooldown only after a complete and locally
* validated reply has been placed in the transmit queue.
*/
markLocalAutoAnswerSent(newMessageArrived);
}
}
LOGGER.fine("Message directed to local callsign: "
+ newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
/**
* message sent by me!
* message from me will appear in the PM window, too, with (>CALLSIGN) before
*/
String originalMessage = newMessageArrived.getMessageText();
newMessageArrived
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// if you sent the message to another station, it will be sorted in to
// the "to me message list" with modified messagetext, added rxers callsign
} else {
/*
* Message sent from one other station to another other station.
*
* The old code reached this point only with members from the visible user list.
* With explicit fallback sender/receiver objects, this path may also see
* stations that already left the chat or arrived before their user-list entry.
* In that case locators may be missing, so angle/range analysis and DX spotting
* must be skipped while the chat message itself is still published.
*/
DirectionalOpportunity opportunity = classifyDirectionalOpportunity(
client.getChatPreferences().getStn_loginLocatorMainCat(),
newMessageArrived.getSender().getQra(),
newMessageArrived.getReceiver().getQra(),
client.getChatPreferences().getStn_maxQRBDefault(),
client.getChatPreferences().getStn_antennaBeamWidthDeg());
if (opportunity != DirectionalOpportunity.MISSING_LOCATOR) {
if (opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) {
//play only tick sound if the sender was not set directedtome before
if (shouldPlayDirectionalTick(
this.client.getChatPreferences().isNotify_playSimpleSounds(),
newMessageArrived.getSender().isInAngleAndRange())) {
this.client.getPlayAudioUtils().playNoiseLauncher('-');
}
newMessageArrived.getSender().setInAngleAndRange(true);
if (client.getChatPreferences().isNotify_dxClusterServerEnabled()) {
try {
ChatMember sender = newMessageArrived.getSender();
DxClusterSpotDecision spotDecision = decideDxClusterSpot(sender);
if (spotDecision == DxClusterSpotDecision.SPOT) {
/*
* The DX Cluster spot must not depend on AirScout data. A known
* frequency and a valid directional opportunity are sufficient.
* Available AP information is added only as an optional comment.
*/
this.client
.getDxClusterServer()
.broadcastSingleDXClusterEntryToLoggers(
buildDxClusterSpotEntry(sender)
);
} else if (spotDecision == DxClusterSpotDecision.ALREADY_WORKED) {
// Only reached when the worked check was the reason (issue #91).
LOGGER.fine("DX Cluster spot suppressed, station already worked on this band: "
+ sender.getCallSign());
}
} catch (Exception exception) {
LOGGER.log(Level.WARNING,
"DXCluster message-server error while processing spot for "
+ newMessageArrived.getSender().getCallSign(),
exception);
// exception.printStackTrace();
}
}
LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
} else {
LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " +
new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) +
" ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
LOGGER.info("Skipping angle/range analysis for message with missing locator: "
+ newMessageArrived.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().getCallSign());
}
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
LOGGER.log(Level.WARNING,
"Referenced user disappeared while processing a message",
referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
// newMessageArrived.setReceiver(this.client.getChatMemberTable().get(splittedMessageLine[7])); // set sender
// to the
// member of
// before
// initialized
// list
}
try {
LOGGER.fine("Processed message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
LOGGER.log(Level.WARNING,
"Could not format processed-message diagnostic",
exceptionOccured);
}
// TODO: Next: get frequency infos out of name?
processChatFrame(splittedMessageLine, true);
} else
/**
@@ -1462,137 +1429,7 @@ public class MessageBusManagementThread extends Thread {
* ^^^^^^^^^^time ...
*/
if (splittedMessageLine[0].contains(SERVERMESSAGEHISTORIC)) {
ChatMessage newMessageArrived = new ChatMessage();
ChatCategory chategoryForMessageAndMessageSender;
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory();
newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]);
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[3].equals("SERVER")) {
ChatMember dummy = new ChatMember();
dummy.setCallSign("SERVER");
dummy.setName("Sysop");
newMessageArrived.setSender(dummy);
newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
dummy.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
// System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> servers cat " + newMessageArrived.getChatCategory());
} else {
newMessageArrived.setSender(resolveInboundSender(
splittedMessageLine[3],
chategoryForMessageAndMessageSender,
newMessageArrived));
}
if (splittedMessageLine[7].equals("0")) {
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
} else {
//message is directed to another chatmember, process as such!
newMessageArrived.setReceiver(resolveInboundReceiver(
splittedMessageLine[7],
chategoryForMessageAndMessageSender));
// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase());
try {
/**
* message is directed to me, will be put in the "to me" messagelist
*/
if (newMessageArrived.getReceiver().getCallSign()
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
LOGGER.fine("Historic message directed to local callsign: "
+ newMessageArrived.getReceiver().getCallSign());
} else if (newMessageArrived.getSender().getCallSign().toUpperCase()
.equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
/**
* message sent by me!
* message from me will appear in the PM window, too, with (>CALLSIGN) before
*/
String originalMessage = newMessageArrived.getMessageText();
newMessageArrived
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// if you sent the message to another station, it will be sorted in to
// the "to me message list" with modified messagetext, added rxers callsign
} else {
//message sent to other user
// message sent from one other station to another other station
DirectionalOpportunity opportunity = classifyDirectionalOpportunity(
client.getChatPreferences().getStn_loginLocatorMainCat(),
newMessageArrived.getSender().getQra(),
newMessageArrived.getReceiver().getQra(),
client.getChatPreferences().getStn_maxQRBDefault(),
client.getChatPreferences().getStn_antennaBeamWidthDeg());
if (opportunity != DirectionalOpportunity.MISSING_LOCATOR) {
if (opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) {
newMessageArrived.getSender().setInAngleAndRange(true);
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
/*
* Historic fallback senders/receivers may not have locators.
* The message is still valid and should be displayed, but angle/range
* analysis cannot be calculated without both locators.
*/
newMessageArrived.getSender().setInAngleAndRange(false);
}
this.client.publishChatMessage(newMessageArrived);
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
LOGGER.log(Level.WARNING,
"Referenced user disappeared while processing a historic message",
referenceDeletedByUserLeftChatDuringMessageprocessing);
}
// sdtout to me message-List
}
try {
LOGGER.fine("Processed historic message: " + newMessageArrived.getChatCategory().getCategoryNumber()
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
LOGGER.log(Level.WARNING,
"Could not format historic-message diagnostic",
exceptionOccured);
}
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
smartFrequencyExtraction(newMessageArrived, this.client.getChatPreferences());
processChatFrame(splittedMessageLine, false);
} else
/**
@@ -1853,6 +1690,30 @@ public class MessageBusManagementThread extends Thread {
: DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE;
}
/**
* Where a chat message belongs. Keeps the long-standing asymmetry on purpose: the
* receiver is compared with the login call case-sensitively, the sender
* case-insensitively. Changing either changes which messages are answered or marked.
*
* @param receiverField the raw receiver field; {@code 0} means public
* @param senderCall the sender's call
* @param receiverCall the resolved receiver's call, unused for public messages
* @param ownCall the raw login call from the preferences
* @return the route of the message
*/
static ChatRoute classifyRoute(String receiverField, String senderCall, String receiverCall, String ownCall) {
if ("0".equals(receiverField)) {
return ChatRoute.PUBLIC;
}
if (receiverCall.equals(ownCall)) {
return ChatRoute.TO_ME;
}
if (senderCall.toUpperCase().equals(ownCall.toUpperCase())) {
return ChatRoute.FROM_ME;
}
return ChatRoute.BETWEEN_OTHERS;
}
/**
* Returns whether the short tick sound for a new directional opportunity is
* played: only with simple sounds enabled and only on the transition into
@@ -1869,6 +1730,78 @@ public class MessageBusManagementThread extends Thread {
return simpleSoundsEnabled && !senderAlreadyInAngleAndRange;
}
/**
* Settings the chat frame decision depends on.
*
* @param simpleSounds whether simple notification sounds are enabled
* @param cwOnPm whether the sender's call is sent in CW for private messages
* @param voiceOnPm whether the sender's call is spoken for private messages
* @param dxClusterServerEnabled whether the DX Cluster server is enabled
* @param autoAnswerTextCandidate the auto answer text built for this message, or {@code null}
*/
record ChatFrameSettings(
boolean simpleSounds,
boolean cwOnPm,
boolean voiceOnPm,
boolean dxClusterServerEnabled,
String autoAnswerTextCandidate
) {
}
/**
* Decides the side effects of a chat frame.
* <ul>
* <li>Public and own messages: none.</li>
* <li>Live message to me: PM sound, CW and voice call per settings, bell for {@code //BELL}
* (with simple sounds), version reply for {@code //VER}, auto answer unless the text is automatic.</li>
* <li>History message to me: none.</li>
* <li>Between others: the sender's flag follows the opportunity; live and in angle and range
* additionally the tick and the DX Cluster spot evaluation.</li>
* </ul>
*
* @param route the route of the message
* @param live {@code true} for a live frame, {@code false} for history
* @param messageText the message text
* @param opportunity the directional opportunity, only used between others
* @param senderAlreadyInAngleAndRange the sender's flag before this message
* @param settings the settings the decision depends on
* @return the effects to execute
*/
static ChatMessageEffects decideChatFrameEffects(
ChatRoute route,
boolean live,
String messageText,
DirectionalOpportunity opportunity,
boolean senderAlreadyInAngleAndRange,
ChatFrameSettings settings
) {
switch (route) {
case TO_ME:
if (!live) {
return ChatMessageEffects.NONE;
}
String upper = messageText.toUpperCase();
return new ChatMessageEffects(
settings.simpleSounds(),
settings.cwOnPm(),
settings.voiceOnPm(),
settings.simpleSounds() && upper.contains("//BELL"),
upper.contains("//VER"),
isAutoMessage(messageText) ? null : settings.autoAnswerTextCandidate(),
null,
false,
false);
case BETWEEN_OTHERS:
boolean inRange = opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE;
boolean tick = live && inRange
&& shouldPlayDirectionalTick(settings.simpleSounds(), senderAlreadyInAngleAndRange);
boolean spot = live && inRange && settings.dxClusterServerEnabled();
return new ChatMessageEffects(false, false, false, false, false, null, inRange, tick, spot);
default:
return ChatMessageEffects.NONE;
}
}
/**
* Outcome of the automatic DX Cluster spot decision for a directional opportunity.
*/
@@ -14,7 +14,7 @@ import java.util.logging.Logger;
public class PstRotatorClient implements Runnable {
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "PSTRotator";
private final String ThreadNickName;
private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName());
private static final int BUFFER_SIZE = 1024;
@@ -38,20 +38,34 @@ public class PstRotatorClient implements Runnable {
* @param listener Callback für den Chatcontroller
*/
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
// Laut Manual antwortet PSTRotator oft auf Port+1.
// Wir binden uns also standardmäßig auf remotePort + 1.
this(host, remotePort, remotePort + 1, listener, callBack);
this(host, remotePort, "PSTRotator", listener, callBack);
}
/**
* @param threadNickName name of the status button and the listener thread, so two
* rotators can be told apart in the status bar
*/
public PstRotatorClient(String host, int remotePort, String threadNickName,
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
// PSTRotator answers on the configured port + 1.
this(host, remotePort, remotePort + 1, threadNickName, listener, callBack);
}
/**
* alternative constructor for seting the remote port explicitely
*/
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this(host, remotePort, localPort, "PSTRotator", listener, callBack);
}
private PstRotatorClient(String host, int remotePort, int localPort, String threadNickName,
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
this.localPort = localPort;
this.listener = listener;
this.ThreadNickName = threadNickName;
}
/**
@@ -0,0 +1,23 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.MutableValue;
/**
* One of the two radios of an SO2R station: the place where its frequency and its
* antenna heading are shown. Without SO2R only {@link #FIRST} is used.
*/
public enum RadioSlot {
FIRST,
SECOND;
/** The frequency field of this radio: MYQRG for radio 1, SECONDQRG for radio 2. */
public MutableValue<String> qrgIn(ChatPreferences prefs) {
return this == FIRST ? prefs.getMYQRGFirstCat() : prefs.getMYQRGSecondCat();
}
/** The heading field of this radio: QTF for radio 1, QTF2 for radio 2. */
public MutableValue<Double> qtfIn(ChatPreferences prefs) {
return this == FIRST ? prefs.getActualQTF() : prefs.getActualQTF2();
}
}
@@ -0,0 +1,47 @@
package kst4contest.controller;
import java.util.Optional;
/**
* Decides which radio a received report belongs to. Pure, so the rule is tested without
* a network connection; the receivers ask it and write to the returned slot.
*
* <p>Without SO2R every report goes to radio 1, as before SO2R existed. An empty result
* means the report is dropped.</p>
*/
public final class RadioSlotRouter {
private RadioSlotRouter() {
}
/**
* @param radioNr the RadioInfo {@code RadioNr} text, null when the packet has none
*/
public static Optional<RadioSlot> forRadioInfo(boolean so2rEnabled, String radioNr) {
if (!so2rEnabled || radioNr == null || radioNr.isBlank()) {
return Optional.of(RadioSlot.FIRST);
}
return switch (radioNr.trim()) {
case "1" -> Optional.of(RadioSlot.FIRST);
case "2" -> Optional.of(RadioSlot.SECOND);
default -> Optional.empty();
};
}
/**
* @param nameRadio1 the existing station filter; empty accepts any station for radio 1
* @param nameRadio2 the station of radio 2; empty means radio 2 receives nothing
*/
public static Optional<RadioSlot> forWinTestStation(
boolean so2rEnabled, String stationName, String nameRadio1, String nameRadio2) {
String station = stationName == null ? "" : stationName;
boolean radio1Accepts = nameRadio1 == null || nameRadio1.isBlank()
|| station.equalsIgnoreCase(nameRadio1);
if (so2rEnabled && nameRadio2 != null && !nameRadio2.isBlank()
&& station.equalsIgnoreCase(nameRadio2)) {
return Optional.of(RadioSlot.SECOND);
}
return radio1Accepts ? Optional.of(RadioSlot.FIRST) : Optional.empty();
}
}
@@ -13,7 +13,9 @@ import java.util.function.Consumer;
import kst4contest.locatorUtils.Location;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.LocatorHighestPointResolver;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
import kst4contest.view.map.PathAnalysisRequest;
@@ -27,7 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Central service for tropo/path reachability calculations.
@@ -50,6 +52,7 @@ public final class ReachabilityService {
private final ChatController chatController;
private final PathAnalysisService pathAnalysisService;
private final LocatorHighestPointResolver highestPointResolver;
private final ExecutorService executor;
/**
@@ -80,7 +83,10 @@ public final class ReachabilityService {
public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
OpenMeteoTerrainProfileProvider terrainProvider = new OpenMeteoTerrainProfileProvider();
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(terrainProvider);
// One provider for both, so profile and grid lookups share its rate limiter.
this.highestPointResolver = new LocatorHighestPointResolver(terrainProvider);
this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
}
@@ -118,7 +124,8 @@ public final class ReachabilityService {
return;
}
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(
buildCalculationKey(request, isHighestPointInSquareEnabled()));
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
}
@@ -316,7 +323,8 @@ public final class ReachabilityService {
return;
}
String calculationKey = buildCalculationKey(request);
boolean highestPointInSquare = isHighestPointInSquareEnabled();
String calculationKey = buildCalculationKey(request, highestPointInSquare);
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
if (cachedResult != null) {
@@ -342,9 +350,30 @@ public final class ReachabilityService {
executor.submit(() -> {
PathAnalysisResult result;
PathAnalysisRequest effectiveRequest = request;
boolean fellBackToSquareCentre = false;
try {
result = pathAnalysisService.analyze(request);
/*
* The highest point of the remote square may need the elevation service,
* so it is resolved here on the reachability thread and never while the
* request is built on the UI thread.
*/
if (highestPointInSquare && needsHighestPointLookup(request)) {
LocatorHighestPointResolver.HighestPoint highestPoint =
highestPointResolver.resolve(request.toLocator6());
if (highestPoint != null) {
effectiveRequest = request.withTargetEndpoint(
highestPoint.latitudeDeg(),
highestPoint.longitudeDeg(),
highestPoint.elevationMeters()
);
} else {
fellBackToSquareCentre = true;
}
}
result = pathAnalysisService.analyze(effectiveRequest);
if (result == null) {
result = createNoProfileResult(request, "Path analysis returned no result.");
}
@@ -357,8 +386,22 @@ public final class ReachabilityService {
boolean usableBudget = hasUsableLinkBudget(result);
if (usableBudget) {
if (usableBudget && fellBackToSquareCentre) {
/*
* A centre fallback answers this request but is not what the setting asks
* for, so it is not kept: the next request tries the square again once the
* resolver's own retry delay has passed. The terrain profile itself stays
* cached in the provider, so this costs no repeated profile download.
*/
failedCalculationRetryAfterEpochMs.remove(calculationKey);
} else if (usableBudget) {
pathAnalysisResultCache.put(calculationKey, result);
/*
* Also file it under the key of the resolved request: once the square is
* cached, buildRequest places the endpoint there directly, and that request
* then finds this result instead of computing it again.
*/
pathAnalysisResultCache.put(buildCalculationKey(effectiveRequest, highestPointInSquare), result);
failedCalculationRetryAfterEpochMs.remove(calculationKey);
} else {
failedCalculationRetryAfterEpochMs.put(
@@ -422,9 +465,42 @@ public final class ReachabilityService {
double targetLongitudeDeg,
double analysisFrequencyMHz) {
return buildRequest(
chatController.getChatPreferences(),
ownLocator6,
targetCallsignRaw,
targetLocator6,
targetLatitudeDeg,
targetLongitudeDeg,
analysisFrequencyMHz,
highestPointResolver::peekCached
);
}
/**
* Builds a path-analysis request from the preferences.
*
* <p>The own ground elevation override comes from the preferences as entered. When
* the highest point of the remote square is enabled and already known, the remote
* endpoint is placed there; when it is not known yet, the request keeps the given
* position and the background job resolves it. Looking it up here would block the
* caller, which may be the UI thread.</p>
*
* @param preferences preferences to read the station values from
* @param cachedHighestPoint non-blocking lookup of an already resolved square
*/
static PathAnalysisRequest buildRequest(ChatPreferences preferences,
String ownLocator6,
String targetCallsignRaw,
String targetLocator6,
double targetLatitudeDeg,
double targetLongitudeDeg,
double analysisFrequencyMHz,
Function<String, LocatorHighestPointResolver.HighestPoint> cachedHighestPoint) {
Location homeLocation = new Location(ownLocator6);
return new PathAnalysisRequest(
PathAnalysisRequest request = new PathAnalysisRequest(
ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
@@ -433,11 +509,39 @@ public final class ReachabilityService {
targetLatitudeDeg,
targetLongitudeDeg,
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
preferences.getStn_pathAnalysisOwnAntennaHeightMeters(),
preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
preferences.buildPathLinkBudgetSettings(),
preferences.getStn_pathAnalysisOwnHeightAslMeters(),
Double.NaN
);
if (preferences.isStn_pathAnalysisDxHighestPointInSquare() && cachedHighestPoint != null) {
LocatorHighestPointResolver.HighestPoint highestPoint = cachedHighestPoint.apply(targetLocator6);
if (highestPoint != null) {
request = request.withTargetEndpoint(
highestPoint.latitudeDeg(),
highestPoint.longitudeDeg(),
highestPoint.elevationMeters()
);
}
}
return request;
}
/**
* Whether the background job still has to look up the highest point of the remote
* square. A request that already carries the remote ground elevation was placed on
* the cached point by {@link #buildRequest}.
*/
static boolean needsHighestPointLookup(PathAnalysisRequest request) {
return !Double.isFinite(request.targetGroundElevationOverrideMeters());
}
private boolean isHighestPointInSquareEnabled() {
return chatController.getChatPreferences().isStn_pathAnalysisDxHighestPointInSquare();
}
/**
@@ -626,10 +730,18 @@ public final class ReachabilityService {
/**
* Builds a stable calculation key.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
* <p>The key includes station, locators, remote position, frequency, antenna heights,
* link budget settings, the own ground elevation override, the remote ground
* elevation and whether the highest point of the remote square is used. If any
* relevant input changes, a new calculation is allowed. The remote position is the
* one in the request: the square centre while the highest point is still unresolved,
* the highest point itself once it is cached. The switch is part of the key because
* before the lookup both settings produce the same centre request.</p>
*
* @param request the request as built
* @param highestPointInSquare whether the remote endpoint is the highest point of its square
*/
private String buildCalculationKey(PathAnalysisRequest request) {
static String buildCalculationKey(PathAnalysisRequest request, boolean highestPointInSquare) {
return normalizeLocator6(request.fromLocator6())
+ "|"
+ normalizeCallsignRaw(request.toCallsignRaw())
@@ -646,14 +758,28 @@ public final class ReachabilityService {
+ "|"
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
+ "|"
+ request.linkBudgetSettings();
+ request.linkBudgetSettings()
+ "|homeAsl="
+ formatOptionalMeters(request.homeGroundElevationOverrideMeters())
+ "|targetAsl="
+ formatOptionalMeters(request.targetGroundElevationOverrideMeters())
+ "|highestPoint="
+ highestPointInSquare;
}
private String normalizeCallsignRaw(String callSignRaw) {
/*
* Full precision on purpose: rounded to one decimal, 123.04 m and 123.00 m would share
* a key and the second would be answered with the first one's result.
*/
private static String formatOptionalMeters(double meters) {
return Double.isFinite(meters) ? Double.toString(meters) : "model";
}
private static String normalizeCallsignRaw(String callSignRaw) {
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
}
private String normalizeLocator6(String locator) {
private static String normalizeLocator6(String locator) {
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
}
@@ -2,6 +2,7 @@ package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
import java.io.BufferedWriter;
@@ -466,12 +467,13 @@ public class ReadUDPByWintestThread extends Thread {
}
/**
* Parses a Win-Test STATUS packet and optionally updates MYQRG.
* Parses a Win-Test STATUS packet and routes it to a radio.
*
* <p>The packet is tokenised while preserving quoted station names.
* The configured station-name filter is applied before any frequency is
* processed. An empty filter accepts STATUS packets from every Win-Test
* station.</p>
* {@code RadioSlotRouter} decides before any frequency is processed: radio 1
* updates MYQRG, radio 2 (only with SO2R) updates SECONDQRG, and a packet
* from any other station is ignored. An empty radio 1 station name accepts
* STATUS packets from every Win-Test station.</p>
*
* <p>The main frequency is read from token 7. If pass-frequency use is
* enabled and token 11 contains a valid frequency, the pass frequency is
@@ -480,7 +482,8 @@ public class ReadUDPByWintestThread extends Thread {
*
* @param msg complete Win-Test STATUS packet
*/
private void parseStatus(String msg) {
/* package-private for tests */
void parseStatus(String msg) {
try {
ArrayList<String> parts = new ArrayList<>();
Matcher matcher = STATUS_TOKEN_PATTERN.matcher(msg);
@@ -501,13 +504,13 @@ public class ReadUDPByWintestThread extends Thread {
}
String stationName = parts.get(1);
String stationFilter = client
.getChatPreferences()
.getLogsynch_wintestNetworkStationNameOfWintestClient1();
if (stationFilter != null
&& !stationFilter.isBlank()
&& !stationName.equalsIgnoreCase(stationFilter)) {
ChatPreferences prefs = client.getChatPreferences();
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forWinTestStation(
prefs.isTrxSynch_so2rEnabled(),
stationName,
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1(),
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2());
if (slot.isEmpty()) {
return;
}
@@ -564,15 +567,9 @@ public class ReadUDPByWintestThread extends Thread {
? formattedPassQrg
: formattedMainQrg;
if (client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()) {
client.uiDispatcher().runOnUi(
() -> client
.getChatPreferences()
.getMYQRGFirstCat()
.set(qrgToSet)
);
if (prefs.isLogsynch_wintestQrgSyncEnabled()) {
RadioSlot target = slot.get();
client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet));
}
System.out.println(
@@ -515,6 +515,15 @@ public class ReadUDPbyUCXMessageThread extends Thread {
continue;
}
Node radioNrNode = element.getElementsByTagName("RadioNr").item(0);
String radioNr = radioNrNode == null ? null : radioNrNode.getTextContent();
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forRadioInfo(
client.getChatPreferences().isTrxSynch_so2rEnabled(), radioNr);
if (slot.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo for unknown RadioNr \"" + radioNr + "\" ignored");
continue;
}
String qrg = freqNode.getTextContent();
String mode = modeNode.getTextContent();
@@ -561,8 +570,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
final String finalFormattedQRG = formattedQRG;
final RadioSlot target = slot.get();
helper_runOnFxThread(() ->
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG)
);
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
@@ -0,0 +1,186 @@
package kst4contest.controller;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* One PSTRotator as the operator drives it: start and stop, a heading command with its
* delayed compatibility check, and the last reported position.
*
* <p>Moved out of ChatController unchanged so SO2R can run one per radio. The command
* scheduler is shared and passed in; nothing here blocks the UI thread.</p>
*/
public final class RotatorControl {
/** What the control needs from a rotator connection; PstRotatorClient in production. */
public interface Link {
void setTrackingMode(boolean on);
void setAzimuth(double azimuth);
/** Tells the rotor to stop turning. */
void stopRotation();
/** Closes the connection and its polling. */
void stop();
}
private static final Logger LOGGER = Logger.getLogger(RotatorControl.class.getName());
/**
* How close the reported position must be to count as "reached". The target carries a
* decimal, while many rotor controllers report whole degrees: with 0.5 a target of 145.7
* and a report of 145 would count as missed and could trigger the retry.
*/
public static final double REACHED_TOLERANCE_DEGREES = 1.0;
private final String threadName;
private final Function<RotatorControl, Link> linkFactory;
private final ScheduledExecutorService retryScheduler;
private final Consumer<Double> reportedAzimuthSink;
private volatile Link link;
private ScheduledFuture<?> pendingRetry;
/* Updated by the receiver thread; read by the retry check, never from UI state. */
private volatile double lastReportedAzimuth = Double.NaN;
/**
* @param threadName names the rotator in logs and in the status bar
* @param linkFactory opens the connection; receives this control as the event target
* @param retryScheduler runs the delayed compatibility check
* @param reportedAzimuthSink receives every valid, normalised reported azimuth on the
* receiver thread; the caller moves it to the UI thread
*/
public RotatorControl(String threadName, Function<RotatorControl, Link> linkFactory,
ScheduledExecutorService retryScheduler, Consumer<Double> reportedAzimuthSink) {
this.threadName = threadName;
this.linkFactory = linkFactory;
this.retryScheduler = retryScheduler;
this.reportedAzimuthSink = reportedAzimuthSink;
}
/** Opens the connection unless one is open. */
public synchronized void start() {
if (link == null) {
link = linkFactory.apply(this);
}
}
public boolean isRunning() {
return link != null;
}
/** Cancels a pending check, closes the connection and forgets the position. */
public synchronized void stop() {
ScheduledFuture<?> retry = pendingRetry;
if (retry != null) {
retry.cancel(false);
pendingRetry = null;
}
Link active = link;
link = null;
lastReportedAzimuth = Double.NaN;
if (active != null) {
active.stop();
}
}
public double lastReportedAzimuth() {
return lastReportedAzimuth;
}
/** Tells the rotor to stop turning; used when the session ends. */
public void stopRotation() {
Link active = link;
if (active != null) {
active.stopRotation();
}
}
/**
* Sends a new azimuth without blocking the caller.
*
* <p>Some SPID configurations occasionally ignore the first azimuth command. The latest
* reported position is therefore checked after two seconds. If no movement was reported
* and the target has not already been reached, the original compatibility sequence is
* sent again.</p>
*/
public synchronized void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(Level.WARNING, "Ignoring invalid {0} azimuth: {1}", new Object[] { threadName, azimuth });
return;
}
Link active = link;
if (active == null) {
LOGGER.log(Level.WARNING, "Cannot rotate antenna to {0} degrees: {1} integration is not active.",
new Object[] { azimuth, threadName });
return;
}
double target = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedAzimuth;
active.setTrackingMode(false);
LOGGER.log(Level.INFO, "Sending {0} azimuth requested by the operator: {1}", new Object[] { threadName, target });
active.setAzimuth(target);
ScheduledFuture<?> previous = pendingRetry;
if (previous != null) {
previous.cancel(false);
}
pendingRetry = retryScheduler.schedule(
() -> retryIfRequired(positionBeforeCommand, target), 2, TimeUnit.SECONDS);
}
/** Called by the receiver thread for every reported position. */
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(Level.WARNING, "Ignoring invalid azimuth reported by {0}: {1}", new Object[] { threadName, azimuth });
return;
}
double normalized = normalizeAzimuth(azimuth);
lastReportedAzimuth = normalized;
reportedAzimuthSink.accept(normalized);
}
private void retryIfRequired(double positionBeforeCommand, double target) {
Link active = link;
if (active == null) {
return;
}
double current = lastReportedAzimuth;
if (Double.isFinite(current) && angularDistance(current, target) < REACHED_TOLERANCE_DEGREES) {
LOGGER.log(Level.FINE, "{0} reached the requested azimuth without retry: {1}", new Object[] { threadName, target });
return;
}
boolean noPositionFeedback = !Double.isFinite(current);
boolean positionUnchanged = Double.isFinite(positionBeforeCommand)
&& Double.isFinite(current)
&& angularDistance(positionBeforeCommand, current) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(Level.FINE, "{0} reported movement towards {1}; no retry required.", new Object[] { threadName, target });
return;
}
LOGGER.log(Level.WARNING, "{0} reported no movement after the command for {1} degrees; "
+ "sending the SPID compatibility retry.", new Object[] { threadName, target });
active.setAzimuth(0.0);
active.setAzimuth(target);
}
static double angularDistance(double first, double second) {
double difference = Math.abs(normalizeAzimuth(first) - normalizeAzimuth(second));
return Math.min(difference, 360.0 - difference);
}
static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
}
@@ -204,6 +204,9 @@ public class ChatPreferences {
boolean stn_pstRotatorEnabled = false;
String stn_pstRotatorHost = "127.0.0.1";
int stn_pstRotatorPort = 12000;
boolean stn_pstRotator2Enabled = false;
String stn_pstRotator2Host = "127.0.0.1";
int stn_pstRotator2Port = 12010;
boolean stn_loginAFKState = false; //always start as here
/*
@@ -225,6 +228,17 @@ public class ChatPreferences {
double stn_pathAnalysisOwnAntennaHeightMeters = 10.0;
double stn_pathAnalysisDefaultTargetAntennaHeightMeters = 10.0;
/**
* Ground elevation of the own site above mean sea level, entered by the operator.
* NaN (the default, and an empty field) leaves it to the elevation model at the
* locator centre.
*/
double stn_pathAnalysisOwnHeightAslMeters = Double.NaN;
/**
* Whether the path analysis ends at the highest sampled point of the remote station's
* locator square instead of at the square centre.
*/
boolean stn_pathAnalysisDxHighestPointInSquare = true;
String stn_pathAnalysisDemRootDirectory = "";
String stn_pathAnalysisDemDatasetId = "copernicus_glo_30";
double stn_pathAnalysisOwnTxPowerWatts = 750.0;
@@ -244,6 +258,7 @@ public class ChatPreferences {
ChatCategory loginChatCategorySecond = new ChatCategory(3);
boolean loginToSecondChatEnabled;
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
boolean stn_bandActive50;
boolean stn_bandActive70;
@@ -266,6 +281,7 @@ public class ChatPreferences {
String logsynch_wintestNetworkStationNameOfKST = "KST4Contest";
String logsynch_wintestNetworkStationNameOfWintestClient1 = "STN1";
String logsynch_wintestNetworkStationNameOfWintestClient2 = "";
boolean logsynch_wintestNetworkSimulationEnabled = false;
int logsynch_wintestNetworkStationIDOfKST = 55555;
int logsynch_wintestNetworkPort = 9871;
@@ -286,6 +302,7 @@ public class ChatPreferences {
MutableValue<String> MYQRGSecondCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is activated
boolean trxSynch_ucxLogUDPListenerEnabled = false;
boolean trxSynch_so2rEnabled = false;
/**
* AirScout prefs
@@ -424,6 +441,21 @@ public class ChatPreferences {
private boolean GUI_darkModeActive = false;
private boolean GUI_darkModeActiveByDefault = false;
/**
* Opt-in: windows reopen across restarts where the operator last moved them, on the
* same screen. Off by default, because a stored position outlives the monitor layout
* it was made on; the positions are only used when that exact screen is connected.
*/
private boolean guiOptions_rememberWindowPositions = false;
/**
* Last operator-chosen window positions by window id, each as
* {x, y, screenX, screenY, screenWidth, screenHeight} in desktop coordinates.
*/
private final Map<String, int[]> windowPositions = new LinkedHashMap<>();
private static final String TAG_WINDOW_POSITION = "windowPosition";
private double[] GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234};
private double[] GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500};
private double[] GUIstage_updateStage_SceneSizeHW = new double[] {580, 480};
@@ -495,6 +527,48 @@ public class ChatPreferences {
this.stn_pathAnalysisOwnAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisOwnAntennaHeightMeters);
}
/** Lowest own ground elevation accepted, metres: below the Dead Sea shore. */
public static final double PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS = -500.0;
/** Highest own ground elevation accepted, metres: above the highest summit. */
public static final double PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS = 9000.0;
/**
* @return own ground elevation above mean sea level in metres, or NaN when the
* elevation model decides
*/
public double getStn_pathAnalysisOwnHeightAslMeters() {
return stn_pathAnalysisOwnHeightAslMeters;
}
/**
* Sets the own ground elevation above mean sea level. NaN, or a value outside
* {@link #PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS} ..
* {@link #PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS}, clears it, so a broken value in
* the XML file falls back to the elevation model instead of distorting every path.
*/
public void setStn_pathAnalysisOwnHeightAslMeters(double stn_pathAnalysisOwnHeightAslMeters) {
this.stn_pathAnalysisOwnHeightAslMeters = isValidOwnHeightAslMeters(stn_pathAnalysisOwnHeightAslMeters)
? stn_pathAnalysisOwnHeightAslMeters
: Double.NaN;
}
/**
* @return whether the value is a usable own ground elevation (finite and in range)
*/
public static boolean isValidOwnHeightAslMeters(double meters) {
return Double.isFinite(meters)
&& meters >= PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS
&& meters <= PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS;
}
public boolean isStn_pathAnalysisDxHighestPointInSquare() {
return stn_pathAnalysisDxHighestPointInSquare;
}
public void setStn_pathAnalysisDxHighestPointInSquare(boolean stn_pathAnalysisDxHighestPointInSquare) {
this.stn_pathAnalysisDxHighestPointInSquare = stn_pathAnalysisDxHighestPointInSquare;
}
public double getStn_pathAnalysisDefaultTargetAntennaHeightMeters() {
return stn_pathAnalysisDefaultTargetAntennaHeightMeters;
}
@@ -577,6 +651,14 @@ public class ChatPreferences {
this.logsynch_wintestNetworkStationNameOfWintestClient1 = logsynch_wintestNetworkStationNameOfWintestClient1;
}
public String getLogsynch_wintestNetworkStationNameOfWintestClient2() {
return logsynch_wintestNetworkStationNameOfWintestClient2;
}
public void setLogsynch_wintestNetworkStationNameOfWintestClient2(String name) {
this.logsynch_wintestNetworkStationNameOfWintestClient2 = name == null ? "" : name.trim();
}
public boolean isLogsynch_wintestNetworkSimulationEnabled() {
return logsynch_wintestNetworkSimulationEnabled;
}
@@ -989,6 +1071,31 @@ public class ChatPreferences {
}
}
public boolean isStn_pstRotator2Enabled() {
return stn_pstRotator2Enabled;
}
public void setStn_pstRotator2Enabled(boolean enabled) {
this.stn_pstRotator2Enabled = enabled;
}
public String getStn_pstRotator2Host() {
return stn_pstRotator2Host;
}
public void setStn_pstRotator2Host(String host) {
this.stn_pstRotator2Host = host == null || host.isBlank() ? "127.0.0.1" : host.trim();
}
public int getStn_pstRotator2Port() {
return stn_pstRotator2Port;
}
/** PSTRotator answers on port + 1, so 65535 is not usable. */
public void setStn_pstRotator2Port(int port) {
this.stn_pstRotator2Port = port < 1 || port > 65534 ? 12010 : port;
}
public MutableValue<String> getNotify_optionalFrequencyPrefix() {
return notify_optionalFrequencyPrefix;
}
@@ -1307,6 +1414,11 @@ public class ChatPreferences {
return actualQTF;
}
/** Heading reported by rotator 2; NaN while there is none. Display only. */
public MutableValue<Double> getActualQTF2() {
return actualQTF2;
}
public void setActualQTF(MutableValue<Double> actualQTF) {
this.actualQTF = actualQTF;
}
@@ -1405,6 +1517,14 @@ public class ChatPreferences {
this.trxSynch_ucxLogUDPListenerEnabled = trxSynch_ucxLogUDPListenerEnabled;
}
public boolean isTrxSynch_so2rEnabled() {
return trxSynch_so2rEnabled;
}
public void setTrxSynch_so2rEnabled(boolean enabled) {
this.trxSynch_so2rEnabled = enabled;
}
public void setNotify_optionalFrequencyPrefix(String notify_optionalFrequencyPrefix) {
this.notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefix);
}
@@ -1621,6 +1741,17 @@ public class ChatPreferences {
stn_pathAnalysisDefaultTargetAntennaHeightMeters.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaHeightMeters + "");
station.appendChild(stn_pathAnalysisDefaultTargetAntennaHeightMeters);
// Empty when unset: reading an empty element keeps the NaN default.
Element stn_pathAnalysisOwnHeightAslMeters = doc.createElement("stn_pathAnalysisOwnHeightAslMeters");
stn_pathAnalysisOwnHeightAslMeters.setTextContent(Double.isFinite(this.stn_pathAnalysisOwnHeightAslMeters)
? this.stn_pathAnalysisOwnHeightAslMeters + ""
: "");
station.appendChild(stn_pathAnalysisOwnHeightAslMeters);
Element stn_pathAnalysisDxHighestPointInSquare = doc.createElement("stn_pathAnalysisDxHighestPointInSquare");
stn_pathAnalysisDxHighestPointInSquare.setTextContent(this.stn_pathAnalysisDxHighestPointInSquare + "");
station.appendChild(stn_pathAnalysisDxHighestPointInSquare);
Element stn_pathAnalysisDemRootDirectory = doc.createElement("stn_pathAnalysisDemRootDirectory");
stn_pathAnalysisDemRootDirectory.setTextContent(this.stn_pathAnalysisDemRootDirectory);
station.appendChild(stn_pathAnalysisDemRootDirectory);
@@ -1730,6 +1861,18 @@ public class ChatPreferences {
stn_pstRotatorPort.setTextContent(Integer.toString(this.stn_pstRotatorPort));
station.appendChild(stn_pstRotatorPort);
Element stn_pstRotator2Enabled = doc.createElement("stn_pstRotator2Enabled");
stn_pstRotator2Enabled.setTextContent(this.stn_pstRotator2Enabled + "");
station.appendChild(stn_pstRotator2Enabled);
Element stn_pstRotator2Host = doc.createElement("stn_pstRotator2Host");
stn_pstRotator2Host.setTextContent(this.stn_pstRotator2Host);
station.appendChild(stn_pstRotator2Host);
Element stn_pstRotator2Port = doc.createElement("stn_pstRotator2Port");
stn_pstRotator2Port.setTextContent(Integer.toString(this.stn_pstRotator2Port));
station.appendChild(stn_pstRotator2Port);
/**
* LOGSYNCH
@@ -1768,6 +1911,10 @@ public class ChatPreferences {
logsynch_wintestNetworkStationNameOfWintestClient1.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient1);
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient1);
Element logsynch_wintestNetworkStationNameOfWintestClient2 = doc.createElement("logsynch_wintestNetworkStationNameOfWintestClient2");
logsynch_wintestNetworkStationNameOfWintestClient2.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient2);
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient2);
Element logsynch_wintestNetworkSimulationEnabled = doc.createElement("logsynch_wintestNetworkSimulationEnabled");
logsynch_wintestNetworkSimulationEnabled.setTextContent(this.logsynch_wintestNetworkSimulationEnabled + "");
logsynch.appendChild(logsynch_wintestNetworkSimulationEnabled);
@@ -1816,6 +1963,10 @@ public class ChatPreferences {
trxSynch_ucxLogUDPListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+"");
trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled);
Element trxSynch_so2rEnabled = doc.createElement("trxSynch_so2rEnabled");
trxSynch_so2rEnabled.setTextContent(this.trxSynch_so2rEnabled + "");
trxSynchUCX.appendChild(trxSynch_so2rEnabled);
Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue");
trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().get());
trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue);
@@ -2159,6 +2310,10 @@ public class ChatPreferences {
guiOptions_darkModeActiveByDefault.setTextContent(this.GUI_darkModeActiveByDefault + "");
guiSaveableOptions.appendChild(guiOptions_darkModeActiveByDefault);
Element guiOptions_rememberWindowPositions = doc.createElement("guiOptions_rememberWindowPositions");
guiOptions_rememberWindowPositions.setTextContent(String.valueOf(this.guiOptions_rememberWindowPositions));
guiSaveableOptions.appendChild(guiOptions_rememberWindowPositions);
// --- GUImainWindowRightSplitPane_dividerposition (2 dividers => 2 values) --- NEW
ensureMainWindowRightSplitPaneDividerPositions(2);
@@ -2241,6 +2396,7 @@ public class ChatPreferences {
guiOptions.appendChild(GUIstationMapClusteringEnabled);
appendTableColumnWidths(doc, guiOptions);
appendWindowPositions(doc, guiOptions);
/****************************************************************************************
****************************** now write this XML! *************************************
@@ -2293,6 +2449,8 @@ public class ChatPreferences {
updateLayoutElements(document, guiOptions);
removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH);
appendTableColumnWidths(document, guiOptions);
removeDirectChildren(guiOptions, TAG_WINDOW_POSITION);
appendWindowPositions(document, guiOptions);
writeDocumentAtomically(document);
return true;
} catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) {
@@ -2467,6 +2625,18 @@ public class ChatPreferences {
"stn_pathAnalysisDefaultTargetAntennaHeightMeters"
);
setStn_pathAnalysisOwnHeightAslMeters(getDouble(
stationEl,
Double.NaN,
"stn_pathAnalysisOwnHeightAslMeters"
));
stn_pathAnalysisDxHighestPointInSquare = getBooleanOrDefault(
stationEl,
stn_pathAnalysisDxHighestPointInSquare,
"stn_pathAnalysisDxHighestPointInSquare"
);
stn_pathAnalysisDemRootDirectory = getText(
stationEl,
stn_pathAnalysisDemRootDirectory,
@@ -2568,6 +2738,11 @@ public class ChatPreferences {
)
);
// Added in release 1.50 for SO2R; absent in older files.
stn_pstRotator2Enabled = getBoolean(stationEl, stn_pstRotator2Enabled, "stn_pstRotator2Enabled");
setStn_pstRotator2Host(getText(stationEl, stn_pstRotator2Host, "stn_pstRotator2Host"));
setStn_pstRotator2Port(getInt(stationEl, stn_pstRotator2Port, "stn_pstRotator2Port"));
}
/**
@@ -2606,6 +2781,10 @@ public class ChatPreferences {
logsynchEl,
logsynch_wintestNetworkStationNameOfWintestClient1,
"logsynch_wintestNetworkStationNameOfWintestClient1");
setLogsynch_wintestNetworkStationNameOfWintestClient2(getText(
logsynchEl,
logsynch_wintestNetworkStationNameOfWintestClient2,
"logsynch_wintestNetworkStationNameOfWintestClient2"));
logsynch_wintestNetworkSimulationEnabled = getBoolean(
logsynchEl,
logsynch_wintestNetworkSimulationEnabled,
@@ -2665,6 +2844,7 @@ public class ChatPreferences {
trxSynchEl,
trxSynch_ucxLogUDPListenerEnabled,
"trxSynch_ucxLogUDPListenerEnabled");
trxSynch_so2rEnabled = getBoolean(trxSynchEl, trxSynch_so2rEnabled, "trxSynch_so2rEnabled");
String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue");
if (qrg1 != null) {
@@ -3151,6 +3331,8 @@ public class ChatPreferences {
this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5));
}
readWindowPositions(element);
tableColumnWidths.clear();
NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH);
for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) {
@@ -3192,6 +3374,9 @@ public class ChatPreferences {
// Added in later versions: dark mode flags
this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive");
this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault");
// Added in release 1.50: opt-in for window positions across restarts.
this.guiOptions_rememberWindowPositions = getBoolean(guiSaveableOptionsEl, false, "guiOptions_rememberWindowPositions");
}
@@ -3329,6 +3514,77 @@ public class ChatPreferences {
this.GUI_darkModeActiveByDefault = GUI_darkModeActiveByDefault;
}
public boolean isGuiOptions_rememberWindowPositions() {
return guiOptions_rememberWindowPositions;
}
public void setGuiOptions_rememberWindowPositions(boolean guiOptions_rememberWindowPositions) {
this.guiOptions_rememberWindowPositions = guiOptions_rememberWindowPositions;
}
/**
* Returns the stored position of one window.
*
* @param windowId stable window identifier
* @return a copy of {x, y, screenX, screenY, screenWidth, screenHeight}, or null when
* nothing usable is stored
*/
public synchronized int[] getWindowPosition(String windowId) {
int[] stored = windowPositions.get(windowId);
return stored == null ? null : stored.clone();
}
/**
* Updates one window position in memory. Persistence is coordinated by the layout
* autosave layer, like the table column widths.
*/
public synchronized void setWindowPosition(String windowId, int x, int y,
int screenX, int screenY, int screenWidth, int screenHeight) {
if (isValidWindowId(windowId) && screenWidth > 0 && screenHeight > 0) {
windowPositions.put(windowId, new int[] { x, y, screenX, screenY, screenWidth, screenHeight });
}
}
private static boolean isValidWindowId(String windowId) {
return windowId != null && !windowId.isBlank();
}
private void appendWindowPositions(Document document, Element guiOptions) {
for (Map.Entry<String, int[]> entry : windowPositions.entrySet()) {
int[] p = entry.getValue();
Element element = document.createElement(TAG_WINDOW_POSITION);
element.setAttribute("windowId", entry.getKey());
element.setAttribute("x", String.valueOf(p[0]));
element.setAttribute("y", String.valueOf(p[1]));
element.setAttribute("screenX", String.valueOf(p[2]));
element.setAttribute("screenY", String.valueOf(p[3]));
element.setAttribute("screenWidth", String.valueOf(p[4]));
element.setAttribute("screenHeight", String.valueOf(p[5]));
guiOptions.appendChild(element);
}
}
private void readWindowPositions(Element guiOptions) {
windowPositions.clear();
NodeList elements = guiOptions.getElementsByTagName(TAG_WINDOW_POSITION);
for (int index = 0; index < elements.getLength(); index++) {
if (!(elements.item(index) instanceof Element element)) {
continue;
}
try {
setWindowPosition(element.getAttribute("windowId"),
Integer.parseInt(element.getAttribute("x")),
Integer.parseInt(element.getAttribute("y")),
Integer.parseInt(element.getAttribute("screenX")),
Integer.parseInt(element.getAttribute("screenY")),
Integer.parseInt(element.getAttribute("screenWidth")),
Integer.parseInt(element.getAttribute("screenHeight")));
} catch (NumberFormatException ignored) {
// A damaged entry is dropped; the window then opens by the default rule.
}
}
}
/**
*
* If the file-reading goes wrong, set the defaults
@@ -114,8 +114,14 @@ public final class GeometryOnlyPathAnalysisService implements PathAnalysisServic
);
}
List<PathProfilePoint> enrichedProfilePoints = buildEnrichedProfilePoints(
List<PathProfilePoint> rawProfilePoints = applyGroundElevationOverrides(
terrainProfileData.profilePoints(),
request.homeGroundElevationOverrideMeters(),
request.targetGroundElevationOverrideMeters()
);
List<PathProfilePoint> enrichedProfilePoints = buildEnrichedProfilePoints(
rawProfilePoints,
distanceKm,
homeAntennaHeightMeters,
targetAntennaHeightMeters,
@@ -203,6 +209,52 @@ public final class GeometryOnlyPathAnalysisService implements PathAnalysisServic
);
}
/**
* Replaces the terrain elevation of the first and/or last raw sample by a known
* ground elevation.
*
* <p>The endpoints are where the antennas stand, so their elevation decides the
* height of the line of sight. An operator who knows the elevation of the own site
* better than the elevation model does at the locator centre enters it, and the
* highest point of the remote square is known from its own lookup. Replacing the raw
* sample, rather than patching the antenna height later, keeps the chart, the horizon
* search and the clearance figures on one consistent profile.</p>
*
* @param rawProfilePoints raw samples from the provider, at least two
* @param homeGroundElevationMeters own ground elevation, or {@code NaN} to keep the model value
* @param targetGroundElevationMeters remote ground elevation, or {@code NaN} to keep the model value
* @return the samples with the overridden endpoints
*/
static List<PathProfilePoint> applyGroundElevationOverrides(List<PathProfilePoint> rawProfilePoints,
double homeGroundElevationMeters,
double targetGroundElevationMeters) {
boolean overrideHome = Double.isFinite(homeGroundElevationMeters);
boolean overrideTarget = Double.isFinite(targetGroundElevationMeters);
if ((!overrideHome && !overrideTarget) || rawProfilePoints.size() < 2) {
return rawProfilePoints;
}
List<PathProfilePoint> points = new ArrayList<>(rawProfilePoints);
if (overrideHome) {
points.set(0, withElevation(points.get(0), homeGroundElevationMeters));
}
if (overrideTarget) {
int lastIndex = points.size() - 1;
points.set(lastIndex, withElevation(points.get(lastIndex), targetGroundElevationMeters));
}
return List.copyOf(points);
}
private static PathProfilePoint withElevation(PathProfilePoint point, double elevationMeters) {
return new PathProfilePoint(
point.distanceKm(),
point.latitudeDeg(),
point.longitudeDeg(),
elevationMeters
);
}
/**
* Converts raw terrain profile samples into enriched samples that already contain:
* <ul>
@@ -0,0 +1,256 @@
package kst4contest.view.map;
import kst4contest.locatorUtils.Location;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.LongSupplier;
import java.util.regex.Pattern;
/**
* Finds the highest terrain point inside a six-character Maidenhead locator square.
*
* <p>A locator only says which square a station is in, not where in it. VHF/UHF contest
* stations tend to operate from the high ground of their square rather than from its
* geometric centre, so the path analysis can use the highest sampled point of the square
* as the remote endpoint instead.</p>
*
* <p><b>Grid.</b> A six-character square spans 5' of longitude and 2.5' of latitude,
* roughly 5.8 km x 4.6 km at 50 degrees north. It is sampled with a
* {@value #GRID_SIZE} x {@value #GRID_SIZE} grid of cell centres, so the samples are
* about 1.2 km x 0.9 km apart there and never sit on the border shared with the
* neighbouring square. An odd grid size puts one sample exactly on the square centre,
* which means the chosen point is never lower than the centre the analysis used before.
* The 25 points fit into a single request of the online elevation service (limit 100
* coordinates). The grid is a coarse search, not a summit finder: a broad hill usually
* gets a sample reasonably close to its top, but a narrow ridge or peak between two
* samples can be missed, and the chosen point is then only the highest of the samples.
* A denser grid would not cost more requests, but would only refine a position that the
* locator itself does not pin down anyway.</p>
*
* <p><b>Ties</b> are broken towards the square centre, so a flat square resolves to its
* centre and the result is deterministic.</p>
*
* <p><b>Caching.</b> A resolved square is pure geometry and is kept in memory for the
* running session (least recently used, at most {@value #MAX_CACHED_SQUARES} squares).
* A failed lookup is remembered as well and not retried for
* {@link #FAILURE_RETRY_DELAY_MS} milliseconds, so an unreachable service is not asked
* again for every station of the same square. The caller falls back to the square centre
* in the meantime. Expired failures are dropped whenever a failure is recorded, and the
* failure list is bounded like the result cache.</p>
*
* <p>{@link #resolve(String)} may block on the network and must run on a background
* thread; {@link #peekCached(String)} never blocks on it and is safe anywhere.</p>
*/
public final class LocatorHighestPointResolver {
/** Samples per axis; see the class comment for why five. */
public static final int GRID_SIZE = 5;
static final int MAX_CACHED_SQUARES = 4096;
/** Same delay the reachability service uses before it retries a failed analysis. */
static final long FAILURE_RETRY_DELAY_MS = 5L * 60L * 1000L;
private static final double SQUARE_HEIGHT_DEG = 2.5 / 60.0;
private static final double SQUARE_WIDTH_DEG = 5.0 / 60.0;
private static final Pattern LOCATOR6 = Pattern.compile("[A-R]{2}[0-9]{2}[A-X]{2}");
private final TerrainElevationProvider elevationProvider;
private final LongSupplier clock;
private final Map<String, HighestPoint> resolvedSquares =
new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, HighestPoint> eldest) {
return size() > MAX_CACHED_SQUARES;
}
};
private final Map<String, Long> retryAfterEpochMsBySquare =
new LinkedHashMap<>(16, 0.75f, false) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
return size() > MAX_CACHED_SQUARES;
}
};
public LocatorHighestPointResolver(TerrainElevationProvider elevationProvider) {
this(elevationProvider, System::currentTimeMillis);
}
LocatorHighestPointResolver(TerrainElevationProvider elevationProvider, LongSupplier clock) {
this.elevationProvider = Objects.requireNonNull(elevationProvider, "elevationProvider");
this.clock = Objects.requireNonNull(clock, "clock");
}
/**
* The highest sampled point of one locator square.
*
* @param locator6 the square, upper case
* @param latitudeDeg latitude of the chosen sample
* @param longitudeDeg longitude of the chosen sample
* @param elevationMeters terrain elevation there, metres above mean sea level
*/
public record HighestPoint(String locator6, double latitudeDeg, double longitudeDeg, double elevationMeters) {
}
/**
* Returns the cached highest point of a square without touching the elevation service.
*
* @param locator6 six-character locator
* @return the cached point, or {@code null} when the square has not been resolved yet,
* its lookup failed, or the locator is not a valid six-character locator
*/
public HighestPoint peekCached(String locator6) {
String square = normalize(locator6);
if (square == null) {
return null;
}
synchronized (resolvedSquares) {
return resolvedSquares.get(square);
}
}
/**
* Returns the highest sampled point of a square, asking the elevation service once per
* square and session. Blocks while the service answers.
*
* @param locator6 six-character locator
* @return the highest point, or {@code null} when the locator is invalid or the lookup
* failed (now or within the retry delay); the caller then uses the square centre
*/
public HighestPoint resolve(String locator6) {
String square = normalize(locator6);
if (square == null) {
return null;
}
synchronized (resolvedSquares) {
HighestPoint cached = resolvedSquares.get(square);
if (cached != null) {
return cached;
}
Long retryAfter = retryAfterEpochMsBySquare.get(square);
if (retryAfter != null) {
if (clock.getAsLong() < retryAfter) {
return null;
}
retryAfterEpochMsBySquare.remove(square);
}
}
// The service is asked outside the lock so that peekCached never waits for it.
HighestPoint resolved = lookUp(square);
synchronized (resolvedSquares) {
if (resolved == null) {
long now = clock.getAsLong();
retryAfterEpochMsBySquare.values().removeIf(retryAfter -> retryAfter <= now);
retryAfterEpochMsBySquare.put(square, now + FAILURE_RETRY_DELAY_MS);
} else {
retryAfterEpochMsBySquare.remove(square);
resolvedSquares.put(square, resolved);
}
}
return resolved;
}
/** Number of squares currently held back after a failure; for tests. */
int failedSquareCount() {
synchronized (resolvedSquares) {
return retryAfterEpochMsBySquare.size();
}
}
/**
* The sample grid of one square, row by row from south-west to north-east.
*
* @param locator6 six-character locator
* @return {@value #GRID_SIZE} x {@value #GRID_SIZE} cell centres, or an empty list for
* an invalid locator
*/
static List<PathGeometryUtils.GeoPoint> buildSampleGrid(String locator6) {
String square = normalize(locator6);
if (square == null) {
return List.of();
}
double southLatitudeDeg = Location.extractLat(square).toDegrees() - SQUARE_HEIGHT_DEG / 2.0;
double westLongitudeDeg = Location.extractLon(square).toDegrees() - SQUARE_WIDTH_DEG / 2.0;
List<PathGeometryUtils.GeoPoint> grid = new ArrayList<>(GRID_SIZE * GRID_SIZE);
for (int row = 0; row < GRID_SIZE; row++) {
double latitudeDeg = southLatitudeDeg + SQUARE_HEIGHT_DEG * (row + 0.5) / GRID_SIZE;
for (int column = 0; column < GRID_SIZE; column++) {
double longitudeDeg = westLongitudeDeg + SQUARE_WIDTH_DEG * (column + 0.5) / GRID_SIZE;
grid.add(new PathGeometryUtils.GeoPoint(latitudeDeg, longitudeDeg));
}
}
return grid;
}
private HighestPoint lookUp(String square) {
List<PathGeometryUtils.GeoPoint> grid = buildSampleGrid(square);
final List<Double> elevations;
try {
elevations = elevationProvider.loadElevations(grid);
} catch (RuntimeException exception) {
return null;
}
if (elevations == null || elevations.size() != grid.size()) {
return null;
}
int centreIndex = (GRID_SIZE / 2) * GRID_SIZE + GRID_SIZE / 2;
PathGeometryUtils.GeoPoint centre = grid.get(centreIndex);
int bestIndex = -1;
double bestElevation = Double.NEGATIVE_INFINITY;
double bestDistanceToCentre = Double.POSITIVE_INFINITY;
for (int i = 0; i < grid.size(); i++) {
Double elevation = elevations.get(i);
if (elevation == null || !Double.isFinite(elevation)) {
continue;
}
double distanceToCentre = squaredDistanceDeg(grid.get(i), centre);
if (elevation > bestElevation
|| (elevation == bestElevation && distanceToCentre < bestDistanceToCentre)) {
bestIndex = i;
bestElevation = elevation;
bestDistanceToCentre = distanceToCentre;
}
}
if (bestIndex < 0) {
return null;
}
PathGeometryUtils.GeoPoint best = grid.get(bestIndex);
return new HighestPoint(square, best.latitudeDeg(), best.longitudeDeg(), bestElevation);
}
private static double squaredDistanceDeg(PathGeometryUtils.GeoPoint a, PathGeometryUtils.GeoPoint b) {
// Only used to order samples of one small square, so a flat approximation with the
// longitude scaled by the square's own aspect is enough.
double latitudeDelta = (a.latitudeDeg() - b.latitudeDeg()) / SQUARE_HEIGHT_DEG;
double longitudeDelta = (a.longitudeDeg() - b.longitudeDeg()) / SQUARE_WIDTH_DEG;
return latitudeDelta * latitudeDelta + longitudeDelta * longitudeDelta;
}
private static String normalize(String locator6) {
if (locator6 == null) {
return null;
}
String square = locator6.trim().toUpperCase(Locale.ROOT);
return LOCATOR6.matcher(square).matches() ? square : null;
}
}
@@ -28,8 +28,14 @@ import java.util.stream.Collectors;
* deliberately local and lightweight: it avoids repeated API calls when the user
* re-selects the same station, but it does not replace the existing long-term
* offline DEM/download architecture.</p>
*
* <p>The same endpoint also answers {@link TerrainElevationProvider} lookups for
* arbitrary point sets, such as the grid used to find the highest point of a locator
* square. Those go through the same rate limiter and are packed into as few requests
* as the 100-coordinate limit allows; caching them is left to the caller, which knows
* what the points stand for.</p>
*/
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider {
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider, TerrainElevationProvider {
private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90";
private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation";
@@ -113,6 +119,44 @@ public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProv
return profileData;
}
@Override
public List<Double> loadElevations(List<PathGeometryUtils.GeoPoint> points) {
if (points == null || points.isEmpty()) {
return List.of();
}
List<Double> elevations = new ArrayList<>(points.size());
try {
for (int start = 0; start < points.size(); start += MAX_COORDINATES_PER_REQUEST) {
int end = Math.min(points.size(), start + MAX_COORDINATES_PER_REQUEST);
List<SamplePoint> chunk = new ArrayList<>(end - start);
for (PathGeometryUtils.GeoPoint point : points.subList(start, end)) {
if (point == null
|| !Double.isFinite(point.latitudeDeg())
|| !Double.isFinite(point.longitudeDeg())) {
return List.of();
}
chunk.add(new SamplePoint(0.0, point.latitudeDeg(), point.longitudeDeg()));
}
List<Double> chunkElevations = fetchElevations(chunk);
if (chunkElevations.size() != chunk.size()) {
return List.of();
}
elevations.addAll(chunkElevations);
}
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
return List.of();
} catch (Exception exception) {
return List.of();
}
return elevations;
}
private int resolveOnlineSampleCount(int requestedSampleCount) {
return Math.max(
MIN_ONLINE_PROFILE_SAMPLE_COUNT,
@@ -236,6 +280,7 @@ public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProv
return parseElevationArray(response.body());
}
// Sleeps while holding the lock: must never be called from the UI thread.
private synchronized void waitForRateLimitSlot() throws InterruptedException {
long now = System.currentTimeMillis();
long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis);
@@ -4,6 +4,15 @@ import java.util.Locale;
/**
* Immutable input for one path analysis run.
*
* <p>The two ground elevation overrides replace the terrain elevation the profile
* provider reports at the respective endpoint. {@code NaN} means "no override": the
* elevation model decides, as it always did.</p>
*
* @param homeGroundElevationOverrideMeters own ground elevation above mean sea level
* entered by the operator, or {@code NaN}
* @param targetGroundElevationOverrideMeters remote ground elevation already known to the
* caller (the highest point of its locator square), or {@code NaN}
*/
public record PathAnalysisRequest(
String fromLocator6,
@@ -17,9 +26,44 @@ public record PathAnalysisRequest(
double homeAntennaHeightMeters,
double targetAntennaHeightMeters,
double effectiveEarthRadiusFactor,
PathLinkBudgetSettings linkBudgetSettings
PathLinkBudgetSettings linkBudgetSettings,
double homeGroundElevationOverrideMeters,
double targetGroundElevationOverrideMeters
) {
/**
* A request without ground elevation overrides.
*/
public PathAnalysisRequest(String fromLocator6,
double fromLatitudeDeg,
double fromLongitudeDeg,
String toCallsignRaw,
String toLocator6,
double toLatitudeDeg,
double toLongitudeDeg,
double frequencyMHz,
double homeAntennaHeightMeters,
double targetAntennaHeightMeters,
double effectiveEarthRadiusFactor,
PathLinkBudgetSettings linkBudgetSettings) {
this(
fromLocator6,
fromLatitudeDeg,
fromLongitudeDeg,
toCallsignRaw,
toLocator6,
toLatitudeDeg,
toLongitudeDeg,
frequencyMHz,
homeAntennaHeightMeters,
targetAntennaHeightMeters,
effectiveEarthRadiusFactor,
linkBudgetSettings,
Double.NaN,
Double.NaN
);
}
public PathAnalysisRequest(String fromLocator6,
double fromLatitudeDeg,
double fromLongitudeDeg,
@@ -63,7 +107,15 @@ public record PathAnalysisRequest(
targetAntennaHeightMeters = Double.NaN;
}
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
effectiveEarthRadiusFactor = PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
if (!Double.isFinite(homeGroundElevationOverrideMeters)) {
homeGroundElevationOverrideMeters = Double.NaN;
}
if (!Double.isFinite(targetGroundElevationOverrideMeters)) {
targetGroundElevationOverrideMeters = Double.NaN;
}
linkBudgetSettings = linkBudgetSettings == null
? PathLinkBudgetSettings.defaults()
@@ -96,6 +148,36 @@ public record PathAnalysisRequest(
&& Double.isFinite(toLongitudeDeg);
}
/**
* Returns a copy whose remote endpoint is moved to the given position and ground
* elevation. Locator and callsign stay, because the station is still the same.
*
* @param latitudeDeg new remote latitude
* @param longitudeDeg new remote longitude
* @param groundElevationMeters ground elevation there, or {@code NaN} for "ask the model"
* @return the moved request
*/
public PathAnalysisRequest withTargetEndpoint(double latitudeDeg,
double longitudeDeg,
double groundElevationMeters) {
return new PathAnalysisRequest(
fromLocator6,
fromLatitudeDeg,
fromLongitudeDeg,
toCallsignRaw,
toLocator6,
latitudeDeg,
longitudeDeg,
frequencyMHz,
homeAntennaHeightMeters,
targetAntennaHeightMeters,
effectiveEarthRadiusFactor,
linkBudgetSettings,
homeGroundElevationOverrideMeters,
groundElevationMeters
);
}
public boolean hasUsableFrequency() {
return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0;
}
@@ -0,0 +1,27 @@
package kst4contest.view.map;
import java.util.List;
/**
* Abstraction for looking up terrain elevations at arbitrary points.
*
* <p>{@link TerrainProfileProvider} only samples along a great circle between two
* endpoints. Some questions need a set of points that is not a path, such as a grid
* over a locator square, and a provider that can answer many points in one request
* should be asked for them together instead of point by point.</p>
*/
public interface TerrainElevationProvider {
/**
* Loads the terrain elevation for every point.
*
* <p>Implementations may block on network or disk access, so this must not be called
* on the user-interface thread.</p>
*
* @param points points to look up
* @return elevations in metres above mean sea level, in the order of {@code points};
* {@code NaN} for a point without data. An empty list means the lookup failed
* as a whole.
*/
List<Double> loadElevations(List<PathGeometryUtils.GeoPoint> points);
}
@@ -0,0 +1,115 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity;
import org.junit.jupiter.api.Test;
import static kst4contest.controller.ChatRoute.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ChatFrameDecisionTest {
@Test
void frameFieldsAreReadFromTheTraditionalIndices() {
InboundChatFrame frame = InboundChatFrame.of(
"CH|2|1700000000|DL1ABC|Hans|0|qrv 144.300|DN9APW|".split("\\|"));
assertEquals(new InboundChatFrame("2", "1700000000", "DL1ABC", "Hans", "qrv 144.300", "DN9APW"), frame);
}
@Test
void receiverZeroIsPublic() {
assertEquals(PUBLIC, MessageBusManagementThread.classifyRoute("0", "DL1ABC", null, "DN9APW"));
}
@Test
void toMeComparesCaseSensitively() {
assertEquals(TO_ME, MessageBusManagementThread.classifyRoute("DN9APW", "DL1ABC", "DN9APW", "DN9APW"));
assertEquals(BETWEEN_OTHERS, MessageBusManagementThread.classifyRoute("dn9apw", "DL1ABC", "dn9apw", "DN9APW"));
}
@Test
void fromMeComparesCaseInsensitively() {
assertEquals(FROM_ME, MessageBusManagementThread.classifyRoute("DL1ABC", "dn9apw", "DL1ABC", "DN9APW"));
}
@Test
void toMeWinsOverFromMe() {
assertEquals(TO_ME, MessageBusManagementThread.classifyRoute("DN9APW", "DN9APW", "DN9APW", "DN9APW"));
}
@Test
void anythingElseIsBetweenOthers() {
assertEquals(BETWEEN_OTHERS, MessageBusManagementThread.classifyRoute("DL2XYZ", "DL1ABC", "DL2XYZ", "DN9APW"));
}
private static final MessageBusManagementThread.ChatFrameSettings ALL_ON =
new MessageBusManagementThread.ChatFrameSettings(true, true, true, true, "/CQ DL1ABC [auto] QRG is: 144.300");
@Test
void publicAndFromMeDoNothing() {
for (boolean live : new boolean[] { true, false }) {
assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(PUBLIC, live, "//ver", null, false, ALL_ON));
assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(FROM_ME, live, "//ver", null, false, ALL_ON));
}
}
@Test
void liveMessageToMeRingsAnswersAndReplies() {
ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, "pse //Bell //ver", null, false, ALL_ON);
assertEquals(new ChatMessageEffects(true, true, true, true, true, "/CQ DL1ABC [auto] QRG is: 144.300", null, false, false), effects);
}
@Test
void bellNeedsSimpleSounds() {
var noSounds = new MessageBusManagementThread.ChatFrameSettings(false, false, false, false, null);
ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, "//bell", null, false, noSounds);
assertEquals(false, effects.playBell());
assertEquals(false, effects.playPmSound());
}
@Test
void anAutomaticMessageIsNeverAnswered() {
String auto = ApplicationConstants.AUTOANSWER_PREFIX + " QRG is: 432.100";
assertEquals(null, MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, auto, null, false, ALL_ON).autoAnswerText());
}
@Test
void historyMessageToMeDoesNothing() {
assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(TO_ME, false, "//bell //ver", null, false, ALL_ON));
}
@Test
void liveOpportunityTicksOnceAndSpots() {
ChatMessageEffects first = MessageBusManagementThread.decideChatFrameEffects(
BETWEEN_OTHERS, true, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, false, ALL_ON);
ChatMessageEffects again = MessageBusManagementThread.decideChatFrameEffects(
BETWEEN_OTHERS, true, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, true, ALL_ON);
assertEquals(Boolean.TRUE, first.inAngleAndRange());
assertEquals(true, first.playDirectionalTick());
assertEquals(true, first.evaluateDxClusterSpot());
assertEquals(false, again.playDirectionalTick());
}
@Test
void historyOpportunityOnlySetsTheFlag() {
ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(
BETWEEN_OTHERS, false, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, false, ALL_ON);
assertEquals(new ChatMessageEffects(false, false, false, false, false, null, Boolean.TRUE, false, false), effects);
}
@Test
void outOfAngleOrMissingLocatorClearsTheFlag() {
for (DirectionalOpportunity o : new DirectionalOpportunity[] {
DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE, DirectionalOpportunity.MISSING_LOCATOR }) {
ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(BETWEEN_OTHERS, true, "", o, true, ALL_ON);
assertEquals(Boolean.FALSE, effects.inAngleAndRange());
assertEquals(false, effects.playDirectionalTick());
assertEquals(false, effects.evaluateDxClusterSpot());
}
}
}
@@ -0,0 +1,425 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import kst4contest.ApplicationConstants;
import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity;
import kst4contest.controller.MessageBusManagementThread.DxClusterSpotDecision;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.SimpleValue;
import kst4contest.utils.PlayAudioUtils;
/**
* Characterises the live ({@code CH}) and history ({@code CR}) chat-frame branches
* of {@link MessageBusManagementThread} by running real frames through a started
* thread. The history branch must publish like the live one but never play sounds,
* answer, tick or spot.
*
* <p>Frame layout, as split on {@code |} by the thread:
* {@code <CH|CR>|category|epoch|senderCall|senderName|0|text|receiverCall|}; a
* receiver of {@code 0} marks a public message.</p>
*/
class MessageBusManagementThreadChatFrameTest {
private static final String MY_CALL = "DN9APW";
private static final String REMOTE_CALL = "DL1ABC";
private static final String OTHER_CALL = "DL2XYZ";
private static final String FIRST_QRG = "144.300";
// Local station between a western sender and an eastern receiver, all on one latitude.
private static final String OWN_LOCATOR = "JO50LL";
private static final String SENDER_LOCATOR = "JO30LL";
private static final String RECEIVER_LOCATOR = "JO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
@TempDir
Path tempDir;
private ChatController controller;
private ChatPreferences prefs;
private PlayAudioUtils playAudioUtils;
private DXClusterThreadPooledServer dxClusterServer;
private LinkedBlockingQueue<ChatMessage> txBus;
private LinkedBlockingQueue<ChatMessage> rxQueue;
private Map<String, ChatMember> activeMembers;
@BeforeEach
void setUp() {
prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setStn_loginCallSign(MY_CALL);
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true);
prefs.setMessageHandling_autoAnswerEnabled(false);
prefs.setMYQRGFirstCat(FIRST_QRG);
prefs.setMYQRGSecondCat("1296.200");
ChatCategory mainCategory = new ChatCategory(ChatCategory.VUHF);
ChatCategory secondCategory = new ChatCategory(ChatCategory.MICROWAVE);
txBus = new LinkedBlockingQueue<>();
rxQueue = new LinkedBlockingQueue<>();
activeMembers = new HashMap<>();
playAudioUtils = mock(PlayAudioUtils.class);
dxClusterServer = mock(DXClusterThreadPooledServer.class);
controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.getMessageTXBus()).thenReturn(txBus);
when(controller.getChatCategoryMain()).thenReturn(mainCategory);
when(controller.getChatCategorySecondChat()).thenReturn(secondCategory);
when(controller.getPlayAudioUtils()).thenReturn(playAudioUtils);
when(controller.getStationMetricsService())
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer()).thenReturn(dxClusterServer);
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
private void enableAllPrivateMessageSounds() {
prefs.setNotify_playSimpleSounds(true);
prefs.setNotify_playCWCallsignsOnRxedPMs(true);
prefs.setNotify_playVoiceCallsignsOnRxedPMs(true);
}
// --- addressed to me ----------------------------------------------------
@Test
@Timeout(10)
void liveMessageToMePlaysSoundsAndAnswersVersion() throws InterruptedException {
enableAllPrivateMessageSounds();
runThrough(chatFrame("CH", REMOTE_CALL, "//ver //bell", MY_CALL));
verify(playAudioUtils).playNoiseLauncher('P');
verify(playAudioUtils).playCWLauncher(" " + REMOTE_CALL);
verify(playAudioUtils).playVoiceLauncher("?" + REMOTE_CALL);
verify(playAudioUtils).playVoiceLauncher("!");
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF",
txBus.peek().getMessageText());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void historyMessageToMePublishesOnly() throws InterruptedException {
enableAllPrivateMessageSounds();
runThrough(chatFrame("CR", REMOTE_CALL, "//ver //bell ur qrg?", MY_CALL));
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
assertTrue(txBus.isEmpty());
verifyNoInteractions(playAudioUtils);
}
// --- sent by me ---------------------------------------------------------
@Test
@Timeout(10)
void liveMessageFromMeIsMarkedWithTheReceiver() throws InterruptedException {
runThrough(chatFrame("CH", MY_CALL.toLowerCase(), "hello", REMOTE_CALL));
assertEquals("(>" + REMOTE_CALL + ")hello", singlePublished().getMessageText());
}
@Test
@Timeout(10)
void historyMessageFromMeIsMarkedWithTheReceiver() throws InterruptedException {
runThrough(chatFrame("CR", MY_CALL.toLowerCase(), "hello", REMOTE_CALL));
assertEquals("(>" + REMOTE_CALL + ")hello", singlePublished().getMessageText());
}
/**
* Pinned: the receiver is resolved case-insensitively against the login call and
* {@code ChatMember#setCallSign} upper-cases it, so a lower-case receiver in the
* frame still counts as addressed to me. (Only a lower-case login call defeats
* the case-sensitive TO_ME comparison, see the auto-answer characterisation.)
*/
@Test
@Timeout(10)
void receiverInDifferentCaseIsStillTreatedAsMe() throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "ur qrg?", MY_CALL.toLowerCase()));
assertEquals(1, txBus.size());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
// --- between two other stations ----------------------------------------
@Test
@Timeout(10)
void liveMessageBetweenOthersInAngleTicksAndSpots() throws InterruptedException {
ChatMember sender = prepareInAngleScene();
prefs.setNotify_playSimpleSounds(true);
prefs.setNotify_dxClusterServerEnabled(true);
runThrough(chatFrame("CH", REMOTE_CALL, "hello", OTHER_CALL));
verify(playAudioUtils).playNoiseLauncher('-');
ArgumentCaptor<ChatMember> spot = ArgumentCaptor.forClass(ChatMember.class);
verify(dxClusterServer, times(1)).broadcastSingleDXClusterEntryToLoggers(spot.capture());
assertEquals(REMOTE_CALL, spot.getValue().getCallSign());
assertEquals(FIRST_QRG, spot.getValue().getFrequency().get());
assertTrue(sender.isInAngleAndRange());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void historyMessageBetweenOthersInAngleOnlySetsTheFlag() throws InterruptedException {
ChatMember sender = prepareInAngleScene();
prefs.setNotify_playSimpleSounds(true);
prefs.setNotify_dxClusterServerEnabled(true);
runThrough(chatFrame("CR", REMOTE_CALL, "hello", OTHER_CALL));
assertTrue(sender.isInAngleAndRange());
verify(playAudioUtils, never()).playNoiseLauncher('-');
verify(dxClusterServer, never()).broadcastSingleDXClusterEntryToLoggers(any());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
// --- SERVER sender ------------------------------------------------------
@Test
@Timeout(10)
void liveServerSenderGetsAirScoutInfo() throws InterruptedException {
runThrough(chatFrame("CH", "SERVER", "welcome", "0"));
ChatMessage live = singlePublished();
assertEquals("SERVER", live.getSender().getCallSign());
assertEquals("Sysop", live.getSender().getName());
assertNotNull(live.getSender().getAirPlaneReflectInfo());
}
@Test
@Timeout(10)
void historyServerSenderGetsNoAirScoutInfo() throws InterruptedException {
runThrough(chatFrame("CR", "SERVER", "welcome", "0"));
ChatMessage history = singlePublished();
assertEquals("SERVER", history.getSender().getCallSign());
assertEquals("Sysop", history.getSender().getName());
assertNull(history.getSender().getAirPlaneReflectInfo());
}
// --- frequency detection order -----------------------------------------
@Test
@Timeout(10)
void liveDetectsFrequencyBeforePublishingHistoryAfter() throws InterruptedException {
stubFrequencyDetection();
List<Boolean> knownAtPublish = new ArrayList<>();
List<ChatMember> senders = new ArrayList<>();
doAnswer(invocation -> {
ChatMessage published = invocation.getArgument(0);
senders.add(published.getSender());
knownAtPublish.add(hasFrequency(published.getSender()));
return null;
}).when(controller).publishChatMessage(any(ChatMessage.class));
runThrough(chatFrame("CH", REMOTE_CALL, "qrv 144.300", "0"));
assertEquals(List.of(true), knownAtPublish);
knownAtPublish.clear();
senders.clear();
runThrough(chatFrame("CR", REMOTE_CALL, "qrv 144.300", "0"));
assertEquals(List.of(false), knownAtPublish);
assertTrue(hasFrequency(senders.get(0)), "known once the thread has finished");
}
@Test
@Timeout(10)
void liveFrequencyFromADirectedMessageIsAvailableForTheSpot() throws InterruptedException {
stubFrequencyDetection();
ChatMember sender = prepareInAngleSceneWithoutFrequency();
prefs.setNotify_dxClusterServerEnabled(true);
List<Boolean> knownAtSpot = new ArrayList<>();
doAnswer(invocation -> {
knownAtSpot.add(hasFrequency(invocation.<ChatMember>getArgument(0)));
return null;
}).when(dxClusterServer).broadcastSingleDXClusterEntryToLoggers(any());
runThrough(chatFrame("CH", REMOTE_CALL, "qrv 144.300", OTHER_CALL));
assertEquals(List.of(true), knownAtSpot);
assertTrue(sender.isInAngleAndRange());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void historyFrequencyFromADirectedMessageComesAfterPublishingWithoutSpot()
throws InterruptedException {
stubFrequencyDetection();
ChatMember sender = prepareInAngleSceneWithoutFrequency();
prefs.setNotify_dxClusterServerEnabled(true);
List<Boolean> knownAtPublish = new ArrayList<>();
doAnswer(invocation -> {
knownAtPublish.add(hasFrequency(invocation.<ChatMessage>getArgument(0).getSender()));
return null;
}).when(controller).publishChatMessage(any(ChatMessage.class));
runThrough(chatFrame("CR", REMOTE_CALL, "qrv 144.300", OTHER_CALL));
assertEquals(List.of(false), knownAtPublish);
assertTrue(hasFrequency(sender), "known once the thread has finished");
assertTrue(sender.isInAngleAndRange());
verify(dxClusterServer, never()).broadcastSingleDXClusterEntryToLoggers(any());
}
// ----------------------------------------------------------------------
/** The controller is a mock: mimic what the real one does to the sender. */
private void stubFrequencyDetection() {
doAnswer(invocation -> {
ChatMember sender = invocation.getArgument(0);
sender.setFrequency(new SimpleValue<>(String.valueOf((double) invocation.getArgument(2))));
return null;
}).when(controller).applyDetectedFrequencyToActiveMembers(
any(ChatMember.class), any(Band.class), anyDouble());
}
/** In-angle scene whose sender has no known frequency yet. */
private ChatMember prepareInAngleSceneWithoutFrequency() {
prefs.setStn_loginLocatorMainCat(OWN_LOCATOR);
prefs.setStn_maxQRBDefault(MAX_QRB_KM);
prefs.setStn_antennaBeamWidthDeg(BEAM_WIDTH_DEG);
ChatMember sender = knownMember(REMOTE_CALL, SENDER_LOCATOR);
knownMember(OTHER_CALL, RECEIVER_LOCATOR);
assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN_LOCATOR, SENDER_LOCATOR, RECEIVER_LOCATOR, MAX_QRB_KM, BEAM_WIDTH_DEG));
assertFalse(hasFrequency(sender));
assertFalse(sender.isInAngleAndRange());
return sender;
}
/**
* Registers sender and receiver with locators that form an in-angle opportunity
* and a spottable sender; the setup is checked with the pure functions first.
*/
private ChatMember prepareInAngleScene() {
prefs.setStn_loginLocatorMainCat(OWN_LOCATOR);
prefs.setStn_maxQRBDefault(MAX_QRB_KM);
prefs.setStn_antennaBeamWidthDeg(BEAM_WIDTH_DEG);
ChatMember sender = knownMember(REMOTE_CALL, SENDER_LOCATOR);
sender.setFrequency(new SimpleValue<>(FIRST_QRG));
knownMember(OTHER_CALL, RECEIVER_LOCATOR);
assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN_LOCATOR, SENDER_LOCATOR, RECEIVER_LOCATOR, MAX_QRB_KM, BEAM_WIDTH_DEG));
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(sender));
assertFalse(sender.isInAngleAndRange());
return sender;
}
private ChatMember knownMember(String callSign, String locator) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setQra(locator);
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
activeMembers.put(callSign, member);
return member;
}
private static boolean hasFrequency(ChatMember member) {
return member.getFrequency() != null
&& member.getFrequency().get() != null
&& !member.getFrequency().get().isBlank();
}
private ChatMessage singlePublished() {
ArgumentCaptor<ChatMessage> published = ArgumentCaptor.forClass(ChatMessage.class);
verify(controller, times(1)).publishChatMessage(published.capture());
return published.getValue();
}
private void runThrough(ChatMessage... frames) throws InterruptedException {
MessageBusManagementThread thread = new MessageBusManagementThread(
controller,
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
rxQueue.add(poisonPill());
thread.start();
thread.join();
}
private static ChatMessage chatFrame(
String opcode, String senderCall, String text, String receiverCall) {
return message(opcode + "|2|1700000000|" + senderCall + "|Hans|0|"
+ text + "|" + receiverCall + "|");
}
private static ChatMessage message(String text) {
ChatMessage message = new ChatMessage();
message.setMessageText(text);
return message;
}
private static ChatMessage poisonPill() {
ChatMessage message = message(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
return message;
}
/** Mimics the active-member lookup: registered stations as-is, others with a default locator. */
private ChatMember activeMemberFor(ChatMember lookup) {
ChatMember registered = activeMembers.get(lookup.getCallSign());
if (registered != null) {
return registered;
}
ChatMember member = new ChatMember();
member.setCallSign(lookup.getCallSign());
member.setChatCategory(lookup.getChatCategory());
member.setQra("JO31AA");
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
}
}
@@ -0,0 +1,61 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static kst4contest.controller.RadioSlot.FIRST;
import static kst4contest.controller.RadioSlot.SECOND;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Every row of the spec's two routing tables. */
class RadioSlotRouterTest {
@Test
void withoutSo2rEveryRadioInfoGoesToRadioOne() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, null));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "x"));
}
@Test
void withSo2rTheRadioNumberDecides() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, "1"));
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, "2"));
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, " 2 "));
}
@Test
void withSo2rAMissingRadioNumberIsRadioOne() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, null));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, " "));
}
@Test
void withSo2rOtherRadioNumbersAreDropped() {
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "3"));
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "0"));
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "x"));
}
@Test
void withoutSo2rWinTestKeepsTheOldFilter() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "STN1", "stn1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "ANY", "", "STN2"));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(false, "STN2", "STN1", "STN2"));
}
@Test
void withSo2rTheSecondNameWinsThenTheFirst() {
assertEquals(Optional.of(SECOND), RadioSlotRouter.forWinTestStation(true, "stn2", "STN1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN1", "STN1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "ANY", "", "STN2"));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN3", "STN1", "STN2"));
}
@Test
void withSo2rAnEmptySecondNameSendsNothingToRadioTwo() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN2", "", ""));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN2", "STN1", ""));
}
}
@@ -0,0 +1,179 @@
package kst4contest.controller;
import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatPreferences;
import kst4contest.view.map.LocatorHighestPointResolver;
import kst4contest.view.map.PathAnalysisRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* How {@link ReachabilityService} turns the station preferences into a path-analysis
* request, and which inputs reach the key that decides whether a cached result is reused.
*/
class ReachabilityServiceRequestTest {
private static final String OWN = "JN49FK";
private static final String TARGET = "JO50AE";
private static final double TARGET_CENTRE_LAT = Location.extractLat(TARGET).toDegrees();
private static final double TARGET_CENTRE_LON = Location.extractLon(TARGET).toDegrees();
private static final LocatorHighestPointResolver.HighestPoint PEAK =
new LocatorHighestPointResolver.HighestPoint(TARGET, TARGET_CENTRE_LAT + 0.01, TARGET_CENTRE_LON - 0.02, 640.0);
private static final Function<String, LocatorHighestPointResolver.HighestPoint> NOTHING_CACHED = locator -> null;
private static final Function<String, LocatorHighestPointResolver.HighestPoint> PEAK_CACHED =
locator -> TARGET.equals(locator) ? PEAK : null;
@TempDir
Path directory;
private ChatPreferences preferences() {
return new ChatPreferences(directory.resolve("preferences.xml"));
}
private static PathAnalysisRequest build(ChatPreferences preferences,
Function<String, LocatorHighestPointResolver.HighestPoint> cache) {
return ReachabilityService.buildRequest(
preferences, OWN, "DL0TEST", TARGET, TARGET_CENTRE_LAT, TARGET_CENTRE_LON, 144.3, cache);
}
@Test
void withoutAnOwnHeightTheElevationModelDecides() {
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
assertTrue(Double.isNaN(request.homeGroundElevationOverrideMeters()));
}
@Test
void theOwnHeightAboveSeaLevelReachesTheRequest() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
PathAnalysisRequest request = build(preferences, NOTHING_CACHED);
assertEquals(312.0, request.homeGroundElevationOverrideMeters(), 1e-12);
assertEquals(Location.extractLat(OWN).toDegrees(), request.fromLatitudeDeg(), 1e-12,
"the own position stays the locator centre");
}
@Test
void aCachedHighestPointMovesTheTarget() {
PathAnalysisRequest request = build(preferences(), PEAK_CACHED);
assertEquals(PEAK.latitudeDeg(), request.toLatitudeDeg(), 1e-12);
assertEquals(PEAK.longitudeDeg(), request.toLongitudeDeg(), 1e-12);
assertEquals(640.0, request.targetGroundElevationOverrideMeters(), 1e-12);
assertEquals(TARGET, request.toLocator6());
assertFalse(ReachabilityService.needsHighestPointLookup(request));
}
@Test
void anUnresolvedSquareKeepsTheCentreForTheBackgroundLookup() {
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
assertEquals(TARGET_CENTRE_LAT, request.toLatitudeDeg(), 1e-12);
assertEquals(TARGET_CENTRE_LON, request.toLongitudeDeg(), 1e-12);
assertTrue(ReachabilityService.needsHighestPointLookup(request));
}
@Test
void switchedOffTheTargetStaysOnTheCentreEvenWhenThePeakIsKnown() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisDxHighestPointInSquare(false);
PathAnalysisRequest request = build(preferences, PEAK_CACHED);
assertEquals(TARGET_CENTRE_LAT, request.toLatitudeDeg(), 1e-12);
assertEquals(TARGET_CENTRE_LON, request.toLongitudeDeg(), 1e-12);
assertTrue(Double.isNaN(request.targetGroundElevationOverrideMeters()));
}
@Test
void theHighestPointIsOnByDefault() {
assertTrue(preferences().isStn_pathAnalysisDxHighestPointInSquare());
}
@Test
void theOwnHeightChangesTheKey() {
ChatPreferences preferences = preferences();
String withoutHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
String withHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
preferences.setStn_pathAnalysisOwnHeightAslMeters(313.0);
String withOtherHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
assertNotEquals(withoutHeight, withHeight);
assertNotEquals(withHeight, withOtherHeight);
}
/* One decimal used to be the key precision, so 123.04 and 123.00 m shared a result. */
@Test
void theOwnHeightIsKeyedAtFullPrecision() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisOwnHeightAslMeters(123.00);
String even = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
preferences.setStn_pathAnalysisOwnHeightAslMeters(123.04);
String odd = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
assertNotEquals(even, odd);
}
@Test
void theTargetGroundElevationIsKeyedAtFullPrecision() {
LocatorHighestPointResolver.HighestPoint lower =
new LocatorHighestPointResolver.HighestPoint(TARGET, PEAK.latitudeDeg(), PEAK.longitudeDeg(), 640.00);
LocatorHighestPointResolver.HighestPoint higher =
new LocatorHighestPointResolver.HighestPoint(TARGET, PEAK.latitudeDeg(), PEAK.longitudeDeg(), 640.04);
assertNotEquals(
ReachabilityService.buildCalculationKey(build(preferences(), locator -> lower), true),
ReachabilityService.buildCalculationKey(build(preferences(), locator -> higher), true));
}
/*
* Before its square is resolved, a request looks the same with the switch on or off:
* both stand on the centre. Only the switch in the key keeps a centre result from
* answering a request that asks for the highest point, and the other way round.
*/
@Test
void theSwitchChangesTheKeyEvenForTheSameCentreRequest() {
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
assertNotEquals(
ReachabilityService.buildCalculationKey(request, true),
ReachabilityService.buildCalculationKey(request, false));
}
@Test
void theResolvedTargetChangesTheKey() {
ChatPreferences preferences = preferences();
String centre = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
String peak = ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true);
assertNotEquals(centre, peak);
}
@Test
void theSameInputsGiveTheSameKey() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
assertEquals(
ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true),
ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true));
}
}
@@ -18,7 +18,9 @@ import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
@@ -304,6 +306,65 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
.anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error")));
}
/** Records what radio 2 receives. */
private final List<String> secondQrgWrites = new CopyOnWriteArrayList<>();
private final SimpleValue<String> secondQrg = new SimpleValue<>("");
@Test
void withSo2rRadioTwoGoesToTheSecondQrgAndLeavesMyQrgAlone(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertEquals(List.of("432.200.00"), secondQrgWrites);
assertEquals(List.of("1296.200.00"), firstWrites, "only the marker may write MYQRG");
}
@Test
void withSo2rARadioInfoWithoutRadioNumberIsRadioOne(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><Freq>14420000</Freq><Mode>CW</Mode></RadioInfo>");
assertEquals(List.of("144.200.00", "1296.200.00"), firstWrites);
assertTrue(secondQrgWrites.isEmpty());
}
@Test
void withSo2rAnUnknownRadioNumberIsDroppedAndTheListenerKeepsRunning(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
sendThenAwaitMarker("<RadioInfo><RadioNr>x</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertTrue(secondQrgWrites.isEmpty());
}
@Test
void withoutSo2rRadioTwoStillWritesMyQrg(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(false);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertEquals(List.of("432.200.00", "1296.200.00"), firstWrites);
assertTrue(secondQrgWrites.isEmpty());
}
private void givenSo2r(boolean enabled) {
when(preferences.isTrxSynch_so2rEnabled()).thenReturn(enabled);
secondQrg.addListener(secondQrgWrites::add);
when(preferences.getMYQRGSecondCat()).thenReturn(secondQrg);
}
private CountDownLatch markerLatch() {
CountDownLatch marker = new CountDownLatch(1);
markerQrg.addListener(value -> {
@@ -0,0 +1,120 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Delayed;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* The rotator command rules moved out of ChatController unchanged: the 1 degree
* "reached" tolerance, no retry after reported movement, and the compatibility retry
* when nothing moved. The scheduler is captured, so the delayed check runs on demand.
*/
class RotatorControlTest {
private final List<String> commands = new ArrayList<>();
private final List<Double> reported = new ArrayList<>();
private final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
private Runnable pendingRetry;
private RotatorControl control() {
when(scheduler.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> {
pendingRetry = call.getArgument(0);
return mock(ScheduledFuture.class);
});
RotatorControl control = new RotatorControl(
"PSTRotator",
ignored -> new RotatorControl.Link() {
@Override public void setTrackingMode(boolean on) { commands.add("tracking " + on); }
@Override public void setAzimuth(double azimuth) { commands.add("az " + azimuth); }
@Override public void stopRotation() { commands.add("halt"); }
@Override public void stop() { commands.add("stop"); }
},
scheduler,
reported::add);
control.start();
return control;
}
@Test
void rotateSendsTheNormalisedTargetAndSchedulesTheCheckAfterTwoSeconds() {
RotatorControl control = control();
control.rotateTo(-10.0);
assertEquals(List.of("tracking false", "az 350.0"), commands);
verify(scheduler).schedule(any(Runnable.class), eq(2L), eq(TimeUnit.SECONDS));
}
@Test
void aReportWithinOneDegreeCountsAsReachedAndSendsNoRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(145.7);
control.onAzimuthUpdate(145.0);
commands.clear();
pendingRetry.run();
assertTrue(commands.isEmpty());
}
@Test
void reportedMovementSendsNoRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(200.0);
control.onAzimuthUpdate(120.0);
commands.clear();
pendingRetry.run();
assertTrue(commands.isEmpty());
}
@Test
void noMovementSendsTheCompatibilityRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(200.0);
commands.clear();
pendingRetry.run();
assertEquals(List.of("az 0.0", "az 200.0"), commands);
}
@Test
void reportsAreNormalisedAndPassedOnAndInvalidOnesIgnored() {
RotatorControl control = control();
control.onAzimuthUpdate(370.0);
control.onAzimuthUpdate(Double.NaN);
assertEquals(List.of(10.0), reported);
assertEquals(10.0, control.lastReportedAzimuth());
}
@Test
void stopClosesTheLinkForgetsThePositionAndIgnoresLaterCommands() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.stop();
control.rotateTo(50.0);
assertEquals(List.of("stop"), commands);
assertFalse(control.isRunning());
assertTrue(Double.isNaN(control.lastReportedAzimuth()));
}
}
@@ -0,0 +1,40 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class SecondRotatorLifecycleTest {
@TempDir
Path directory;
@Test
void rotatorTwoIsWantedOnlyWithSo2rAndItsOwnSwitch() {
ChatPreferences prefs = new ChatPreferences(directory.resolve("p.xml"));
assertFalse(ChatController.secondRotatorWanted(prefs));
prefs.setStn_pstRotator2Enabled(true);
assertFalse(ChatController.secondRotatorWanted(prefs), "rotator 2 without SO2R");
prefs.setTrxSynch_so2rEnabled(true);
assertTrue(ChatController.secondRotatorWanted(prefs));
prefs.setStn_pstRotator2Enabled(false);
assertFalse(ChatController.secondRotatorWanted(prefs));
}
@Test
void sessionRunsOnlyInStatesBetweenStartAndStop() {
assertFalse(ChatController.sessionRunning(null));
assertFalse(ChatController.sessionRunning(On4KstConnectionState.DISCONNECTED));
assertFalse(ChatController.sessionRunning(On4KstConnectionState.STOPPING));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.CONNECTING));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.ONLINE));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.RECONNECT_WAIT));
}
}
@@ -0,0 +1,60 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** A Win-Test STATUS of the second station writes SECONDQRG when SO2R is on. */
class WinTestStatusSlotTest {
/* STATUS layout as parseStatus reads it: token 1 station, 5 mode, 7 frequency in 100 Hz. */
private static final String STATUS_STN2 = "STATUS: \"STN2\" \"\" 0 12 0 0 4322000 0";
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences prefs = mock(ChatPreferences.class);
private final List<String> first = new ArrayList<>();
private final List<String> second = new ArrayList<>();
private static SimpleValue<String> recording(List<String> writes) {
SimpleValue<String> value = new SimpleValue<>("");
value.addListener(writes::add);
return value;
}
private ReadUDPByWintestThread thread(boolean so2r) {
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
when(prefs.isLogsynch_wintestQrgSyncEnabled()).thenReturn(true);
when(prefs.isTrxSynch_so2rEnabled()).thenReturn(so2r);
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()).thenReturn("STN1");
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2()).thenReturn("STN2");
when(prefs.getMYQRGFirstCat()).thenReturn(recording(first));
when(prefs.getMYQRGSecondCat()).thenReturn(recording(second));
return new ReadUDPByWintestThread(controller, mock(ThreadStatusCallback.class));
}
@Test
void withSo2rTheSecondStationWritesTheSecondQrg() {
thread(true).parseStatus(STATUS_STN2);
assertEquals(1, second.size());
assertTrue(first.isEmpty());
}
@Test
void withoutSo2rTheSecondStationIsFilteredOutAsBefore() {
thread(false).parseStatus(STATUS_STN2);
assertTrue(first.isEmpty());
assertTrue(second.isEmpty());
}
}
@@ -0,0 +1,95 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The own ground elevation above sea level: unset by default, limited to a sensible range,
* and an unset value survives the XML file as unset rather than as a number.
*/
class ChatPreferencesOwnHeightAslTest {
@TempDir
Path directory;
private ChatPreferences preferences() {
return new ChatPreferences(directory.resolve("preferences.xml"));
}
@Test
void itIsUnsetByDefault() {
assertTrue(Double.isNaN(preferences().getStn_pathAnalysisOwnHeightAslMeters()));
}
@Test
void valuesInsideTheRangeAreKept() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisOwnHeightAslMeters(-430.0);
assertEquals(-430.0, preferences.getStn_pathAnalysisOwnHeightAslMeters(), 1e-12);
preferences.setStn_pathAnalysisOwnHeightAslMeters(ChatPreferences.PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS);
assertEquals(ChatPreferences.PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS,
preferences.getStn_pathAnalysisOwnHeightAslMeters(), 1e-12);
}
@Test
void valuesOutsideTheRangeClearIt() {
ChatPreferences preferences = preferences();
preferences.setStn_pathAnalysisOwnHeightAslMeters(300.0);
preferences.setStn_pathAnalysisOwnHeightAslMeters(9000.1);
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
preferences.setStn_pathAnalysisOwnHeightAslMeters(300.0);
preferences.setStn_pathAnalysisOwnHeightAslMeters(-500.1);
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
preferences.setStn_pathAnalysisOwnHeightAslMeters(Double.POSITIVE_INFINITY);
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
}
@Test
void anUnsetValueIsWrittenEmptyAndReadBackUnset() throws Exception {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setStn_pathAnalysisOwnHeightAslMeters(Double.NaN);
assertTrue(written.writePreferencesToXmlFile());
String xml = Files.readString(file);
assertTrue(xml.contains("<stn_pathAnalysisOwnHeightAslMeters/>")
|| xml.contains("<stn_pathAnalysisOwnHeightAslMeters></stn_pathAnalysisOwnHeightAslMeters>"),
"unset is written as an empty element");
assertFalse(xml.contains(">NaN<"), "NaN must not reach the file");
ChatPreferences restored = new ChatPreferences(file);
restored.setStn_pathAnalysisOwnHeightAslMeters(123.0);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(Double.isNaN(restored.getStn_pathAnalysisOwnHeightAslMeters()),
"an empty element clears a value held before reading");
}
@Test
void anOutOfRangeValueInTheFileIsReadAsUnset() throws Exception {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setStn_pathAnalysisOwnHeightAslMeters(250.0);
assertTrue(written.writePreferencesToXmlFile());
String xml = Files.readString(file).replace(
"<stn_pathAnalysisOwnHeightAslMeters>250.0</stn_pathAnalysisOwnHeightAslMeters>",
"<stn_pathAnalysisOwnHeightAslMeters>25000</stn_pathAnalysisOwnHeightAslMeters>");
Files.writeString(file, xml);
ChatPreferences restored = new ChatPreferences(file);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(Double.isNaN(restored.getStn_pathAnalysisOwnHeightAslMeters()));
}
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more