Compare commits

...
10 Commits
Author SHA1 Message Date
Rsclub2_2andClaude Opus 5.5 c64e9a9014 Open the release notes with the message of an annotated tag (#130)
The release body was built from direct commits on main and GitHub's
generated PR list only; whatever was written into the tag was lost. An
annotated tag's subject and body now open the notes, ahead of "What's
Changed", and so reach the in-app update feed too. The tag object is
fetched explicitly because actions/checkout can leave the triggering tag
as a lightweight ref; contents:subject/body leave the SSH signature out.
Lightweight tags contribute nothing, as before.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Drop changelog entries for defects that never shipped

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

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

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

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

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

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

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

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

* Judge a committed field against the live stored value

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Make DXClusterThreadPooledServer.serverSocket volatile

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

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

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

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

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

* Check the test user.home in a finalizer task

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

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

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

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

* Keep generateStrings configuration-cache safe

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

MonitorContent becomes internal so its body can be drawn headless.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

+19 -1
View File
@@ -867,7 +867,9 @@ jobs:
fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section:
# pushed directly to main are added here as their own section, and the
# message of an annotated tag opens the body:
# <tag message> (annotated tags only; signature dropped)
# ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list)
@@ -892,6 +894,19 @@ jobs:
RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# actions/checkout can leave the triggering tag as a lightweight ref, so
# fetch the tag object itself before reading its message.
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
# A lightweight tag has no message of its own (its "message" would be the
# commit's); only an annotated tag contributes. contents:subject and
# contents:body leave out an SSH or GPG signature.
: > tag-message.md
if [ "$(git cat-file -t "refs/tags/${TAG}")" = "tag" ]; then
git for-each-ref "refs/tags/${TAG}" \
--format='%(contents:subject)%0a%0a%(contents:body)' > tag-message.md
fi
echo "Tag message:"; cat tag-message.md
# --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well.
@@ -913,6 +928,7 @@ jobs:
import re
commits = open("direct-commits.md", encoding="utf-8").read().strip()
tag_message = open("tag-message.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally
@@ -932,6 +948,8 @@ jobs:
rest.append(line)
parts = []
if tag_message:
parts.append(tag_message)
if commits or prs:
parts.append("## What's Changed")
if commits:
+5 -2
View File
@@ -66,11 +66,14 @@ val generateStrings by tasks.registering(JavaExec::class) {
mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n")
// A local copy: the provider below must not capture the script-level val, which
// drags the build script object into the configuration cache and fails to serialize.
val output = generatedI18nDirectory
inputs.dir(input)
outputs.dir(generatedI18nDirectory)
outputs.dir(output)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath)
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
})
}
@@ -253,6 +253,7 @@ selected.lookUpAndShow=Nachschlagen und zeigen
selected.lookupQrz=Auf qrz.com nachschlagen
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
selected.showPathInAs=Strecke in AS zeigen
selected.turnAntenna=Antenne 1 Richtung {0} drehen
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
candidates.title=Stationen mit höchster Priorität
candidates.none=Noch keine Kandidaten.
@@ -361,7 +362,7 @@ tooltip.unknownGrid=unbekanntes Feld
tooltip.grid=Feld {0}
messages.public=Öffentliche Nachrichten
messages.dxCluster=DXCluster-Nachrichten
messages.qsoOfTheOther=QSO der Gegenstation
messages.qsoOfTheOther=Fremd-QSOs
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
@@ -378,3 +379,49 @@ contrast.textOnWindowSurface=Text auf der Fensterfläche
contrast.textInsideField=Text im Feld
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
contrast.textOnMenuStrip=Text auf der Menüzeile
gui.startInEveningDesign=Im Abenddesign starten
gui.filterPublic=Öffentliche Nachrichten
gui.filterPmToMe=Privatnachrichten an mich
gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
form.choose=Wählen...
form.notSelected=nicht gewählt
dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%]
column.score=Wertung
monitor.title=Cluster & Fremd-QSOs
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
settings.windowTitle=Client-Einstellungen ändern
settings.noticeInfo=Info
settings.noticeProblem=Einstellungen
profiles.unknown=unbekannt
profiles.copyOf=Kopie von {0}
profiles.sharedDatabase=gemeinsame Stationsdatenbank
profiles.ownDatabase=eigene Datenbank
@@ -262,6 +262,7 @@ selected.lookUpAndShow=Look up and show
selected.lookupQrz=Lookup on qrz.com
selected.lookupQrzcq=Lookup on qrzcq.com
selected.showPathInAs=Show path in AS
selected.turnAntenna=Turn ant1 to {0}
selected.lastActivity=Last activity: {0} ({1} min ago)
candidates.title=Top priority candidates
candidates.none=No candidates yet.
@@ -387,3 +388,49 @@ contrast.textOnWindowSurface=Text on the window surface
contrast.textInsideField=Text inside a field
contrast.accentOnWindowSurface=Accent on the window surface
contrast.textOnMenuStrip=Text on the menu strip
gui.startInEveningDesign=Start in the evening design
gui.filterPublic=Public messages
gui.filterPmToMe=Private messages to me
gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field
form.choose=Choose...
form.notSelected=not selected
dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
column.category=Category
column.sender=Sender
column.receiver=Receiver
column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%]
column.score=Score
monitor.title=Cluster & QSO of the other
profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated.
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
profiles.deleteFailed=The profile could not be deleted.
profiles.deletionFolder=The following folder is removed permanently:\n{0}
profiles.deletionKeepsShared=The common station worked stations are not touched.
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
notification.alreadyMonitored=This base callsign is already in the monitoring list.
settings.windowTitle=Change Client Settings
settings.noticeInfo=Info
settings.noticeProblem=Settings
profiles.unknown=unknown
profiles.copyOf=Copy of {0}
profiles.sharedDatabase=common station database
profiles.ownDatabase=own database
@@ -3034,8 +3034,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
member -> parseTableDouble(formatPriorityScore(member)),
(member, band, worked) -> formatBandCellStatus(member, band, worked),
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|"
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
"compose-stations",
widths);
@@ -3136,6 +3135,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (member == null) return null;
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
return resolveDefaultSkedBand(member, enabledBands);
},
azimuth -> {
chatcontroller.rotateTo(azimuth);
return kotlin.Unit.INSTANCE;
});
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
@@ -3727,6 +3730,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/**
* The session state the settings window shows, read from the main window's Compose state
* so the Station tab's login fields follow it while the window is open. Before that state
* exists the controller's value is the best there is.
*/
private On4KstConnectionState settingsConnectionState() {
if (composeMainWindowState != null) {
return composeMainWindowState.getSurroundings().getConnectionState();
}
On4KstConnectionState current = chatcontroller.getOn4KstConnectionState();
return current == null ? On4KstConnectionState.DISCONNECTED : current;
}
/**
* Opens the settings window, or does nothing when it is already open.
*
@@ -3736,7 +3752,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private void openSettingsWindow() {
SettingsWindow.show(
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore),
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
this::settingsConnectionState),
settingsNotices,
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP,
@@ -181,7 +181,7 @@ fun ChatInputRow(
)
Form.button(text = "TX", onClick = { state.send() })
Form.button(text = "clear", onClick = { state.clear() })
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
VerticalRule()
@@ -274,7 +274,7 @@ object ComposeAlert {
AlertContent(
header = header,
body = body,
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
@@ -54,7 +54,7 @@ object DirectedMessageColumns {
),
DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
@@ -62,7 +62,7 @@ object DirectedMessageColumns {
),
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -111,10 +111,25 @@ object Form {
* every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter.
*
* @param stored the value currently stored; the draft falls back to it when an
* entry is refused, which is what those fields did with setText
* Only an edited draft is committed: drawing the field, or entering and leaving it
* without typing, commits nothing, and neither does Enter on an unchanged value.
* A field removed while it holds the focus -- its tab switched away, its window
* closed -- commits a typed entry once: Compose reports the cleared focus to the
* field as a focus loss.
*
* @param stored the value stored when the caller composed; the draft starts from it
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
* @param isUnchanged whether the draft names the stored value. Textual by default,
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value 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.
*/
@Composable
fun committed(
@@ -122,14 +137,32 @@ object Form {
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
current: () -> String,
) {
var draft by remember(stored) { mutableStateOf(stored) }
/*
* Whether the field has held the focus since it was last committed. onFocusChanged
* also fires when the modifier is first attached, with "not focused"; reading that
* as a focus loss committed every such field the moment its tab was opened. Only a
* change from focused to unfocused is the operator leaving the field. A plain holder,
* not state: nothing is drawn from it.
*/
val hadFocus = remember { booleanArrayOf(false) }
fun commitDraft() {
/*
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
* into an override of itself, restart the beacon timer, or report a stored value
* the rule refuses although the operator only passed through the field.
*/
if (isUnchanged(draft, current())) return
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = stored
draft = current()
}
}
@@ -139,7 +172,14 @@ object Form {
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
.onFocusChanged { focus ->
if (focus.isFocused) {
hadFocus[0] = true
} else if (hadFocus[0]) {
hadFocus[0] = false
commitDraft()
}
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft()
@@ -183,7 +223,7 @@ object Form {
.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
@@ -227,7 +267,7 @@ object Form {
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -51,7 +51,7 @@ object MonitorColumns {
*/
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -61,7 +61,7 @@ object MonitorWindow {
onResized: (Float, Float) -> Unit,
) {
host.show(
title = { "Cluster & QSO of the other" },
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -75,8 +75,9 @@ object MonitorWindow {
}
}
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable
private fun MonitorContent(
internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
) {
@@ -96,7 +97,7 @@ private fun MonitorContent(
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
"QSO of the other",
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
@@ -26,7 +26,7 @@ object PublicMessageColumns {
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn(
id = "last-qrg",
title = "Last QRG",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f,
),
@@ -38,7 +38,7 @@ object PublicMessageColumns {
),
DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -16,13 +16,13 @@ object SelectedStationMessageColumns {
),
DataColumn(
id = "call-tx",
title = "Sender",
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = "Receiver",
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f,
),
@@ -127,7 +127,7 @@ fun SelectedStationPanel(
}
}
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
@@ -183,7 +183,11 @@ fun SelectedStationPanel(
Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button("🗺 Show on map", onClick = onShowOnMap)
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
Form.button(
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnAntenna,
) { state.turnAntenna() }
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl())
}
@@ -54,6 +54,8 @@ enum class SelectedMessageFilter(val label: String) {
* `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
* `ChatController.rotateTo` does; null where no rotator can be driven
*/
class SelectedStationState(
private val propagateNotQrv: (ChatMember) -> Unit,
@@ -63,6 +65,7 @@ class SelectedStationState(
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
private val turnAntennaTo: ((Double) -> Unit)? = null,
) {
var selected: ChatMember? by mutableStateOf(null)
@@ -103,6 +106,16 @@ class SelectedStationState(
val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank()
/** Needs a rotator to drive and a finite heading to the selected station. */
val canTurnAntenna: Boolean
get() = turnAntennaTo != null && selected?.getQTFdirection()?.isFinite() == true
/** Turns the antenna to the selected station's heading; does nothing when it cannot. */
fun turnAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
}
fun select(member: ChatMember?) {
if (selected?.callSignRaw != member?.callSignRaw) {
skedBand = member?.let { resolveDefaultSkedBand(it) }
@@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.On4KstConnectionState
import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile
@@ -113,6 +114,10 @@ interface SettingsHost {
* place, and the foreign-write coverage test can see it. A tab that only wrote a
* preference and skipped its session call would look correct in the file and do
* nothing until the next restart.
*
* @param connectionState the session state as Compose state reads it; the Station tab
* locks the login fields from it. The controller's own getter is not observable,
* so a tab reading it would stay on whatever the link was when the window opened.
*/
fun buildSettingsTabs(
controller: ChatController,
@@ -120,6 +125,7 @@ fun buildSettingsTabs(
notices: SettingsNotices,
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
connectionState: () -> On4KstConnectionState,
): List<SettingsTab> {
val prefs = controller.chatPreferences
@@ -128,6 +134,7 @@ fun buildSettingsTabs(
prefs,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
)
val notificationState = NotificationTabState(
@@ -257,18 +264,18 @@ fun buildSettingsTabs(
*/
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return "Connect KST4Contest and enable the local DX Cluster server first."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
}
val server = controller.dxClusterServer
?: return "Connect KST4Contest and enable the local DX Cluster server first."
?: return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
if (!server.hasConnectedClients()) {
return "No DX Cluster client is connected to KST4Contest."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNoClient
}
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return "The test spot could not be delivered."
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNotDelivered
}
return null
@@ -311,14 +318,9 @@ fun settingsButtons(
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(
"The configuration file could not be written. The settings stay " +
"active for this session."
)
notices.problem(kst4contest.view.i18n.CurrentStrings.get().settingsWriteFailed)
} else {
notices.info(
"Settings are stored as default to the xml config file:\n" + target
)
notices.info(kst4contest.view.i18n.CurrentStrings.get().settingsWrittenTo(target))
}
},
onApplyAndClose = close,
@@ -165,7 +165,7 @@ object SettingsWindow {
buttons: @Composable (close: () -> Unit) -> Unit,
) {
host.show(
title = { "Change Client Settings" },
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -241,13 +241,13 @@ internal fun SettingsContent(
title = {
Text(
when (notice) {
is SettingsNotice.Info -> "Info"
is SettingsNotice.Problem -> "Settings"
is SettingsNotice.Info -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeInfo
is SettingsNotice.Problem -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeProblem
}
)
},
text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
confirmButton = { TextButton(onClick = notices::dismiss) { Text(kst4contest.view.i18n.LocalStrings.current.dialogOk) } },
)
}
}
@@ -26,6 +26,19 @@ object StationColumns {
/** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked"
/**
* The identity a station row is drawn and selected under: the callsign and the chat
* category. The category is part of it because the same callsign can be in two chats at
* once, and AGENTS.md makes the category part of the chat identity; keyed by callsign
* alone, the two rows collided (fixed in 21251377).
*
* Kept here rather than inline at the one call site so the station list can be tested
* with the key it really uses. The text is exactly what the inline Java expression
* produced, including "null" for a missing callsign.
*/
fun rowKey(member: ChatMember): String =
"${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}"
fun all(
tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double,
@@ -52,7 +65,7 @@ object StationColumns {
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
@@ -61,7 +74,7 @@ object StationColumns {
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn(
id = "score",
title = "Score",
title = { kst4contest.view.i18n.CurrentStrings.get().columnScore },
value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f,
@@ -100,7 +113,7 @@ object StationColumns {
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn(
id = "category",
title = "Category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
},
@@ -10,6 +10,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -59,8 +60,6 @@ object StationMapWindow {
heightDp = heightDp,
onResized = onResized,
) { _ ->
val strings = LocalStrings.current
val window = this.window
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
val tileFetcher = remember { TileFetcher() }
@@ -74,6 +73,37 @@ object StationMapWindow {
onDispose { tileFetcher.close() }
}
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
} // host.show content
}
/**
* The window's body, without the window.
*
* Split out of [show] so it can be drawn headless in a test, with a tile fetcher that
* stays off the network and the user's tile cache, a roster that is not the running
* application's, and a [MapState] the test can read. The defaults are exactly what
* [show] used inline before the split.
*
* @param chatMembers the roster a marker click looks its station up in; a marker stands
* for a raw callsign, and this is where its category variants come from
* @param mapState where the map is looking
* @param pathAnalysis asked each time the selection changes; null means there is no
* running application, and then the map neither pans nor analyses, exactly as
* when no chat controller existed before the split
*/
@Composable
internal fun Content(
mainWindowState: MainWindowState,
tileFetcher: TileFetcher,
chatMembers: () -> List<kst4contest.model.ChatMember> = {
ApplicationRuntimeLauncher.getCurrent()?.chatController?.lst_chatMemberList?.snapshot().orEmpty()
},
mapState: MapState = remember { MapState() },
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
) {
val strings = LocalStrings.current
/*
* The fetcher is plain Java state, so nothing about it recomposes on its
* own. Polled rather than wired through a callback: this drives one line of
@@ -118,7 +148,9 @@ object StationMapWindow {
}
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
val mapState = remember { MapState() }
// Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
LaunchedEffect(selectedMember) {
if (selectedMember == null) {
@@ -126,8 +158,8 @@ object StationMapWindow {
return@LaunchedEffect
}
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
if (chatController != null) {
val analysis = currentPathAnalysis()
if (analysis != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
@@ -155,13 +187,7 @@ object StationMapWindow {
selectedSnapshot?.callSignRaw() ?: ""
)
val overrideBand: kst4contest.model.Band? = null
chatController.reachabilityService.requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
overrideBand
) { result ->
analysis.request(selectedMember, selectedSnapshot) { result ->
pathAnalysisResult = result
}
}
@@ -277,10 +303,8 @@ object StationMapWindow {
maxQrbKm = maxQrbKm,
darkMode = isDarkTheme,
onMarkerClicked = { snapshot ->
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
val variants = chatController?.lst_chatMemberList?.snapshot()
?.filter { it.callSignRaw == snapshot.callSignRaw() }
.orEmpty()
val variants = chatMembers()
.filter { it.callSignRaw == snapshot.callSignRaw() }
/*
* A marker stands for a raw callsign, not a category. Keep the
* category already selected when it is one of the variants.
@@ -298,7 +322,7 @@ object StationMapWindow {
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
}
},
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
)
MapOverlayControls(
@@ -361,8 +385,10 @@ object StationMapWindow {
} // main layout column
} // window column
} // Surface
} // host.show content
}
} // Content
/** Marks the map canvas so a test can click a marker on it. */
internal const val MAP_CANVAS_TAG = "station-map-canvas"
@JvmStatic
fun applyDarkMode(darkMode: Boolean) {
@@ -397,3 +423,34 @@ object StationMapWindow {
}
}
}
/**
* Asks for the path analysis of the station selected on the map.
*
* The seam between the map window and the running application: the window pans to the
* station and starts the analysis only when there is one to ask, which a test supplies
* without building a chat controller.
*/
internal fun interface MapPathAnalysis {
fun request(
member: kst4contest.model.ChatMember,
snapshot: kst4contest.view.map.MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
)
}
/**
* The running application's reachability service, or null before there is one.
*
* Looked up on every call, as the window did inline: a profile switch replaces the runtime.
* No band override -- the map always analyses on the automatically resolved band.
*/
private fun runtimePathAnalysis(): MapPathAnalysis? {
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null
return MapPathAnalysis { member, snapshot, onResult ->
chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result ->
onResult(result)
}
}
}
@@ -31,6 +31,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextMainCat,
commit = state::commitBeaconTextMainCat,
onRefused = onRefused,
current = { state.beaconTextMainCat },
)
}
@@ -43,6 +44,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextSecondCat,
commit = state::commitBeaconTextSecondCat,
onRefused = onRefused,
current = { state.beaconTextSecondCat },
)
}
@@ -52,6 +54,12 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconIntervalMinutes.toString(),
commit = state::commitBeaconInterval,
onRefused = onRefused,
/*
* Compared as the number commitBeaconInterval stores, so " 9" or "09" after
* Enter does not count as a second edit and restart the timer again.
*/
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.beaconIntervalMinutes.toString() },
)
}
}
@@ -2,6 +2,7 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.CurrentStrings
/**
* State of the beacon tab: the two CQ templates and the one interval they share.
@@ -69,7 +70,7 @@ class BeaconTabState(
* so a refused template that replaced a working one would silently stop
* the beacon instead of reporting anything.
*/
return "The beacon message is invalid: " + refusal.message
return CurrentStrings.get().beaconInvalidMessage(refusal.message.orEmpty())
}
store(template)
@@ -85,8 +86,7 @@ class BeaconTabState(
val minutes = entered.trim().toIntOrNull()
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
return "Enter a whole beacon interval of at least " +
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
}
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
@@ -119,6 +119,13 @@ fun ColoursTab(
stored = row.hex,
commit = { typed -> state.set(row.role, typed) },
onRefused = reportRefusal,
/*
* A colour is compared the way PaletteOverrides normalises it -- trimmed,
* case ignored -- so retyping the shown colour in lower case is no edit
* and does not turn it into an override of itself.
*/
isUnchanged = { typed, now -> typed.trim().equals(now.trim(), ignoreCase = true) },
current = { state.roles().single { it.role == row.role }.hex },
)
}
}
@@ -103,7 +103,7 @@ internal fun FilePathRow(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Form.button("Choose...") { choosing = true }
Form.button(kst4contest.view.i18n.LocalStrings.current.formChoose) { choosing = true }
}
}
@@ -25,31 +25,31 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section(strings.guiStartupDesign) {
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
Form.check(strings.guiStartInEveningDesign, state.gUI_darkModeActiveByDefault) {
state.gUI_darkModeActiveByDefault = it
}
}
Form.flowingSection(strings.guiStartupFilters) {
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
Form.check(strings.guiFilterPublic, state.guiOptions_defaultFilterPublicMsgs) {
state.guiOptions_defaultFilterPublicMsgs = it
}
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
Form.check(strings.guiFilterPmToMe, state.guiOptions_defaultFilterPmToMe) {
state.guiOptions_defaultFilterPmToMe = it
}
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
Form.check(strings.guiFilterPmToOthers, state.guiOptions_defaultFilterPmToOther) {
state.guiOptions_defaultFilterPmToOther = it
}
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
Form.check(strings.guiFilterNothing, state.guiOptions_defaultFilterNothing) {
state.guiOptions_defaultFilterNothing = it
}
}
Form.flowingSection(strings.guiBandColumnHints) {
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
Form.check(strings.guiHintFreshCall, state.guiOptions_showFreshCallHintInBandColumns) {
state.guiOptions_showFreshCallHintInBandColumns = it
}
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
Form.check(strings.guiHintLargeGridWorked, state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
}
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band
import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form
@@ -76,12 +77,18 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
stored = state.notify_dxclusterServerPort.toString(),
commit = state::commitDxClusterServerPort,
onRefused = onRefused,
// Compared as the number commitDxClusterServerPort stores.
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.notify_dxclusterServerPort.toString() },
)
Form.committed(
label = strings.notificationSpotterCallsign,
stored = state.notify_DXCSrv_SpottersCallSign,
commit = state::commitSpotterCallSign,
onRefused = onRefused,
// commitSpotterCallSign stores the callsign trimmed and in upper case.
isUnchanged = { typed, now -> typed.trim().uppercase(java.util.Locale.ROOT) == now },
current = { state.notify_DXCSrv_SpottersCallSign },
)
/*
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
@@ -134,6 +141,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
onValueChange = { entered = it },
singleLine = true,
label = { Text(strings.notificationCallsign) },
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
)
}
},
@@ -149,3 +157,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
)
}
}
/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */
internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field"
@@ -9,6 +9,7 @@ import kst4contest.view.GuiUtils
import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule
import kst4contest.view.i18n.CurrentStrings
import java.util.Locale
/**
@@ -106,8 +107,7 @@ class NotificationTabState(
val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) {
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
"Enter a value from 1 to 65535."
return CurrentStrings.get().notificationInvalidPort(entered.trim())
}
prefs.setNotify_dxclusterServerPort(port)
@@ -130,7 +130,7 @@ class NotificationTabState(
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
}
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
@@ -151,7 +151,7 @@ class NotificationTabState(
}
if (verdict !is CommitOutcome.Stored) {
return "Please enter a valid callsign."
return CurrentStrings.get().notificationInvalidCallsign
}
/*
@@ -202,11 +202,11 @@ class NotificationTabState(
)
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
return CommitOutcome.Refused("Please enter a valid callsign.")
return CommitOutcome.Refused(CurrentStrings.get().notificationInvalidCallsign)
}
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
return CommitOutcome.Refused(CurrentStrings.get().notificationAlreadyMonitored)
}
return CommitOutcome.Stored(baseCall)
@@ -341,7 +341,7 @@ private fun ProfileDialogs(
)
}
},
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text(strings.dialogApply) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
)
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection
import kst4contest.view.i18n.CurrentStrings
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -57,7 +58,7 @@ class ProfilesTabState(
}
val activeProfileName: String
get() = activeSelection()?.profile?.displayName ?: "unknown"
get() = activeSelection()?.profile?.displayName ?: CurrentStrings.get().profilesUnknown
val preferencesPath: String
get() = activeSelection()?.preferencesAbsolutePath ?: ""
@@ -90,9 +91,7 @@ class ProfilesTabState(
}
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
?: return ProfileNotice.Error(
"The profile could not be created. The profile registry could not be written."
)
?: return ProfileNotice.Error(CurrentStrings.get().profilesCreateFailed)
refresh()
selected = created
@@ -101,10 +100,7 @@ class ProfilesTabState(
* Said explicitly because a new profile that cannot log in looks broken
* otherwise: callsign and password are deliberately not copied or invented.
*/
return ProfileNotice.Info(
"The profile \"" + created.displayName + "\" was created without callsign and " +
"password. Enter them on the Station tab after switching to it."
)
return ProfileNotice.Info(CurrentStrings.get().profilesCreatedWithoutCredentials(created.displayName))
}
fun duplicateSelected(name: String): ProfileNotice? {
@@ -115,7 +111,7 @@ class ProfilesTabState(
}
val duplicate = service.duplicateProfile(profile, name)
?: return ProfileNotice.Error("The profile could not be duplicated.")
?: return ProfileNotice.Error(CurrentStrings.get().profilesDuplicateFailed)
refresh()
selected = duplicate
@@ -124,7 +120,7 @@ class ProfilesTabState(
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
fun duplicateNameSuggestion(): String =
selected?.let { "Copy of " + it.displayName } ?: ""
selected?.let { CurrentStrings.get().profilesCopyOf(it.displayName) } ?: ""
fun renameSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection()
@@ -142,20 +138,16 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile uses the files of the installation itself and cannot be deleted."
)
return ProfileNotice.Error(CurrentStrings.get().profilesRootNotDeletable)
}
if (isActive(profile)) {
return ProfileNotice.Error(
"The profile currently in use cannot be deleted. Switch to another profile first."
)
return ProfileNotice.Error(CurrentStrings.get().profilesActiveNotDeletable)
}
if (!service.deleteProfile(profile)) {
refresh()
return ProfileNotice.Error("The profile could not be deleted.")
return ProfileNotice.Error(CurrentStrings.get().profilesDeleteFailed)
}
refresh()
@@ -164,22 +156,18 @@ class ProfilesTabState(
/** The folder the deletion confirmation names, and what happens to worked stations. */
fun describeDeletion(profile: OperatorProfile): String =
"The following folder is removed permanently:\n" +
service.getProfileDirectory(profile) + "\n\n" +
CurrentStrings.get().profilesDeletionFolder(service.getProfileDirectory(profile).toString()) + "\n\n" +
if (profile.isSharedWorkedDatabase) {
"The common station worked stations are not touched."
CurrentStrings.get().profilesDeletionKeepsShared
} else {
"The worked stations of this profile are deleted as well."
CurrentStrings.get().profilesDeletionRemovesOwn
}
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile always uses the common station worked stations, because " +
"that database is the one of the installation itself."
)
return ProfileNotice.Error(CurrentStrings.get().profilesRootAlwaysShared)
}
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
@@ -190,7 +178,7 @@ class ProfilesTabState(
refresh()
if (isActive(profile)) {
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
return ProfileNotice.Info(CurrentStrings.get().profilesChangeAfterSwitch)
}
return null
@@ -200,7 +188,7 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (isActive(profile)) {
return ProfileNotice.Info("This profile is already active.")
return ProfileNotice.Info(CurrentStrings.get().profilesAlreadyActive)
}
requestActivation(profile)
@@ -217,7 +205,7 @@ class ProfilesTabState(
}
private fun notASelection(): ProfileNotice =
ProfileNotice.Info("Select a profile in the table first.")
ProfileNotice.Info(CurrentStrings.get().profilesSelectFirst)
companion object {
@@ -230,9 +218,9 @@ class ProfilesTabState(
*/
fun describeWorkedDataMode(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"common station database"
CurrentStrings.get().profilesSharedDatabase
} else {
"own database"
CurrentStrings.get().profilesOwnDatabase
}
/** Blank for a profile never used, rather than an epoch date. */
@@ -1,5 +1,6 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
@@ -26,6 +27,11 @@ class StationTabState(
*/
private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit,
/**
* The state of the ON4KST session. A lambda over Compose state, so the login fields
* follow it while the tab is open; the default suits a state built without a session.
*/
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
) {
var loginChatCategoryMain: ChatCategory?
@@ -173,8 +179,14 @@ class StationTabState(
* password, name, locator and both category pickers once the session was logged
* in: changing them there would not reach the running connection, so an edit
* would look applied and be ignored until the next login.
*
* Locked for every state but DISCONNECTED: a reconnect wait or a session being
* stopped still belongs to the credentials it was started with. Derived on each read
* rather than held, so a composition reading it follows the connection state the
* moment that changes, provided [connectionState] reads Compose state.
*/
var loginFieldsEnabled: Boolean = true
val loginFieldsEnabled: Boolean
get() = connectionState() == On4KstConnectionState.DISCONNECTED
/**
* The categories the operator can log into, built the way the JavaFX choice box
@@ -20,6 +20,8 @@ import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The four ways the drawing layer went wrong while the build was green.
@@ -41,7 +43,7 @@ import org.junit.jupiter.api.Test
* these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about.
*/
class DrawingLayerRegressionTest {
class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
@@ -52,7 +54,7 @@ class DrawingLayerRegressionTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
@@ -0,0 +1,323 @@
package kst4contest.view.compose
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.PaletteRole
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The in-window menu row, drawn and clicked (#107).
*
* [MainMenuModelTest] covers the bar as data. This covers what the operator actually meets:
* a title that opens, an item that runs its own action, a label that follows the state while
* the row is on screen. Two regressions are the reason it exists:
*
* - `fe4215bc` (Remove JavaFX, in `main` as `2a5c89b9`): the sixteen menu actions still hung
* on JavaFX `MenuItem`s that had been deleted, so picking them in the Compose menu did
* nothing. Found by a GUI acceptance run, never by the build. They were moved onto their
* own `menuAction…` methods behind [MainMenuActions].
* - `83e23f55` (Draw the menu bar in Compose, in `main` as `2a5c89b9`): Compose's `MenuBar`
* is a Swing `JMenuBar` that no theme reaches, so Linux showed the default Metal menu above
* a themed window. The row is now Compose and reads the stylesheet palette.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class MainMenuRowScreenTest {
private class RecordingActions : MainMenuActions {
val calls = mutableListOf<String>()
override fun connect() { calls += "connect" }
override fun disconnect() { calls += "disconnect" }
override fun switchOperatorProfile() { calls += "switchOperatorProfile" }
override fun exitApplication() { calls += "exitApplication" }
override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" }
override fun toggleAwayState() { calls += "toggleAwayState" }
override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" }
override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" }
override fun toggleStationMap() { calls += "toggleStationMap" }
override fun useDarkDesign() { calls += "useDarkDesign" }
override fun useDefaultDesign() { calls += "useDefaultDesign" }
override fun openDonationPage() { calls += "openDonationPage" }
override fun openHomepage() { calls += "openHomepage" }
override fun openNewsgroup() { calls += "openNewsgroup" }
override fun openChangelog() { calls += "openChangelog" }
override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" }
override fun contactAuthor() { calls += "contactAuthor" }
override fun showAbout() { calls += "showAbout" }
}
private val rowTag = "menu-row"
private fun ComposeUiTest.showRow(
state: MainMenuState,
actions: MainMenuActions,
rowState: MainMenuRowState = MainMenuRowState(),
settingsOpen: () -> Boolean = { false },
monitorOpen: () -> Boolean = { false },
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = state,
actions = actions,
settingsWindowOpen = settingsOpen(),
monitorWindowOpen = monitorOpen(),
rowState = rowState,
modifier = Modifier.testTag(rowTag),
)
}
}
}
private fun onlineState() = MainMenuState().apply {
connectionState = On4KstConnectionState.ONLINE
connectLabel = "Connect to 144/432 MHz"
}
/**
* Every item, picked through the drawn row, reaches its own action and nobody else's.
* This is the `fe4215bc` fault in the form the operator saw it: the item was there and
* picking it did nothing.
*/
@Test
fun everyItemPickedInTheRowRunsItsOwnAction() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
val rowState = MainMenuRowState()
showRow(state, actions, rowState)
// Online, so Disconnect is usable and Connect is not; Connect has its own test below.
val expected = listOf(
Triple("File", "Disconnect", "disconnect"),
Triple("File", "Switch operator profile...", "switchOperatorProfile"),
Triple("File", "Exit + disconnect", "exitApplication"),
Triple("Options", "Set QRG as name in Chat (main category)", "setQrgAsNameInChat"),
Triple("Options", "Show me as AWAY FROM chat!", "toggleAwayState"),
Triple("Options", "Show options", "toggleSettingsWindow"),
Triple("Windows", "Show cluster / stranger QSOs", "toggleMonitorWindow"),
Triple("Windows", "Show / hide station map", "toggleStationMap"),
Triple("Windows", "Use dark mode design", "useDarkDesign"),
Triple("Windows", "Use default mode design", "useDefaultDesign"),
Triple("Info", "Donate for kst4Contest development via PayPal", "openDonationPage"),
Triple("Info", "Donate for OV3T´s plane feed service", "openOv3tDonationPage"),
Triple("Info", "Visit DARC X08-Homepage", "openHomepage"),
Triple("Info", "Join kst4Contest newsgroup", "openNewsgroup"),
Triple("Info", "Contact the author using default mail app", "contactAuthor"),
Triple("Info", "About...", "showAbout"),
)
expected.forEach { (menu, item, action) ->
onNodeWithText(menu).performClick()
awaitNode(inPopup(item)).performClick()
awaitGone(inPopup(item))
assertEquals(action, actions.calls.lastOrNull(), "'$menu > $item' ran the wrong action")
assertEquals(null, rowState.openMenu, "'$menu' stayed down after '$item' was picked")
}
assertEquals(
expected.size,
actions.calls.size,
"an item ran more than one action, or one ran none",
)
}
/** Offline, Connect works; once an attempt runs it is greyed out and Disconnect works. */
@Test
fun theConnectItemsFollowTheConnectionStateWhileTheMenuIsDown() = runComposeUiTest {
val actions = RecordingActions()
val state = MainMenuState().apply { connectLabel = "Connect to 144/432 MHz" }
showRow(state, actions)
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz")).assertIsEnabled()
onNode(inPopup("Disconnect")).assertIsNotEnabled()
onNode(inPopup("Connect to 144/432 MHz")).performClick()
awaitGone(inPopup("Connect to 144/432 MHz"))
assertEquals(listOf("connect"), actions.calls, "Connect did not run while offline")
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz"))
// The state changes from elsewhere -- the connection callback -- with the menu down.
runOnIdle { state.connectionState = On4KstConnectionState.CONNECTING }
waitForIdle()
onNodeWithText("Connect to 144/432 MHz").assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsEnabled()
onNodeWithText("Connect to 144/432 MHz").performClick()
waitForIdle()
assertEquals(listOf("connect"), actions.calls, "a disabled item ran its action")
}
/**
* The away item's label is the only place the AFK state is shown at all. JavaFX flipped
* it inside the click handler, which left it wrong whenever the state changed from
* elsewhere; here it must follow the state, drawn.
*/
@Test
fun theAwayLabelFollowsTheStateAndNotTheClick() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
showRow(state, actions)
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").performClick()
waitForIdle()
// The click alone changes nothing: the action did not touch the state.
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").assertExists()
runOnIdle { state.awayFromChat = true }
waitForIdle()
onNodeWithText("Show me as ACTIVE in chat!").assertExists()
onNodeWithText("Show me as AWAY FROM chat!").assertDoesNotExist()
}
/** Both toggles name the next action, and follow windows that close on their own. */
@Test
fun theWindowTogglesFollowWhetherTheWindowIsOpen() = runComposeUiTest {
var monitorOpen by mutableStateOf(false)
showRow(onlineState(), RecordingActions(), monitorOpen = { monitorOpen })
onNodeWithText("Windows").performClick()
waitForIdle()
onNodeWithText("Show cluster / stranger QSOs").assertExists()
runOnIdle { monitorOpen = true }
waitForIdle()
onNodeWithText("Hide cluster / stranger QSOs").assertExists()
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
}
/** A second click on an open title closes it again, the way a menu bar behaves. */
@Test
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
val rowState = MainMenuRowState()
showRow(onlineState(), RecordingActions(), rowState)
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals("Info", rowState.openMenu)
onNodeWithText("About...").assertExists()
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals(null, rowState.openMenu)
onNodeWithText("About...").assertDoesNotExist()
}
/**
* The `83e23f55` fault: the bar must be drawn in the palette, and a change of the palette
* must reach it while it is on screen. Measured on the rendered row, to the right of the
* last title, where nothing but the row's own background is drawn.
*/
@Test
fun theRowIsDrawnInThePaletteAndFollowsAChange() = runComposeUiTest {
var stored = ""
val store = PaletteStore(
shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") },
fileRolesOf = { emptyMap() },
storedOverridesOf = { stored },
storeOverrides = { _, encoded -> stored = encoded },
)
setContent {
CompositionLocalProvider(LocalPaletteStore provides store) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
modifier = Modifier.testTag(rowTag),
)
}
}
}
waitForIdle()
assertEquals(store.shipped(false).base, rightEdgeColour(), "the row is not in the shipped base")
runOnIdle { store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") }
waitForIdle()
assertEquals(Color(0xFFFF0000), rightEdgeColour(), "a palette change did not reach the row")
}
private fun ComposeUiTest.rightEdgeColour(): Color {
val pixels = onNodeWithTag(rowTag).captureToImage().toPixelMap()
// Well inside the row's right end, clear of any edge antialiasing.
return pixels[pixels.width - 12, pixels.height / 2]
}
/** A language picked after the first draw reaches the titles and the items. */
@Test
fun aLanguageChosenWhileTheRowIsShownReachesIt() = runComposeUiTest {
var storedLanguage = "en"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.UK,
)
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
)
}
}
}
onNodeWithText("File").assertExists()
try {
runOnIdle { languages.use("de") }
waitForIdle()
onNodeWithText("Datei").performClick()
waitForIdle()
onNodeWithText("Trennen").assertExists()
onNodeWithText("File").assertDoesNotExist()
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
}
@@ -6,6 +6,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
@@ -13,11 +15,11 @@ import org.junit.jupiter.api.Test
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest {
class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -31,7 +33,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -44,7 +46,7 @@ class MainWindowStateRosterTest {
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -60,7 +62,7 @@ class MainWindowStateRosterTest {
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -79,9 +81,9 @@ class MainWindowStateRosterTest {
* A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so.
*/
class MainWindowSplitterDefaultsTest {
class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
private val prefs = ChatPreferences()
private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
@Test
fun `the message column has as many dividers as the profile stores positions`() {
@@ -0,0 +1,158 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The cluster and QSO monitor's body, drawn with live tables (#107).
*
* This was the first window whose tables fill while the operator watches, and the defects
* that the Compose port shipped here were all about the coupling between a live list and
* what gets drawn, not about the table itself (`cc8c9020`, Draw the whole interface in
* Compose; in `main` as `2a5c89b9`):
*
* - The monitor sat empty beside a filling JavaFX tab and only redrew when an unrelated
* click forced it: `DataTableState.visibleRows` returned its cache before reading the row
* state, so a recomposition that hit the cache left the table subscribed to nothing.
* - Rows without identity were keyed in a way that could hand out the same key twice, which
* a `LazyColumn` refuses; two messages that read the same are still two messages.
*
* The tables here are built exactly as `Kst4ContestApplication` builds them: the monitor
* columns, keyed by reference.
*
* Headless: [MonitorContent] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class MonitorWindowScreenTest {
private fun clusterTable() =
DataTableState(MonitorColumns.dxCluster(), RowKeys.byReference<ClusterMessage>(), "dx-cluster-monitor")
private fun qsoTable() =
DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference<ChatMessage>(), "qso-other-monitor")
private fun member(call: String) = ChatMember().apply { callSign = call }
private fun spot(from: String, to: String, text: String) = ClusterMessage().apply {
sender = member(from)
receiver = member(to)
messageInhibited = text
}
private fun qso(from: String, to: String, text: String) = ChatMessage().apply {
sender = member(from)
receiver = member(to)
messageText = text
}
private fun ComposeUiTest.show(
cluster: DataTableState<ClusterMessage>,
qsos: DataTableState<ChatMessage>,
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MonitorContent(cluster, qsos)
}
}
waitForIdle()
}
@Test
fun bothTablesAreDrawnUnderTheirHeadings() = runComposeUiTest {
show(clusterTable(), qsoTable())
onNodeWithText("DX cluster").assertExists()
onNodeWithText("QSO of the other").assertExists()
onNodeWithText("Call tx").assertExists()
onNodeWithText("Call TX").assertExists()
}
@Test
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "144.300 tnx qso")))
qsos.replaceRows(listOf(qso("DL2CCC", "OK2DDD", "pse sked 432")))
}
waitForIdle()
onNodeWithText("144.300 tnx qso").assertExists()
onNodeWithText("pse sked 432").assertExists()
}
/**
* The empty-monitor fault in its exact shape: the table recomposes once for a reason that
* is not its rows -- here a column moved by the operator -- and the rows that arrive after
* that must still come through.
*/
@Test
fun rowsStillArriveAfterARedrawThatWasNotAboutTheRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle { cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "first spot"))) }
waitForIdle()
onNodeWithText("first spot").assertExists()
// A redraw with the order cache warm.
runOnIdle { cluster.moveColumn("message", 0) }
waitForIdle()
runOnIdle {
cluster.replaceRows(
listOf(spot("DL3EEE", "OK3FFF", "second spot")) + cluster.rows,
)
}
waitForIdle()
onNodeWithText("second spot").assertExists()
onNodeWithText("first spot").assertExists()
}
/** Two spots that read alike are two rows; neither is dropped and the list does not throw. */
@Test
fun twoIdenticalLookingMessagesAreTwoRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle {
cluster.replaceRows(
listOf(
spot("DL1AAA", "OK1BBB", "repeated spot"),
spot("DL1AAA", "OK1BBB", "repeated spot"),
),
)
}
waitForIdle()
assertEquals(2, onAllNodesWithText("repeated spot").fetchSemanticsNodes().size)
}
/** A half-filled message off the wire draws as empty cells, not as a crash. */
@Test
fun aMessageWithoutSenderOrReceiverStillDraws() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(ClusterMessage().apply { messageInhibited = "orphan spot" }))
qsos.replaceRows(listOf(ChatMessage().apply { messageText = "orphan qso" }))
}
waitForIdle()
onNodeWithText("orphan spot").assertExists()
onNodeWithText("orphan qso").assertExists()
}
}
@@ -5,6 +5,8 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
@@ -15,11 +17,11 @@ import org.junit.jupiter.api.Test
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField.
*/
class ObservedValueTest {
class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test
fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -30,7 +32,7 @@ class ObservedValueTest {
/** The rotator moving must reach the screen without anything asking it to. */
@Test
fun `a change to the underlying value reaches the mirror by itself`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF())
@@ -41,7 +43,7 @@ class ObservedValueTest {
@Test
fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null)
@@ -55,7 +57,7 @@ class ObservedValueTest {
*/
@Test
fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -73,7 +75,7 @@ class ObservedValueTest {
*/
@Test
fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -85,7 +87,7 @@ class ObservedValueTest {
@Test
fun `releasing twice is harmless`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
observed.release()
@@ -0,0 +1,45 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
/*
* Waiting for what a click opens on top of the window (#107).
*
* In Compose Multiplatform 1.8.2 a Material3 AlertDialog goes through the skiko Dialog, and a
* DropdownMenu through the skiko Popup; both compose their content into a ComposeSceneLayer of
* the same scene (Dialog_skikoKt calls rememberComposeSceneLayer, checked in the bytecode of
* ui-desktop 1.8.2). The layer's content is set from the composition that opened it, so it can
* take one more frame than the click itself. These helpers wait for the node instead of
* assuming that frame has already happened, and they look only inside the overlay, so a text
* that also stands in the window below can never be picked by accident.
*/
private const val OVERLAY_TIMEOUT_MILLIS = 5_000L
/** Waits until a node matching [matcher] is in the tree, then returns it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher): SemanticsNodeInteraction {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() }
return onNode(matcher)
}
/** Waits until no node matches [matcher] any more. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitGone(matcher: SemanticsMatcher) {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isEmpty() }
}
/** A text inside an open dialog. */
internal fun inDialog(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isDialog())
/** A text inside an open popup, which is what a dropdown menu is. */
internal fun inPopup(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isPopup())
@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The factory binds the store to one profile's preferences and one profile's stylesheet.
@@ -15,7 +17,7 @@ import java.io.File
* another's colours. The stylesheet path is injected here rather than derived inside, so the
* test does not have to write into the operator's home directory to check which file is read.
*/
class PaletteStoreFactoryTest {
class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
private val daylight = OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
@@ -24,7 +26,7 @@ class PaletteStoreFactoryTest {
@Test
fun aChangeLandsInTheDesignsOwnPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -35,7 +37,7 @@ class PaletteStoreFactoryTest {
@Test
fun anEveningChangeLandsInTheEveningPreference() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -47,7 +49,7 @@ class PaletteStoreFactoryTest {
@Test
fun whatIsAlreadyStoredIsInForceFromTheStart() {
// The store is built at startup; a colour saved last session has to be there at once.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -57,7 +59,7 @@ class PaletteStoreFactoryTest {
@Test
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -73,7 +75,7 @@ class PaletteStoreFactoryTest {
file.writeText(".root { -fx-accent: #123456; }")
val store = PaletteStoreFactory.create(
ChatPreferences(),
ChatPreferences(tempDir.resolve("preferences.xml")),
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
)
@@ -39,6 +39,42 @@ class SelectedStationStateTest {
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun turnAntennaSendsTheSelectedStationsHeading() {
val headings = mutableListOf<Double>()
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
val station = member("DL1ABC")
station.setQTFdirection(145.7)
state.select(station)
assertTrue(state.canTurnAntenna)
state.turnAntenna()
assertEquals(listOf(145.7), headings)
}
@Test
fun turnAntennaIsUnavailableWithoutARotatorOrAHeading() {
val withoutRotator = SelectedStationState(propagateNotQrv = { }, createSked = { _, _, _, _ -> true })
withoutRotator.select(member("DL1ABC").apply { setQTFdirection(145.7) })
assertFalse(withoutRotator.canTurnAntenna)
val headings = mutableListOf<Double>()
val withoutHeading = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
withoutHeading.select(member("DL1ABC"))
assertFalse(withoutHeading.canTurnAntenna)
withoutHeading.turnAntenna()
assertTrue(headings.isEmpty())
}
@Test
fun `without a selection there is nothing to act on`() {
val state = Session().state()
@@ -162,4 +162,27 @@ class StationColumnsTest {
"reachability is computed by the controller, not carried by the row")
assertEquals("250", columns.first { it.id == "score" }.value(member))
}
/**
* The key Kst4ContestApplication binds the station list to (#107). The category is part
* of the chat identity, so the same callsign in two categories is two keys; keyed by the
* callsign alone the two rows collided (21251377). The exact text is pinned because it
* was moved out of an inline Java lambda and must read the same as that lambda did.
*/
@Test
fun `the row key separates the categories of one callsign`() {
fun member(call: String?, category: Int?) = ChatMember().apply {
callSign = call
chatCategory = category?.let { kst4contest.model.ChatCategory(it) }
}
assertEquals("DL1ABC|2", StationColumns.rowKey(member("DL1ABC", 2)))
assertEquals("DL1ABC|3", StationColumns.rowKey(member("DL1ABC", 3)))
assertTrue(
StationColumns.rowKey(member("DL1ABC", 2)) != StationColumns.rowKey(member("DL1ABC", 3)),
"two categories of one callsign share a key",
)
assertEquals("DL1ABC|", StationColumns.rowKey(member("DL1ABC", null)))
assertEquals("null|2", StationColumns.rowKey(member(null, 2)))
}
}
@@ -0,0 +1,198 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import java.util.EnumSet
/**
* The station list of the main window, drawn: the operator's main working surface (#107).
*
* The same [DataTableView] over [StationColumns] and [StationColumns.rowKey] that
* `Kst4ContestApplication` builds; only the computed cells (tropo, score, band status) are
* stubbed, because they come from the controller. Three regressions are why this list is
* tested on screen and not only as state:
*
* - `21251377` (Codex review findings on PR 89): the row key was the callsign alone, so the
* same callsign in two chat categories collided -- two rows under one key, which a
* `LazyColumn` refuses outright.
* - `21251377` again: sorting worked on all rows instead of the filtered ones, so choosing a
* sort column silently switched the filters off.
* - `ab282162` (#87): the AP column had no comparator and sorted by its text, so
* "13 (75%)" came before "3 (50%)".
*
* Plus the rule the table was built around in `cc8c9020` (Draw the whole interface in
* Compose): rows replaced after the first draw reach the screen, and the selection is kept by
* key across a refresh that hands in new instances.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class StationListScreenTest {
private fun stationList(): DataTableState<ChatMember> = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.noneOf(Band::class.java),
),
RowKeys.byValue(StationColumns::rowKey),
"compose-stations",
)
private fun station(
call: String,
category: Int = 2,
qrb: Double? = null,
planes: List<Pair<Int, Int>>? = null,
): ChatMember = ChatMember().apply {
callSign = call
name = "op $call"
chatCategory = ChatCategory(category)
this.qrb = qrb
if (planes != null) {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = planes.map { (minutes, potential) ->
AirPlane().apply {
arrivingDurationMinutes = minutes
this.potential = potential
}
}.toMutableList()
}
}
}
private fun ComposeUiTest.show(
state: DataTableState<ChatMember>,
onRowClick: (ChatMember) -> Unit = {},
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(state = state, onRowClick = onRowClick)
}
}
waitForIdle()
}
/** The callsigns as they stand on screen, top to bottom. */
private fun ComposeUiTest.drawnOrder(vararg calls: String): List<String> =
calls.sortedBy { call ->
onNodeWithText(call).fetchSemanticsNode().positionInRoot.y
}
@Test
fun theSameCallsignInTwoCategoriesIsTwoRowsAndOnlyTheClickedOneIsSelected() = runComposeUiTest {
val state = stationList()
val inMain = station("DL1ABC", category = 2)
val inSecond = station("DL1ABC", category = 3)
val clicked = mutableListOf<ChatMember>()
show(state) { clicked += it }
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
waitForIdle()
assertEquals(
2,
onAllNodesWithText("DL1ABC").fetchSemanticsNodes().size,
"the second category's row did not reach the screen",
)
// Unsorted, the rows keep their arrival order: the second category is the lower row.
val rows = onAllNodesWithText("DL1ABC")
val lower = rows.fetchSemanticsNodes().withIndex().maxBy { it.value.positionInRoot.y }.index
rows[lower].performClick()
waitForIdle()
assertSame(inSecond, clicked.single(), "the click reached the other category's row")
assertSame(inSecond, state.selectedRow)
assertEquals(false, state.isSelected(inMain), "both categories read as selected")
}
@Test
fun aSortChosenWhileFilteredKeepsTheFilter() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL3CCC", qrb = 300.0),
station("DL1AAA", qrb = 100.0),
station("DL2BBB", qrb = 200.0),
),
)
state.rowFilter = { it.callSign != "DL2BBB" }
}
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
onNodeWithText("QRB").performClick()
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
}
@Test
fun theApColumnSortsByMinutesAndNotByItsText() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL1THIRTEEN", planes = listOf(13 to 75)),
station("DL1NONE", planes = emptyList()),
station("DL1THREE", planes = listOf(3 to 50)),
),
)
}
waitForIdle()
onNodeWithText("AP [minutes / pot%]").performClick()
waitForIdle()
assertEquals(
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
"the text order puts \"13 (75%)\" above \"3 (50%)\"",
)
onNodeWithText("AP [minutes / pot%] ▲").assertExists()
}
@Test
fun rowsArrivingAfterTheFirstDrawReachTheScreenAndKeepTheSelection() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle { state.replaceRows(listOf(station("DL1AAA"), station("DL2BBB"))) }
waitForIdle()
onNodeWithText("DL2BBB").performClick()
waitForIdle()
// A refresh from the roster hands in new instances, with one more station on top.
val refreshed = station("DL2BBB")
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"), refreshed)) }
waitForIdle()
onNodeWithText("DL9NEW").assertExists()
assertSame(refreshed, state.selectedRow, "the selection did not follow the station by key")
// A station that left takes the selection with it rather than handing it on.
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"))) }
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(null, state.selectedRow)
}
}
@@ -0,0 +1,384 @@
package kst4contest.view.compose.map
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.locatorUtils.Location
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import kst4contest.model.ClusterMessage
import kst4contest.view.compose.BlinkingNotice
import kst4contest.view.compose.ChatInputActions
import kst4contest.view.compose.ChatInputState
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.MainMenuState
import kst4contest.view.compose.MainWindowState
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.RowKeys
import kst4contest.view.compose.SelectedStationState
import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.StationFilterState
import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.io.IOException
import java.util.EnumSet
/**
* The station map window's body, drawn and clicked (#107).
*
* The map has the longest regression history of the Compose windows, and the two that
* reached an operator are both about what a click on the map does to the rest of the
* application:
*
* - `21251377` (Codex review findings on PR 89): a marker click and "Reset view" set the
* map's own copy of the selection instead of going through the central station selector,
* so the station list, the score service and the send line did not follow the map.
* - `cc8c9020` (Draw the whole interface in Compose, in `main` as `2a5c89b9`): an earlier
* Compose version recomputed the zoom on every selection and threw an operator working at
* zoom 15 out to about 7. AGENTS.md: reset clears the selection without changing the zoom.
*
* Tiles never arrive: an empty temporary cache and a source that always fails, the
* arrangement `3dd2cf10` gave the drawing test after the default fetcher read the user's
* disk cache and the real tile server. No running application exists: the path analysis is
* either absent, as it is before a runtime exists, or a recording stand-in passed through the
* window's [MapPathAnalysis] seam, which is what lets the zoom rule on selection run here.
*
* Headless: [StationMapWindow.Content] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class StationMapWindowScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
private lateinit var offlineTileFetcher: TileFetcher
@BeforeEach
fun openTileFetcher() {
offlineTileFetcher = TileFetcher(
directory.resolve("tile-cache"),
{ throw IOException("no tile source in this test") },
)
}
/** The fetcher owns a thread pool; the window closes it on dispose, and so does this. */
@AfterEach
fun closeTileFetcher() {
offlineTileFetcher.close()
}
/** Records what the window asks the path analysis for, instead of a running application. */
private class RecordingPathAnalysis : MapPathAnalysis {
val requested = mutableListOf<ChatMember>()
override fun request(
member: ChatMember,
snapshot: MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
) {
requested += member
}
}
private val ownLocator = "JO40HA"
private fun station(call: String, category: Int, locator: String = "JO50JP") = ChatMember().apply {
callSign = call
qra = locator
chatCategory = ChatCategory(category)
}
private class NoChatActions : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = ""
override fun ownQrgSecond(): String = ""
}
private fun windowState(prefs: ChatPreferences): MainWindowState {
val stations = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
),
RowKeys.byValue(StationColumns::rowKey),
)
val menu = MainMenuState()
return MainWindowState(
activeBands = EnumSet.of(Band.B_144, Band.B_432),
prefs = prefs,
stations = stations,
stationFilter = StationFilterState(),
directedMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
publicMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
clusterMessages = DataTableState(emptyList(), RowKeys.byReference<ClusterMessage>()),
qsoOfTheOther = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStationMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStation = SelectedStationState(
propagateNotQrv = {},
createSked = { _, _, _, _ -> false },
),
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
timeline = TimelineState(),
chatInput = ChatInputState(NoChatActions()),
menu = menu,
surroundings = MainWindowSurroundings(menu),
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = ThreadStatusButtons(),
outerSplitter = SplitterState(2, doubleArrayOf(0.5)) {},
messageSplitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {},
rightSplitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, doubleArrayOf(0.4, 0.6)) {},
)
}
private fun prefs(pathAnalysisVisible: Boolean = false) = preferences().apply {
stn_loginLocatorMainCat = ownLocator
isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
isGUIstationMapClusteringEnabled = false
}
private fun ComposeUiTest.show(
state: MainWindowState,
members: List<ChatMember>,
mapState: MapState,
pathAnalysis: MapPathAnalysis? = null,
onPathAnalysisAsked: () -> Unit = {},
) {
state.stations.replaceRows(members)
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StationMapWindow.Content(
mainWindowState = state,
tileFetcher = offlineTileFetcher,
chatMembers = { members },
mapState = mapState,
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
)
}
}
waitForIdle()
}
/** A map looking straight at a locator, so its marker sits in the middle of the canvas. */
private fun mapLookingAt(locator: String, zoom: Float): MapState {
val location = Location(locator)
return MapState(
zoom = zoom,
centerLon = location.longitude.toDegrees(),
centerLat = location.latitude.toDegrees(),
)
}
@Test
fun resetViewClearsTheSelectionThroughTheSelectorAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
assertSame(
dl1abc,
state.selectedStation.selected,
"reset cleared the panel's selection behind the selector's back",
)
assertEquals(15f, mapState.zoom, "reset changed the zoom")
val home = Location(ownLocator)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(home.latitude.toDegrees(), mapState.centerLat, 1e-9)
}
/** Without a selector -- before the host set one -- reset still clears the selection. */
@Test
fun resetViewWithoutASelectorClearsThePanelSelection() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
show(state, listOf(dl1abc), mapLookingAt("JO50JP", zoom = 9f))
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(null, state.selectedStation.selected)
}
/**
* A marker stands for a raw callsign. With one of its category variants already selected
* the click keeps that one, goes through the selector, and prepares "/cq CALL" over
* whatever was typed -- the JavaFX handleMapCallsignSelection behaviour.
*/
@Test
fun aMarkerClickSelectsThroughTheSelectorAndKeepsTheSelectedCategory() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
state.selectedStation.select(inSecond)
state.chatInput.value = TextFieldValue("half typed")
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 12f)
show(state, listOf(inMain, inSecond), mapState)
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertEquals(1, selectorCalls.size, "the marker click did not reach the station selector")
assertSame(inSecond, selectorCalls.single(), "the click swapped the selected category")
assertEquals("/cq DL1ABC ", state.chatInput.value.text)
assertEquals(12f, mapState.zoom, "selecting from the map changed the zoom")
}
@Test
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertSame(inMain, selectorCalls.single())
}
/**
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
*/
@Test
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 15f)
show(state, listOf(dl1abc), mapState, analysis)
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
val target = Location("JO50JP")
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
}
/**
* With grouping on, a selection zooms in only as far as it takes to break the station out
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
*/
@Test
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
val state = windowState(prefs)
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val far = mapLookingAt(ownLocator, zoom = 5f)
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
}
/** Without a running application there is nothing to ask, and the map stays put. */
@Test
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 15f)
val home = Location(ownLocator)
var asked = 0
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(15f, mapState.zoom)
}
/** The two header switches write the profile, so the map opens the same way next time. */
@Test
fun theHeaderSwitchesWriteThePreferences() = runComposeUiTest {
val prefs = prefs(pathAnalysisVisible = true)
val state = windowState(prefs)
show(state, emptyList(), MapState())
onNodeWithText("Hide path analysis").performClick()
waitForIdle()
assertEquals(false, prefs.isGUIstationMapPathAnalysisVisible)
onNodeWithText("Path analysis is hidden.").assertExists()
onNodeWithText("Show path analysis").assertExists()
onNodeWithText("Show path analysis").performClick()
waitForIdle()
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
// The grouping checkbox sits beside its label; the label itself is not clickable.
onNodeWithText("Group nearby stations").assertExists()
onNode(isToggleable()).assertIsOff().performClick()
waitForIdle()
onNode(isToggleable()).assertIsOn()
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
}
}
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class AirscoutTabStateTest {
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state by lazy { AirscoutTabState(prefs) }
}
@@ -8,11 +8,13 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class BeaconTabStateTest {
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
private class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences()
private inner class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var timerRestarts = 0
var validated = mutableListOf<String>()
@@ -9,8 +9,10 @@ import java.util.Locale
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class GuiOptionsTabStateTest {
class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
/*
* Over lambdas rather than over the preferences, so switching the language in a test does
@@ -33,7 +35,7 @@ class GuiOptionsTabStateTest {
* that gets saved, and it has to do so through the store, which is what repaints the
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
@@ -45,7 +47,7 @@ class GuiOptionsTabStateTest {
@Test
fun `reading comes straight from the preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
@@ -53,7 +55,7 @@ class GuiOptionsTabStateTest {
@Test
fun `writing reaches the preferences at once`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
@@ -68,7 +70,7 @@ class GuiOptionsTabStateTest {
// The JavaFX tab offered them as four separate checkboxes, not a choice of
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
@@ -0,0 +1,188 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The two tabs built around a list, drawn: Shortcuts and the worked-station database (#107).
*
* The shortcut lists are what the button row above the send line and the snippet menus are
* built from. Their write-through has a history: the refresh hook was called into thin air
* after the JavaFX buttons were unplugged, so an edited shortcut reached the operator only
* after the window was rebuilt (`fe4215bc`, Remove JavaFX). The hook is what these tests see.
*/
@OptIn(ExperimentalTestApi::class)
class ListTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Shortcuts
private class Shortcuts(isolated: ChatPreferences) {
val prefs = isolated.apply {
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
lst_txtSnipList.setAll(listOf("tnx qso 73"))
}
var shortcutRefreshes = 0
var snippetRefreshes = 0
val state = ShortcutsTabState(prefs, { shortcutRefreshes++ }, { snippetRefreshes++ })
}
@Test
fun shortcutsShowsBothListsWithWhatTheProfileHolds() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
onNodeWithText("Shortcut buttons above the message field").assertExists()
onNodeWithText("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)").assertExists()
onNode(hasSetTextAction() and hasText("pse sked")).assertExists()
onNode(hasSetTextAction() and hasText("tnx qso 73")).assertExists()
onNodeWithText("Add shortcut").assertExists()
onNodeWithText("Add new snippet").assertExists()
}
@Test
fun anEditedShortcutIsWrittenAndTheButtonsAreRefreshed() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
val row = onNode(hasSetTextAction() and hasText("pse sked"))
row.performScrollTo().performClick()
row.performTextReplacement("pse sked 432")
onNode(hasSetTextAction() and hasText("pse sked 432")).performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals(listOf("MYQRG", "pse sked 432"), s.prefs.lst_txtShortCutBtnList.snapshot())
assertTrue(s.shortcutRefreshes > 0, "the shortcut buttons were not told")
}
@Test
fun addingASnippetPutsTheTemplateOnTopAndWritesIt() = runComposeUiTest {
val s = Shortcuts(preferences())
showTab { ShortcutsTab(s.state) { } }
onNodeWithText("Add new snippet").performScrollTo().performClick()
waitForIdle()
assertEquals(
listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "tnx qso 73"),
s.prefs.lst_txtSnipList.snapshot(),
)
assertTrue(s.snippetRefreshes > 0, "the snippet menus were not told")
assertEquals(listOf("MYQRG", "pse sked"), s.prefs.lst_txtShortCutBtnList.snapshot())
}
// ------------------------------------------------------------------ Worked database
private class Worked(affected: Int = 12) {
val roster = SimpleRoster<ChatMember>().apply {
setAll(listOf(ChatMember().apply { callSign = "DL1ABC"; isWorked = true; isWorked144 = true }))
}
var resets = 0
var guiRefreshes = 0
var viewRefreshes = 0
val state = WorkedDatabaseTabState(
resetInDatabase = { resets++; affected },
refreshGuiLists = { guiRefreshes++ },
refreshView = { viewRefreshes++ },
workedRoster = roster,
)
}
@Test
fun theWorkedDatabaseShowsItsRows() = runComposeUiTest {
val w = Worked()
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Worked station database").assertExists()
onNodeWithText("DL1ABC").assertExists()
onNodeWithText("wkd").assertExists()
}
@Test
fun refreshReReadsTheRoster() = runComposeUiTest {
val w = Worked()
showTab { WorkedDatabaseTab(w.state) }
runOnIdle { w.roster.setAll(listOf(ChatMember().apply { callSign = "OK1XYZ" })) }
onNodeWithText("Refresh worked database").performScrollTo().performClick()
waitForIdle()
assertEquals(1, w.viewRefreshes)
onNodeWithText("OK1XYZ").assertExists()
onNodeWithText("DL1ABC").assertDoesNotExist()
}
/** Nothing is touched until the dialog is confirmed; then the database goes first. */
@Test
fun aResetAsksFirstAndReportsWhatItDid() = runComposeUiTest {
val w = Worked(affected = 12)
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Reset contest data"))
assertEquals(0, w.resets, "the reset ran before it was confirmed")
onNode(inDialog("Cancel")).performClick()
awaitGone(inDialog("Reset contest data"))
assertEquals(0, w.resets)
onNodeWithText("Reset worked, NOT-QRV and grid data...").performClick()
waitForIdle()
awaitNode(inDialog("Reset data")).performClick()
awaitGone(inDialog("Reset data"))
assertEquals(1, w.resets)
assertEquals(1, w.guiRefreshes)
onNodeWithText("Last reset affected 12 rows.").assertExists()
}
@Test
fun aFailedResetIsSaidAndLeavesTheListsAlone() = runComposeUiTest {
val w = Worked(affected = -1)
showTab { WorkedDatabaseTab(w.state) }
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Reset data")).performClick()
awaitGone(inDialog("Reset data"))
awaitNode(
inDialog(
"The contest data could not be reset. The internal database may be unavailable or " +
"inconsistent. No successful reset was confirmed.",
),
)
assertEquals(0, w.guiRefreshes, "the lists were cleared although the database was not")
}
}
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class LogSynchTabStateTest {
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session(detected: String? = null) {
val prefs = ChatPreferences()
private inner class Session(detected: String? = null) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var restarts = 0
var stops = 0
val detectedBroadcast = detected
@@ -5,12 +5,14 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class MessageHandlingTabStateTest {
class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `the state starts from the stored preferences`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(true)
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
@@ -26,7 +28,7 @@ class MessageHandlingTabStateTest {
fun `one checkbox drives both category flags`() {
// The JavaFX window offered a single checkbox and wrote both preferences from
// it; keeping only the main one would leave the second chat on its old value.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerEnabled = true
@@ -42,7 +44,7 @@ class MessageHandlingTabStateTest {
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerText = "qrv 432.200"
@@ -53,7 +55,7 @@ class MessageHandlingTabStateTest {
@Test
fun `the QRG reply flag writes through on its own`() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
state.autoAnswerToQRGRequestEnabled = true
@@ -0,0 +1,397 @@
package kst4contest.view.compose.tabs
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import androidx.compose.ui.test.onNodeWithTag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
/**
* The tabs that decide what this station says and hears, drawn: Message handling, GUI,
* Beacon and Notification (#107).
*
* Each is composed headless, shows its sections and key controls, and an edit made through
* the drawn control reaches the preferences. The message-handling tab carries the
* debug-to-file switch that the Compose redraw had dropped and `c8ce0e98` gave back.
*/
@OptIn(ExperimentalTestApi::class)
class MessagingTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Message handling
@Test
fun messageHandlingWritesBothCategoriesAndAppliesTheFileRecording() = runComposeUiTest {
val prefs = preferences().apply {
isMessageHandling_autoAnswerEnabled = false
isMessageHandling_debugModeToFileEnabled = false
}
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs) { recording += it }
showTab { MessageHandlingTab(state) }
onNodeWithText("Automatic answer to private messages").assertExists()
onNodeWithText("Diagnostics").assertExists()
toggle("Answer private messages automatically")
assertTrue(prefs.isMessageHandling_autoAnswerEnabled)
assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat, "the second category was left behind")
type("Answer text", "qrv 432.200")
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextMainCat)
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextSecondCat)
// The c8ce0e98 control: it writes the preference and asks the application to act.
toggle("Record the whole session to file")
assertTrue(prefs.isMessageHandling_debugModeToFileEnabled)
assertEquals(listOf(true), recording)
}
// ------------------------------------------------------------------ GUI
@Test
fun guiOptionsWritesTheStartupChoicesAndTheLanguage() = runComposeUiTest {
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
var storedLanguage = "en"
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
val state = GuiOptionsTabState(prefs, languages)
showTab { GuiOptionsTab(state) }
onNodeWithText("Startup design").assertExists()
onNodeWithText("Message filters active at startup").assertExists()
checkBeside("Start in the evening design").assertIsOff()
toggle("Start in the evening design")
assertTrue(prefs.isGUI_darkModeActiveByDefault)
val before = prefs.isGuiOptions_defaultFilterPublicMsgs
toggle("Public messages")
assertEquals(!before, prefs.isGuiOptions_defaultFilterPublicMsgs)
try {
onNodeWithText("EN").performScrollTo().performClick()
waitForIdle()
awaitNode(inPopup("DE")).performClick()
waitForIdle()
assertEquals("de", storedLanguage, "the picked language was not stored")
assertEquals("de", languages.language)
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
// ------------------------------------------------------------------ Beacon
private class Beacon(val prefs: ChatPreferences, val refuse: Boolean = false) {
var restarts = 0
val refusals = mutableListOf<String>()
val state = BeaconTabState(
prefs,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = { restarts++ },
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
}
@Test
fun beaconShowsOneSectionPerCategoryAndCommitsOnEnter() = runComposeUiTest {
val s = Beacon(preferences())
showTab { BeaconTab(s.state) { s.refusals += it } }
onNodeWithText("CQ beacon for Microwave").assertExists()
onNodeWithText("CQ beacon for 144/432 MHz").assertExists()
onNodeWithText("Shared interval").assertExists()
typeAndCommit("Beacon message", "cq de dn9apw %MYQRG%", occurrence = 1)
assertEquals("cq de dn9apw %MYQRG%", s.prefs.bcn_beaconTextSecondCat)
typeAndCommit("Interval in whole minutes", "7")
assertEquals(7, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
/** A refused template is reported, not stored, and the field goes back to what is stored. */
@Test
fun beaconRefusesAnInvalidTemplateAndPutsTheFieldBack() = runComposeUiTest {
val s = Beacon(preferences(), refuse = true)
s.prefs.bcn_beaconTextMainCat = "cq cq"
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndCommit("Beacon message", "cq %foo%")
assertEquals("cq cq", s.prefs.bcn_beaconTextMainCat)
assertEquals(1, s.refusals.size)
fieldBeside("Beacon message").assertTextEquals("cq cq")
}
/*
* Opening the tab used to commit all three fields once, because a committed field's
* focus callback fires with "not focused" when it is first attached: the beacon timer
* was restarted and a stored template the validator refuses was reported, although
* the operator had done nothing but look.
*/
@Test
fun openingTheBeaconTabNeitherRestartsTheTimerNorValidates() = runComposeUiTest {
val s = Beacon(preferences(), refuse = true)
s.prefs.bcn_beaconTextMainCat = "cq %foo%"
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
assertEquals(0, s.restarts, "opening the tab restarted the beacon timer")
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
}
/*
* The tab reads the interval from a plain preference getter, so nothing recomposes it
* after Enter commits. Compared with that stale value, leaving the field afterwards
* looked like a second edit and restarted the timer again.
*/
@Test
fun enterAndThenLeavingTheFieldRestartsTheTimerOnce() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndCommit("Interval in whole minutes", "9")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, s.restarts, "one edit restarted the beacon timer more than once")
}
/*
* Pins what happens to a typed but uncommitted entry when the field leaves the screen
* while it has the focus -- the settings window switching to another tab, or closing.
* Compose clears the focus of a removed focused node and reports that to the field as a
* focus loss, so the entry is committed, exactly once. That was so before the field
* learned to tell a real focus loss from its first attachment, and must stay so: an
* operator who typed a value and switched tabs expects it to count.
*/
@Test
fun aFieldRemovedWhileTypingCommitsTheEntryOnce() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val visible = mutableStateOf(true)
showTab { if (visible.value) BeaconTab(s.state) { s.refusals += it } }
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
runOnIdle { visible.value = false }
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
assertEquals(1, s.restarts)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
/*
* The stored value can change under a field 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.
*/
@Test
fun aValueResetBehindTheFieldIsCommittedAgainWhenTheFieldIsLeft() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val generation = mutableStateOf(0)
val reportA: (String) -> Unit = { s.refusals += it }
val reportB: (String) -> Unit = { s.refusals += it }
// A different callback per generation, so bumping it recomposes BeaconTab itself.
showTab { BeaconTab(s.state, if (generation.value % 2 == 0) reportA else reportB) }
typeAndCommit("Interval in whole minutes", "9")
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
runOnIdle {
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
generation.value++
}
waitForIdle()
fieldBeside("Interval in whole minutes").assertTextEquals("9")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the shown 9 was not committed again")
assertEquals(2, s.restarts)
}
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
@Test
fun aTypedIntervalIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
showTab { BeaconTab(s.state) { s.refusals += it } }
typeAndLeave("Interval in whole minutes", "9", leaveTo = "Beacon message")
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, s.restarts)
typeAndLeave("Beacon message", "cq de dn9apw", leaveTo = "Beacon message", leaveToOccurrence = 1)
assertEquals("cq de dn9apw", s.prefs.bcn_beaconTextMainCat)
assertEquals(1, s.restarts, "leaving a template field restarted the timer")
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
// ------------------------------------------------------------------ Notification
private class Notification(val prefs: ChatPreferences) {
init {
prefs.isNotify_dxClusterServerEnabled = false
}
val monitored = SimpleRoster<String>()
val serverEnabled = mutableListOf<Boolean>()
val ports = mutableListOf<Int>()
val spots = mutableListOf<ChatMember>()
val refusals = mutableListOf<String>()
val state = NotificationTabState(
prefs,
monitored,
applyDxClusterServerEnabled = { serverEnabled += it },
applyDxClusterServerPort = { ports += it },
broadcastTestSpot = { spots += it; "no logger connected" },
)
}
@Test
fun notificationWritesThroughAndStartsTheClusterServer() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
listOf("Sounds", "Band upgrade hints", "Local DX Cluster server", "QSO monitoring")
.forEach { onNodeWithText(it).assertExists() }
val before = s.prefs.isNotify_playSimpleSounds
toggle("Play simple sounds")
assertEquals(!before, s.prefs.isNotify_playSimpleSounds)
toggle("Run the DX Cluster server")
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
assertEquals(listOf(true), s.serverEnabled)
typeAndCommit("Spotter callsign", "dn9apw")
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
val storedPort = s.prefs.notify_dxclusterServerPort
typeAndCommit("TCP port", "0")
assertEquals(1, s.refusals.size, "an impossible port was not refused")
assertEquals(storedPort, s.prefs.notify_dxclusterServerPort)
assertTrue(s.ports.isEmpty(), "the server was moved to a refused port")
}
/*
* Opening the tab used to commit the port and the spotter callsign once, because a
* committed field's focus callback fires with "not focused" when it is first attached.
* A stored value the rule refuses was then reported the moment the tab was looked at.
*/
@Test
fun openingTheNotificationTabCommitsNothing() = runComposeUiTest {
val s = Notification(preferences())
s.prefs.setNotify_DXCSrv_SpottersCallSign("x")
showTab { NotificationTab(s.state) { s.refusals += it } }
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
assertTrue(s.ports.isEmpty(), "opening the tab moved the cluster server")
assertEquals("x", s.prefs.notify_DXCSrv_SpottersCallSign.get())
}
/** Leaving the field commits what was typed, without Enter. */
@Test
fun aTypedSpotterCallsignAndPortAreCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val s = Notification(preferences())
s.prefs.notify_dxclusterServerPort = 7300
showTab { NotificationTab(s.state) { s.refusals += it } }
typeAndLeave("Spotter callsign", "do5amf", leaveTo = "TCP port")
assertEquals("DO5AMF", s.prefs.notify_DXCSrv_SpottersCallSign.get())
typeAndLeave("TCP port", "7301", leaveTo = "Spotter callsign")
assertEquals(7301, s.prefs.notify_dxclusterServerPort)
assertEquals(listOf(7301), s.ports)
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
}
@Test
fun theFallbackBandAndTheTestSpotReachTheirTargets() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
val current = s.state.notify_optionalFrequencyPrefix
onNodeWithText(current.prefix + " MHz").performScrollTo().performClick()
waitForIdle()
val other = Band.entries.first { it != current }
awaitNode(inPopup(other.prefix + " MHz")).performClick()
waitForIdle()
assertEquals(other.prefix, s.prefs.notify_optionalFrequencyPrefix.get())
onNodeWithText("Send a test spot").performScrollTo().performClick()
waitForIdle()
assertEquals("DO5AMF", s.spots.single().callSign)
assertEquals(listOf("no logger connected"), s.refusals, "the outcome was not reported")
}
/** The add button asks for the callsign first; what is confirmed reaches the roster. */
@Test
fun aMonitoredCallsignAddedThroughTheDialogReachesTheRoster() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
onNodeWithText("Add monitored callsign").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("Add a callsign to the monitoring list"))
onNodeWithTag(MONITORED_CALLSIGN_FIELD_TAG).performTextReplacement("DL1ABC")
waitForIdle()
awaitNode(inDialog("OK")).performClick()
awaitGone(inDialog("OK"))
assertEquals(listOf("DL1ABC"), s.monitored.snapshot())
assertFalse(s.refusals.isNotEmpty(), "the callsign was refused: ${s.refusals}")
}
}
@@ -11,11 +11,13 @@ import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class NotificationTabStateTest {
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
@@ -0,0 +1,316 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.OperatorProfileStore
import kst4contest.model.OperatorProfile
import kst4contest.model.PaletteRole
import kst4contest.view.compose.JavaFxStylesheet
import kst4contest.view.compose.OperatorProfilePaletteFiles
import kst4contest.view.compose.PaletteSource
import kst4contest.view.compose.PaletteStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
/**
* The two tabs that belong to the operator profile, drawn: Profiles and Colours (#107).
*
* Profiles: only what refuses before the service writes anything, or what writes into a
* temporary registry, is exercised. Creating, renaming and deleting a real profile reach
* into the application directory under the operator's home ([ProfilesTabStateTest] says why
* that is not done here either).
*
* Colours: a store made of lambdas and stylesheet files in a temporary directory, so nothing
* reaches the operator's profile. The theme reading the store is `ThemeReadsPaletteStoreTest`;
* this is the tab that writes into it.
*/
@OptIn(ExperimentalTestApi::class)
class ProfileTabsScreenTest {
@TempDir
lateinit var directory: Path
// ------------------------------------------------------------------ Profiles
private fun profilesState(activated: MutableList<OperatorProfile> = mutableListOf()) =
ProfilesTabState(
OperatorProfileManagementService(
OperatorProfileStore(directory.resolve("profiles.xml").toString())
),
{ null },
{ activated += it },
)
@Test
fun profilesShowsTheDefaultProfileAndItsActions() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("Active profile:").assertExists()
onNodeWithText("Profile").assertExists()
onNodeWithText(state.profiles.single().displayName).assertExists()
listOf("New profile...", "Duplicate...", "Rename...", "Delete...",
"Change worked stations...", "Switch to selected profile...")
.forEach { onNodeWithText(it).assertExists() }
}
/** The default profile is the installation's own; deleting it is refused, and said so. */
@Test
fun deletingTheDefaultProfileIsRefusedInADialog() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("Delete...").performScrollTo().performClick()
waitForIdle()
// A selected profile is always asked about first; the refusal comes on confirmation.
awaitNode(inDialog("Delete operator profile"))
onNode(inDialog("Delete profile")).performClick()
awaitNode(
inDialog("The default profile uses the files of the installation itself and cannot be deleted."),
)
onNode(inDialog("OK")).performClick()
awaitGone(inDialog("Operator profiles"))
assertEquals(1, state.profiles.size)
}
@Test
fun switchingAsksTheApplicationToActivateTheSelectedProfile() = runComposeUiTest {
val activated = mutableListOf<OperatorProfile>()
val state = profilesState(activated)
showTab { ProfilesTab(state) }
onNodeWithText("Switch to selected profile...").performScrollTo().performClick()
waitForIdle()
assertEquals(listOf(state.profiles.single()), activated)
}
/** The New dialog opens and, cancelled, creates nothing. */
@Test
fun aCancelledNewProfileDialogCreatesNothing() = runComposeUiTest {
val state = profilesState()
showTab { ProfilesTab(state) }
onNodeWithText("New profile...").performScrollTo().performClick()
waitForIdle()
awaitNode(inDialog("New operator profile"))
onNode(inDialog("Cancel")).performClick()
awaitGone(inDialog("New operator profile"))
assertEquals(1, state.profiles.size)
}
// ------------------------------------------------------------------ Colours
private class Colours(directory: Path) {
val stored = mutableMapOf(false to "", true to "")
val store = PaletteStore(
shippedOf = { dark ->
JavaFxStylesheet.read(
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
)
},
fileRolesOf = { emptyMap() },
storedOverridesOf = { dark -> stored.getValue(dark) },
storeOverrides = { dark, encoded -> stored[dark] = encoded },
)
val files = OperatorProfilePaletteFiles(
dayFile = directory.resolve("day.css").toFile(),
eveningFile = directory.resolve("evening.css").toFile(),
)
val state = ColoursTabState(store, { false }, files)
val refusals = mutableListOf<String>()
val outcomes = mutableListOf<String>()
}
private fun ComposeUiTest.show(c: Colours) {
showTab { ColoursTab(c.state, { c.refusals += it }, { c.outcomes += it }) }
}
/** The label a role row carries right now: its name and where its colour came from. */
private fun Colours.labelOf(role: PaletteRole, name: String): String {
val provenance = when (state.roles().single { it.role == role }.source) {
PaletteSource.SHIPPED -> "shipped"
PaletteSource.FILE -> "from file"
PaletteSource.CHANGED -> "changed"
}
return "$name ($provenance)"
}
@Test
fun coloursShowsTheSixRolesAndTheWaysBack() = runComposeUiTest {
val c = Colours(directory)
show(c)
onNodeWithText("Colours of this design").assertExists()
onNodeWithText("Ways back").assertExists()
listOf(
PaletteRole.SURFACE to "Surface (menu strip, selected rows)",
PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)",
PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)",
PaletteRole.TEXT to "Text",
PaletteRole.ACCENT to "Accent",
PaletteRole.SEPARATOR to "Separator line",
).forEach { (role, name) ->
fieldBeside(c.labelOf(role, name)).assertTextEquals(c.state.roles().single { it.role == role }.hex)
}
onNodeWithText(
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
).assertExists()
}
@Test
fun aTypedColourIsCommittedIntoTheStore() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
assertEquals("#3C7A4B", c.state.roles().single { it.role == PaletteRole.ACCENT }.hex)
assertEquals(Color(0xFF3C7A4B), c.store.resolved(false).palette.accent)
assertTrue(c.refusals.isEmpty(), "a valid colour was refused: ${c.refusals}")
}
/*
* Opening the tab used to commit every role field once, because a committed field's
* focus callback fires with "not focused" when it is first attached. Each role then
* became an override of itself: the labels said "changed" instead of "shipped", and
* after "Save settings" the overrides hid the profile's stylesheet file for good.
*/
@Test
fun openingTheTabWritesNoColour() = runComposeUiTest {
val c = Colours(directory)
show(c)
assertEquals(mapOf(false to "", true to ""), c.stored, "opening the tab wrote overrides")
c.state.roles().forEach { row ->
assertEquals(PaletteSource.SHIPPED, row.source, "${row.role} is no longer the shipped colour")
}
assertTrue(c.refusals.isEmpty(), "opening the tab reported: ${c.refusals}")
}
/** Passing through a field without typing is not an edit either. */
@Test
fun enteringAndLeavingAFieldWithoutTypingWritesNothing() = runComposeUiTest {
val c = Colours(directory)
show(c)
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
val text = c.labelOf(PaletteRole.TEXT, "Text")
fieldBeside(accent).performScrollTo().performClick()
waitForIdle()
fieldBeside(text).performScrollTo().performClick()
waitForIdle()
fieldBeside(accent).performScrollTo().performClick()
waitForIdle()
assertEquals(mapOf(false to "", true to ""), c.stored, "passing through a field wrote an override")
}
/*
* The field shows the colour in upper case. Typing the same colour in lower case names
* no other colour, so it is no edit and must not become an override of itself.
*/
@Test
fun theSameColourInOtherCaseIsNoChange() = runComposeUiTest {
val c = Colours(directory)
show(c)
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
val shown = c.state.roles().single { it.role == PaletteRole.ACCENT }.hex
typeAndCommit(accent, shown.lowercase())
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).performScrollTo().performClick()
waitForIdle()
assertEquals(mapOf(false to "", true to ""), c.stored, "an unchanged colour was written as an override")
assertEquals(PaletteSource.SHIPPED, c.state.roles().single { it.role == PaletteRole.ACCENT }.source)
assertTrue(c.refusals.isEmpty(), "the colour was refused: ${c.refusals}")
}
/** Leaving the field still commits what was typed, without Enter. */
@Test
fun aTypedColourIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndLeave(
c.labelOf(PaletteRole.ACCENT, "Accent"),
"#3C7A4B",
leaveTo = c.labelOf(PaletteRole.TEXT, "Text"),
)
val accent = c.state.roles().single { it.role == PaletteRole.ACCENT }
assertEquals("#3C7A4B", accent.hex)
assertEquals(PaletteSource.CHANGED, accent.source)
assertEquals(
PaletteSource.SHIPPED,
c.state.roles().single { it.role == PaletteRole.TEXT }.source,
"the field that was only entered became an override",
)
}
@Test
fun aMistypedColourIsRefusedAndChangesNothing() = runComposeUiTest {
val c = Colours(directory)
show(c)
val before = c.state.roles().single { it.role == PaletteRole.TEXT }.hex
typeAndCommit(c.labelOf(PaletteRole.TEXT, "Text"), "#12")
assertEquals(1, c.refusals.size)
assertEquals(before, c.state.roles().single { it.role == PaletteRole.TEXT }.hex)
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).assertTextEquals(before)
}
@Test
fun resetToShippedPutsEveryRoleBack() = runComposeUiTest {
val c = Colours(directory)
show(c)
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
onNodeWithText(
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
).performScrollTo().performClick()
waitForIdle()
assertEquals(c.store.shipped(false).accent, c.store.resolved(false).palette.accent)
}
/** The export writes the shipped sheet into the profile, and never over an existing file. */
@Test
fun theExportWritesTheStylesheetOnceAndThenLeavesItAlone() = runComposeUiTest {
val c = Colours(directory)
show(c)
val day: File = c.files.dayFile
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
waitForIdle()
assertTrue(day.isFile, "nothing was written")
assertEquals(listOf("Written to ${day.path}. Edit it and restart to see the result."), c.outcomes)
day.writeText("/* the operator's own */")
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
waitForIdle()
assertEquals("/* the operator's own */", day.readText(), "an existing stylesheet was replaced")
assertEquals("${day.path} is already there and was left untouched.", c.outcomes.last())
}
}
@@ -0,0 +1,140 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import kst4contest.view.compose.Kst4ContestTheme
/*
* Shared plumbing for the settings-tab screen tests (#107).
*
* The form controls draw a label beside a field, not a labelled field: there is no semantic
* link between the two. These helpers find a control by the label the operator reads, on the
* same row, which is exactly how the operator finds it -- and which breaks, as it should,
* when a label moves away from its field.
*/
/**
* Draws one tab the way the settings window does: themed, in a column that scrolls, so a
* control further down can be scrolled to and clicked.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
content()
}
}
}
waitForIdle()
}
@OptIn(ExperimentalTestApi::class)
private fun ComposeUiTest.labelNode(label: String, occurrence: Int): SemanticsNode {
val labels = onAllNodes(hasText(label, substring = false)).fetchSemanticsNodes()
check(labels.size > occurrence) { "no label '$label' (#$occurrence) on screen; found ${labels.size}" }
return labels.sortedBy { it.boundsInRoot.top }[occurrence]
}
/** The control matching [matcher] on the row of [label], picked by [choose] among candidates. */
@OptIn(ExperimentalTestApi::class)
private fun ComposeUiTest.besideLabel(
label: String,
occurrence: Int,
matcher: SemanticsMatcher,
choose: (label: SemanticsNode, candidates: List<SemanticsNode>) -> SemanticsNode?,
): SemanticsNodeInteraction {
val anchor = labelNode(label, occurrence)
val all = onAllNodes(matcher).fetchSemanticsNodes()
val sameRow = all.filter { node ->
node.boundsInRoot.center.y in anchor.boundsInRoot.top..anchor.boundsInRoot.bottom
}
val chosen = choose(anchor, sameRow) ?: error("no control beside '$label' (#$occurrence)")
return onAllNodes(matcher)[all.indexOf(chosen)]
}
/**
* The text field to the right of [label]. Matched by its editable text rather than by its
* set-text action, which a disabled field does not offer, so a locked field is found too.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.fieldBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
besideLabel(label, occurrence, SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) { anchor, candidates ->
candidates.filter { it.boundsInRoot.left >= anchor.boundsInRoot.right - 1f }
.minByOrNull { it.boundsInRoot.left }
}
/** The checkbox directly to the left of [label], as [kst4contest.view.compose.Form.check] draws it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.checkBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
besideLabel(label, occurrence, isToggleable()) { anchor, candidates ->
candidates.filter { it.boundsInRoot.right <= anchor.boundsInRoot.left + 1f }
.maxByOrNull { it.boundsInRoot.right }
}
/** Ticks or unticks the checkbox beside [label], scrolling to it first. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.toggle(label: String, occurrence: Int = 0) {
checkBeside(label, occurrence).performScrollTo().performClick()
waitForIdle()
}
/** Replaces the text of the field beside [label], as typing a whole new value would. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.type(label: String, text: String, occurrence: Int = 0) {
fieldBeside(label, occurrence).performTextReplacement(text)
waitForIdle()
}
/**
* Types into a field that commits on Enter or on leaving it, the way
* [kst4contest.view.compose.Form.committed] does, and presses Enter.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.typeAndCommit(label: String, text: String, occurrence: Int = 0) {
val field = fieldBeside(label, occurrence)
field.performScrollTo().performClick()
field.performTextReplacement(text)
field.performKeyInput { pressKey(Key.Enter) }
waitForIdle()
}
/**
* Types into a field that commits on leaving it, then leaves it the way the operator does:
* by clicking into the field beside [leaveTo]. No Enter is pressed, so only the focus loss
* can commit the entry.
*/
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.typeAndLeave(
label: String,
text: String,
leaveTo: String,
occurrence: Int = 0,
leaveToOccurrence: Int = 0,
) {
val field = fieldBeside(label, occurrence)
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement(text)
waitForIdle()
fieldBeside(leaveTo, leaveToOccurrence).performScrollTo().performClick()
waitForIdle()
}
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ShortcutsTabStateTest {
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var shortcutRefreshes = 0
var snippetRefreshes = 0
@@ -0,0 +1,198 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.runtime.mutableStateOf
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.feed.ConnectionStateFeed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.inDialog
import kst4contest.view.compose.inPopup
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The Station tab, drawn (#107).
*
* [StationTabStateTest] pins the facade; this pins that the drawn controls show what the
* profile holds and that editing one writes through, which is the class of fault the
* settings window shipped repeatedly with a green build (`83e23f55`, Etappe 7).
*/
@OptIn(ExperimentalTestApi::class)
class StationTabScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
private class Session(isolated: ChatPreferences) {
val prefs = isolated.apply {
stn_loginCallSign = "DN9APW"
stn_on4kstServersPort = 23001
isStn_bandActive144 = true
isStn_bandActive10G = false
}
val mainApplied = mutableListOf<ChatCategory?>()
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
val state = StationTabState(prefs, { mainApplied += it }, { }, { link.value })
}
@Test
fun theTabShowsItsSectionsAndWhatTheProfileHolds() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() }
fieldBeside("Callsign").assertTextEquals("DN9APW")
fieldBeside("Server port").assertTextEquals("23001")
checkBeside("144 MHz").assertIsOn()
checkBeside("10 GHz").assertIsOff()
}
@Test
fun editingTheTextFieldsWritesThroughToThePreferences() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
type("Callsign", "DO5AMF")
type("Locator", "JO51IJ")
type("Server host name", "www.on4kst.info")
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
fieldBeside("Callsign").assertTextEquals("DO5AMF")
}
/** A number field writes only what parses; a half-typed entry leaves the stored value. */
@Test
fun theNumberFieldsWriteOnlyWhatParses() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
type("Server port", "23000")
assertEquals(23000, s.prefs.stn_on4kstServersPort)
type("Server port", "23x")
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
type("QTF", "135,5")
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
@Test
fun aBandCheckboxWritesThrough() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
toggle("10 GHz")
assertTrue(s.prefs.isStn_bandActive10G)
checkBeside("10 GHz").assertIsOn()
toggle("144 MHz")
assertFalse(s.prefs.isStn_bandActive144)
}
@Test
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
val s = Session(preferences())
s.prefs.loginChatCategoryMain = ChatCategory(2)
showTab { StationTab(s.state) }
val target = s.state.availableCategories.first { it.categoryNumber == 3 }
val current = s.state.describeCategory(s.prefs.loginChatCategoryMain)
// The main category is the upper of the two pickers; the second may show the same name.
onAllNodesWithText(current).onFirst().performScrollTo().performClick()
waitForIdle()
awaitNode(inPopup(s.state.describeCategory(target))).performClick()
waitForIdle()
assertEquals(3, s.prefs.loginChatCategoryMain?.categoryNumber)
assertEquals(3, s.mainApplied.single()?.categoryNumber, "the running session was not told")
}
/*
* The credentials are locked while a session exists or is being made: the running
* connection would not pick up a change, so an edit would look applied and be ignored
* until the next login. The lock used to be a plain field nothing ever wrote.
*/
@Test
fun theLoginFieldsAreLockedWhileConnectedOrConnecting() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val credentials = listOf("Callsign", "Name, main category", "Name, second category", "Locator", "Password")
credentials.forEach { fieldBeside(it).assertIsEnabled() }
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
runOnIdle { s.link.value = On4KstConnectionState.ONLINE }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
fieldBeside("Server host name").assertIsEnabled()
runOnIdle { s.link.value = On4KstConnectionState.DISCONNECTED }
waitForIdle()
credentials.forEach { fieldBeside(it).assertIsEnabled() }
}
/*
* The application hands the tab `surroundings.connectionState`, which ConnectionStateFeed
* fills from the controller's callback. The application class itself cannot be built in
* a test -- it needs a controller with its database, the windows and the update service --
* so this drives the same chain from the feed on: a pushed state reaches the drawn tab
* without anything rebuilding it.
*/
@Test
fun theLoginFieldsFollowWhatTheConnectionFeedPushes() = runComposeUiTest {
val surroundings = MainWindowSurroundings()
val immediately = object : UiDispatcher {
override fun runOnUi(task: Runnable) = task.run()
override fun isUiThread(): Boolean = true
}
val feed = ConnectionStateFeed(surroundings, immediately)
val state = StationTabState(preferences(), { }, { }, { surroundings.connectionState })
showTab { StationTab(state) }
fieldBeside("Callsign").assertIsEnabled()
runOnIdle { feed.push(On4KstConnectionState.AUTHENTICATING, null) }
waitForIdle()
fieldBeside("Callsign").assertIsNotEnabled()
// An absent state reads as DISCONNECTED in the feed, and unlocks the fields.
runOnIdle { feed.push(null, null) }
waitForIdle()
fieldBeside("Callsign").assertIsEnabled()
}
}
@@ -6,11 +6,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class StationTabStateTest {
class StationTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
@@ -87,7 +89,7 @@ class StationTabStateTest {
fun `the second chat switch lives with the login settings`() {
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
// next to the category pickers, and it decides what the Connect button does.
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = StationTabState(prefs, { }, { })
state.loginToSecondChatEnabled = true
@@ -0,0 +1,146 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The three integration tabs, drawn: Log synch, TRX synch and Airscout (#107).
*
* All three draw from a facade with no snapshot state in it and count a revision on every
* write so the control redraws. That device is what these tests watch: a write that
* reached the preferences and not the screen is the class-1 fault of `83e23f55`.
*
* The "Choose..." button of the log file is never pressed here: it opens a modal AWT file
* dialog, which has no place in a headless test.
*/
@OptIn(ExperimentalTestApi::class)
class SynchTabsScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
// ------------------------------------------------------------------ Log synch
private class LogSynch(isolated: ChatPreferences, detected: String? = "192.168.1.255") {
val prefs = isolated.apply {
// Both listeners default to on; start from off so a tick is a visible change.
isLogsynch_ucxUDPWkdCallListenerEnabled = false
isLogsynch_wintestNetworkListenerEnabled = false
}
var restarts = 0
var stops = 0
val state = LogSynchTabState(prefs, { restarts++ }, { stops++ }, { detected })
}
@Test
fun logSynchShowsItsSectionsAndTheDetectedBroadcastAddress() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
onNodeWithText("File polling for worked callsigns").assertExists()
onNodeWithText("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)").assertExists()
onNodeWithText("Win-Test Network-Listener").assertExists()
onNodeWithText("Choose...").assertExists()
fieldBeside("UDP broadcast address for Win-Test (default = internet interface broadcast)")
.assertTextEquals("192.168.1.255")
}
@Test
fun logSynchWritesThroughAndStartsTheWinTestListener() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
toggle("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net")
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled)
checkBeside("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net").assertIsOn()
type("Shared UDP port for QSO and TRX messages [default 12060]:", "12061")
assertEquals(12061, s.prefs.logsynch_ucxUDPWkdCallListenerPort)
val winTestPort = "UDP-Port for Win-Test listener (default is 9871)"
// The port field is greyed out until the listener is switched on; greyed out, it
// offers no text input, so it is found by the port it shows.
onNodeWithText(s.prefs.logsynch_wintestNetworkPort.toString()).assertIsNotEnabled()
toggle("Receive Win-Test network based UDP log messages")
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled)
assertEquals(1, s.restarts, "switching the listener on did not start it")
type(winTestPort, "9872")
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
// A refused entry is kept on screen as typed, and nothing is written.
type(winTestPort, "98x")
fieldBeside(winTestPort).assertTextEquals("98x")
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
type("KST station name in Win-Test network (src of SKED packets)", " KST2 ")
assertEquals("KST2", s.prefs.logsynch_wintestNetworkStationNameOfKST)
}
// ------------------------------------------------------------------ TRX synch
@Test
fun trxSynchWritesThroughAndSwitchesTheQrgFollower() = runComposeUiTest {
val prefs = preferences()
val follower = mutableListOf<Boolean>()
val state = TrxSynchTabState(prefs) { follower += it }
showTab { TrxSynchTab(state) }
onNodeWithText("Receive UCXLog TRX info").assertExists()
onNodeWithText("What an enabled source does").assertExists()
toggle("Update MYQRG from RadioInfo messages received on the shared log-sync port")
assertTrue(prefs.isTrxSynch_ucxLogUDPListenerEnabled)
assertEquals(listOf(true), follower)
toggle("Use pass frequency from Win-Test STATUS (instead of own QRG)")
assertTrue(prefs.isLogsynch_wintestUsePassQrg)
type("Win-Test station name filter (e.g. STN1, empty = accept all)", "STN1 ")
assertEquals("STN1", prefs.logsynch_wintestNetworkStationNameOfWintestClient1)
}
// ------------------------------------------------------------------ Airscout
@Test
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
// The link defaults to on; start from off so a tick is a visible change.
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
val state = AirscoutTabState(prefs)
showTab { AirscoutTab(state) }
onNodeWithText("AirScout UDP communication").assertExists()
onNodeWithText("Queried frequency").assertExists()
toggle("Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)")
assertTrue(prefs.isAirScout_asUDPListenerEnabled)
val port = "AirScout UDP port [9872] — reconnect after changing:"
type(port, "9873")
assertEquals(9873, prefs.airScout_asCommunicationPort)
type(port, "70000")
fieldBeside(port).assertTextEquals("70000")
assertEquals(9873, prefs.airScout_asCommunicationPort, "an out-of-range port was written")
type("AirScout server identifier [AS]:", "AS2")
assertEquals("AS2", prefs.airScout_asServerNameString)
}
}
@@ -5,11 +5,13 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TrxSynchTabStateTest {
class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
private class Session {
val prefs = ChatPreferences()
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
}
@@ -0,0 +1,121 @@
package kst4contest.view.i18n
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.tabs.BeaconTabState
import kst4contest.view.compose.tabs.GuiOptionsTab
import kst4contest.view.compose.tabs.GuiOptionsTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
/**
* The texts that stayed English after stage 9 now follow the language as well.
*
* One sample from each of the three ways a text reaches the operator: a label read inside a
* composition, a column title built outside one, and a refusal produced by a tab state. All
* three have to switch, and each goes through a different door.
*/
@OptIn(ExperimentalTestApi::class)
class RemainingTextsFollowTheLanguageTest {
/* Never the no-argument ChatPreferences: it would touch the operator's real profile. */
@TempDir
lateinit var directory: Path
@Test
fun theMovedTextsAppearInGermanAfterTheSwitch() = runComposeUiTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, store)
val beacon = BeaconTabState(
prefs,
validateBeaconTemplate = {},
restartBeaconTimer = {},
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
val score = StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
).single { it.id == "score" }
setContent {
CompositionLocalProvider(LocalStrings provides store.strings) {
GuiOptionsTab(state)
}
}
waitForIdle()
onNodeWithText("Start in the evening design").assertExists()
assertEquals("Score", score.title())
try {
runOnIdle { store.use("de") }
waitForIdle()
onNodeWithText("Im Abenddesign starten").assertExists()
onNodeWithText("Kein Filter").assertExists()
onNodeWithText("Ein gearbeitetes Großfeld markieren").assertExists()
assertEquals("Wertung", score.title(), "the column title stayed in the old language")
assertEquals(
"Einen ganzzahligen Bakenabstand von mindestens 1 Minute eingeben.",
beacon.commitBeaconInterval("0"),
"the refusal from the tab state stayed in the old language",
)
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { store.use("en") }
}
}
/**
* The settings window's notice: its title is read in the composition, its message is
* produced outside one by the save action -- the same split as above, one window further.
*/
@Test
fun theSettingsNoticeFollowsTheLanguage() = runComposeUiTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val notices = SettingsNotices()
setContent {
CompositionLocalProvider(LocalStrings provides store.strings) {
SettingsContent(
tabs = listOf(SettingsTab("Tab") {}),
notices = notices,
buttons = {},
close = {},
)
}
}
try {
runOnIdle {
store.use("de")
notices.problem(CurrentStrings.get().settingsWriteFailed)
}
waitForIdle()
onNodeWithText("Einstellungen").assertExists()
onNodeWithText(
"Die Konfigurationsdatei konnte nicht geschrieben werden. " +
"Die Einstellungen bleiben für diese Sitzung aktiv.",
).assertExists()
} finally {
runOnIdle { store.use("en") }
}
}
}
@@ -5,6 +5,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.lang.reflect.Modifier
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* Nothing that goes to the server or to another radio amateur has got into the texts.
@@ -17,7 +19,7 @@ import java.lang.reflect.Modifier
* deliberately not: a partial file is the normal state of a contribution in progress, and a
* check over every language would turn the arrival of one into a red build.
*/
class TranslationBoundaryTest {
class TranslationBoundaryTest(@TempDir private val tempDir: Path) {
private val everyText: List<String> =
Translations.BY_LANGUAGE.values.flatMap { it.values }
@@ -63,7 +65,7 @@ class TranslationBoundaryTest {
@Test
fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() {
val prefs = ChatPreferences()
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val defaults = ChatPreferences::class.java.declaredFields
.filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) }
+72
View File
@@ -68,8 +68,80 @@ subprojects {
}
}
/*
* Tests never see the operator's real home directory. ApplicationFileUtils resolves the
* application folder below user.home, and the no-argument ChatPreferences constructor
* seeds preferences.xml there when it is missing; a test that slipped through would
* create or rewrite files in ~/.praktiKST of whoever runs the build. Each test task's JVM
* gets its own scratch home, build/test-user-home/<task name>, emptied before the task
* runs, so one test task can never wipe another's. Tests that need their own profile
* directory still set user.home themselves and restore it afterwards.
*
* The redirection alone would hide the mistake, so every test task is finalized by
* checkTestUserHome, which fails the build if a preferences.xml was left in any scratch
* home: tests are expected to use ChatPreferences(Path) with a @TempDir. It is a
* finalizer and not a doLast because a doLast is skipped when a test fails, and a
* failing test must not hide the stray file. The check stays deliberately narrow: other
* files land in the scratch .praktiKST by design, for example the sound files
* PlayAudioUtils seeds from the bundled resources, and absorbing those is what the
* redirection is for. Only preferences.xml marks a test that should have used its own file.
*
* The opt-in ON4KST practice tests, which deliberately read the real profile, need
* -Don4kst.live=true, which the test task does not forward; they skip here either way
* and are meant to be run from an IDE.
*/
val testUserHomeRoot = layout.buildDirectory.dir("test-user-home").get().asFile
val checkTestUserHome = tasks.register("checkTestUserHome") {
description = "Fails if a test wrote preferences.xml into its scratch user.home"
group = "verification"
doLast {
val strayPreferences = testUserHomeRoot.listFiles().orEmpty()
.map { it.resolve(".praktiKST/preferences.xml") }
.filter { it.exists() }
if (strayPreferences.isNotEmpty()) {
throw GradleException(
"A test wrote ${strayPreferences.joinToString()}: it used the user " +
"profile instead of ChatPreferences(Path) with a @TempDir"
)
}
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
val testUserHome = testUserHomeRoot.resolve(name)
/*
* The scratch home is a declared output. Test tasks are cacheable, and a cache hit
* skips doFirst; as an output the directory is restored together with the cached
* result, so checkTestUserHome always judges the files that belong to these inputs
* and never a leftover from another run.
*
* Skiko unpacks its native library (about 30 MB) below user.home unless told
* otherwise. It is kept beside the scratch home instead, so it neither bloats every
* cache entry nor is unpacked again on each run.
*
* Both paths are passed through an argument provider rather than systemProperty():
* a system property is a task input, and an absolute path there would make the
* cache key differ between checkouts.
*/
val skikoDataPath = layout.buildDirectory.dir("skiko-data").get().asFile
outputs.dir(testUserHome)
jvmArgumentProviders.add(CommandLineArgumentProvider {
listOf(
"-Duser.home=${testUserHome.absolutePath}",
"-Dskiko.data.path=${skikoDataPath.absolutePath}"
)
})
doFirst {
// deleteRecursively() reports failure instead of throwing. A file left over
// from an earlier run would later be blamed on this one, so stop here.
if (!testUserHome.deleteRecursively()) {
throw GradleException("Could not empty the test user.home $testUserHome")
}
testUserHome.mkdirs()
}
finalizedBy(checkTestUserHome)
}
// XML for CI to read, HTML for people. Gradle does not wire the report after the test
@@ -321,6 +321,13 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
rotatorClient.start();
}
/**
* 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.
*/
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = 1.0;
/**
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
*
@@ -399,7 +406,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
double currentAzimuth = lastReportedRotatorAzimuth;
if (Double.isFinite(currentAzimuth)
&& angularDistance(currentAzimuth, targetAzimuth) < 0.5) {
&& angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) {
LOGGER.log(
Level.FINE,
@@ -40,7 +40,8 @@ public class DXClusterThreadPooledServer implements Runnable {
Executors.newSingleThreadScheduledExecutor();
private volatile boolean stopped;
private ServerSocket serverSocket;
/** Assigned by run() on the server thread and closed by stop() from another one. */
private volatile ServerSocket serverSocket;
public DXClusterThreadPooledServer(
int port,
@@ -52,12 +53,38 @@ public class DXClusterThreadPooledServer implements Runnable {
this.callBackToController = callback;
}
/**
* Creates a server on a socket that is already bound. Tests use this to
* listen on an ephemeral loopback port; the application binds the
* configured port itself through the public constructor.
*
* @param boundServerSocket bound server socket this server takes ownership of
* @param chatController controller that provides the spot preferences
* @param callback thread-status sink
*/
DXClusterThreadPooledServer(
ServerSocket boundServerSocket,
ChatController chatController,
ThreadStatusCallback callback
) {
this.serverSocket = boundServerSocket;
this.serverPort = boundServerSocket.getLocalPort();
this.chatController = chatController;
this.callBackToController = callback;
}
@Override
public void run() {
Thread.currentThread().setName("DXCluster-thread-pooled-server");
try {
serverSocket = new ServerSocket(serverPort);
/*
* A pre-bound socket is used once; any later run() binds the
* configured port again, as it always did.
*/
if (serverSocket == null || serverSocket.isClosed()) {
serverSocket = new ServerSocket(serverPort);
}
if (stopped) {
return;
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,7 @@ import kst4contest.model.ThreadStateMessage;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -37,19 +38,16 @@ public class PstRotatorClient implements Runnable {
* @param listener Callback für den Chatcontroller
*/
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
// Laut Manual antwortet PSTRotator oft auf Port+1.
// Wir binden uns also standardmäßig auf remotePort + 1.
this.localPort = remotePort + 1;
this.listener = listener;
this(host, remotePort, remotePort + 1, listener, callBack);
}
/**
* alternative constructor for seting the remote port explicitely
*/
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener) {
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
this.localPort = localPort;
@@ -195,8 +193,24 @@ public class PstRotatorClient implements Runnable {
// Öffentliche Steuermethoden
public void setAzimuth(double degrees) {
// Formatierung ohne unnötige Nachkommastellen, falls nötig
sendCommand("AZIMUTH", String.valueOf((int) degrees));
sendCommand("AZIMUTH", formatAzimuth(degrees));
}
/**
* Formats an azimuth with one decimal and a dot as separator, independent of the
* default locale, wrapped into [0, 360): 360.0 is sent as 0.0, -10 as 350.0. The
* azimuth used to be truncated to whole degrees, so 145.7 turned the antenna to 145.
*
* @throws IllegalArgumentException for NaN or an infinite value, which has no heading
*/
static String formatAzimuth(double degrees) {
if (!Double.isFinite(degrees)) {
throw new IllegalArgumentException("Azimuth is not a finite number: " + degrees);
}
double rounded = Math.round(degrees * 10.0) / 10.0;
double wrapped = ((rounded % 360.0) + 360.0) % 360.0;
// Rounding can leave a tiny negative remainder that would print as -0.0.
return String.format(Locale.ROOT, "%.1f", wrapped == 0.0 ? 0.0 : wrapped);
}
public void setElevation(double degrees) {
@@ -6,6 +6,7 @@ import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.List;
@@ -43,6 +44,26 @@ public class ReadUDPbyAirScoutMessageThread extends Thread {
this.callBackToController = callback;
}
/**
* Creates a listener on a socket that is already bound. Tests use this to
* listen on an ephemeral loopback port; the application binds the configured
* port itself through the public constructor.
*
* @param boundSocket bound datagram socket this thread takes ownership of
* @param client controller that receives the parsed responses
* @param callback thread-status sink
*/
ReadUDPbyAirScoutMessageThread(
DatagramSocket boundSocket,
ChatController client,
ThreadStatusCallback callback
) {
this.socket = boundSocket;
this.localPort = boundSocket.getLocalPort();
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
@@ -107,9 +128,11 @@ public class ReadUDPbyAirScoutMessageThread extends Thread {
);
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort));
if (socket == null) {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort));
}
socket.setSoTimeout(3000);
while (!Thread.currentThread().isInterrupted()) {
@@ -136,7 +159,8 @@ public class ReadUDPbyAirScoutMessageThread extends Thread {
String received = new String(
packet.getData(),
packet.getOffset(),
packet.getLength()
packet.getLength(),
StandardCharsets.UTF_8
).trim();
if (received.contains(
@@ -2,6 +2,7 @@ package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Arrays;
@@ -32,7 +33,12 @@ import kst4contest.model.Band;
*/
public class ReadUDPbyUCXMessageThread extends Thread {
private BufferedReader reader;
private Socket socket;
/**
* The listening socket. It is owned by this thread: {@link #run()} binds it
* (unless a pre-bound socket was handed in) and {@link #interrupt()} closes
* it, which is the only way to wake a blocking {@code receive()}.
*/
private volatile DatagramSocket socket;
private ChatController client;
private int udpPortNr = 12060;
private ThreadStatusCallback callBackToController;
@@ -49,6 +55,22 @@ public class ReadUDPbyUCXMessageThread extends Thread {
this.callBackToController = callback;
}
/**
* Creates a listener on a socket that is already bound. Tests use this to
* listen on an ephemeral loopback port; the application binds the configured
* port itself through the public constructor.
*
* @param boundSocket bound datagram socket this thread takes ownership of
* @param client controller that receives the parsed packets
* @param callback thread-status sink
*/
ReadUDPbyUCXMessageThread(DatagramSocket boundSocket, ChatController client, ThreadStatusCallback callback) {
this.socket = boundSocket;
this.udpPortNr = boundSocket.getLocalPort();
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
@@ -138,30 +160,40 @@ public class ReadUDPbyUCXMessageThread extends Thread {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
DatagramSocket socket = null;
boolean running;
byte[] buf = new byte[1777];
DatagramPacket packet = new DatagramPacket(buf, buf.length);
try {
// socket = new DatagramSocket(12060);
socket = new DatagramSocket(udpPortNr);
if (socket == null) {
socket = new DatagramSocket(udpPortNr);
}
socket.setSoTimeout(2000); //TODO try for end properly
}
catch (SocketException e) {
//this will catch the repeating Sockettimeoutexception...nothing to do
// e.printStackTrace();
}
while (true) {
/*
* interrupt() sets the flag and closes the socket; the closed socket makes
* the blocking receive() throw, and the flag ends the loop.
*/
while (!isInterrupted()) {
boolean timeOutIndicator = false;
// packet = new DatagramPacket(buf, buf.length); //TODO: Changed that due to memory leak, check if all works (seems like that)
// DatagramPacket packet = new DatagramPacket(SRPDefinitions.BYTE_BUFFER_MAX_LENGTH); //TODO: Changed that due to memory leak, check if all works (seems like that)
try {
/*
* A DatagramPacket remembers the length of the last datagram it
* received; without this reset a long packet after a short one is
* truncated to the short one's length (#115).
*/
packet.setLength(packet.getData().length);
socket.receive(packet);
} catch (SocketTimeoutException e2) {
@@ -171,6 +203,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// e2.printStackTrace();
}
catch (IOException e) {
if (isInterrupted() || socket == null || socket.isClosed()) {
break;
}
// TODO Auto-generated catch block
e.printStackTrace();
} catch (NullPointerException nE) {
@@ -189,8 +224,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort()));
socket.receive(packet);
packet.setLength(packet.getData().length);
socket.setSoTimeout(3000);
socket.receive(packet);
} catch (Exception ex) {
System.out.println("ReadUDPByUCXMsgTh: Could not solve that. Program Restart needed.");
throw new RuntimeException(ex);
@@ -203,7 +239,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
InetAddress address = packet.getAddress();
int port = packet.getPort();
// packet = new DatagramPacket(buf, buf.length, address, port);
String received = new String(packet.getData(), packet.getOffset(), packet.getLength());
String received = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
received = received.trim();
@@ -227,7 +263,26 @@ public class ReadUDPbyUCXMessageThread extends Thread {
}
if (!timeOutIndicator) {
processUCXUDPMessage(received);
/*
* Safety net: one malformed or unexpected packet must not end the
* listener. The packet is dropped and the loop keeps receiving.
*/
try {
processUCXUDPMessage(received);
} catch (RuntimeException exception) {
System.out.println("[ReadUDPFromUCX, warning]: packet dropped after unexpected error: "
+ exception);
exception.printStackTrace();
ThreadStateMessage droppedPacketMessage = new ThreadStateMessage(this.ThreadNickName, true,
"packet dropped after error: " + exception, false);
try {
callBackToController.onThreadStatus(ThreadNickName, droppedPacketMessage);
} catch (RuntimeException callbackException) {
// A failing status callback must not end the listener either.
System.out.println("[ReadUDPFromUCX, warning]: status callback failed: "
+ callbackException);
}
}
} else {
//dont process the empty message
}
@@ -236,12 +291,16 @@ public class ReadUDPbyUCXMessageThread extends Thread {
}
// Covers an interrupt() that arrived before the socket was bound.
DatagramSocket listeningSocket = socket;
if (listeningSocket != null && !listeningSocket.isClosed()) {
listeningSocket.close();
}
}
public String processUCXUDPMessage(String udpPacketToProcess) {
File logUDPMessageToThisFile;
String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false);
@@ -421,24 +480,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
}
logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
FileWriter fileWriterPersistUDPToFile = null;
BufferedWriter bufwrtrRawMSGOut;
try {
fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true);
} catch (IOException e1) {
e1.printStackTrace();
}
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
bufwrtrRawMSGOut.flush();
bufwrtrRawMSGOut.close();
appendToUdpBackupFile(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup(),
"\n" + workedCall.toString());
}
}
@@ -464,8 +508,15 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Element element = (Element) node;
String qrg = element.getElementsByTagName("Freq").item(0).getTextContent();
String mode = element.getElementsByTagName("Mode").item(0).getTextContent();
Node freqNode = element.getElementsByTagName("Freq").item(0);
Node modeNode = element.getElementsByTagName("Mode").item(0);
if (freqNode == null || modeNode == null) {
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo without Freq or Mode ignored");
continue;
}
String qrg = freqNode.getTextContent();
String mode = modeNode.getTextContent();
// System.out.println("QRG Length: " + qrg.length() + " // " + qrg);
@@ -553,6 +604,28 @@ public class ReadUDPbyUCXMessageThread extends Thread {
return "";
}
/**
* Appends one line to the UDP backup file. The backup is a convenience copy:
* the QSO has already been applied and stored when this runs, so a missing,
* empty or unwritable path is logged and the write is skipped.
*
* @param backupFilePath configured backup file path, may be null or blank
* @param text text to append
*/
private void appendToUdpBackupFile(String backupFilePath, String text) {
if (backupFilePath == null || backupFilePath.isBlank()) {
System.out.println("[ReadUDPFromUCX, warning]: no UDP backup file configured, backup write skipped");
return;
}
try (BufferedWriter backupWriter = new BufferedWriter(new FileWriter(new File(backupFilePath), true))) {
backupWriter.write(text);
} catch (IOException exception) {
System.out.println("[ReadUDPFromUCX, warning]: cannot write UDP backup file \""
+ backupFilePath + "\": " + exception.getMessage());
}
}
public boolean terminateConnection() throws IOException {
// String[] threadStatusMessage = new String[2];
// threadStatusMessage = new String[2];
@@ -563,7 +636,10 @@ public class ReadUDPbyUCXMessageThread extends Thread {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "terminated", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
this.socket.close();
DatagramSocket listeningSocket = this.socket;
if (listeningSocket != null && !listeningSocket.isClosed()) {
listeningSocket.close();
}
return true;
@@ -178,7 +178,6 @@ public class WinTestSkedSender {
private void sendUdp(WinTestMessage msg) throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
socket.setBroadcast(true);
socket.setReuseAddress(true);
byte[] bytes = msg.toBytes();
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcastAddress, port);
@@ -0,0 +1,191 @@
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.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Loopback tests for the local DX Cluster server that logging programs
* connect to. The server runs on a socket bound to an ephemeral loopback port,
* and the clients are plain sockets reading what a telnet client would see.
*
* <p>The column layout itself is pinned by {@link DXClusterSpotFormatterTest};
* these tests pin what reaches a connected client and when.</p>
*/
class DXClusterThreadPooledServerLoopbackTest {
/** Prompt sent on connect; the server never reads the answer. */
private static final String LOGIN_PROMPT = "login: ";
/** 75 characters, two BEL and CR LF. */
private static final int SPOT_LENGTH = 79;
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences preferences = mock(ChatPreferences.class);
private final BlockingQueue<ThreadStateMessage> statusMessages = new LinkedBlockingQueue<>();
private final List<Socket> clients = new ArrayList<>();
private int port;
private DXClusterThreadPooledServer server;
private Thread serverThread;
@BeforeEach
void startServer() throws Exception {
when(controller.getChatPreferences()).thenReturn(preferences);
when(preferences.getNotify_DXCSrv_SpottersCallSign()).thenReturn(new SimpleValue<>("dl0kst"));
when(preferences.getNotify_optionalFrequencyPrefix()).thenReturn(new SimpleValue<>("144"));
ServerSocket bound = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
port = bound.getLocalPort();
server = new DXClusterThreadPooledServer(bound, controller,
(threadName, message) -> statusMessages.add(message));
serverThread = new Thread(server, "dxcluster-loopback-test");
serverThread.setDaemon(true);
serverThread.start();
}
@AfterEach
void stopServer() throws Exception {
server.stop();
serverThread.join(2_000);
for (Socket client : clients) {
client.close();
}
}
@Test
void serverReportsTheListeningPort() throws Exception {
ThreadStateMessage message = statusMessages.poll(5, TimeUnit.SECONDS);
assertNotNull(message);
assertEquals("Listening on TCP port " + port, message.getRunningInformation());
assertTrue(message.isRunning());
}
@Test
void connectingClientIsGreetedWithALoginPrompt() throws Exception {
Socket client = connect();
assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length()));
assertTrue(server.hasConnectedClients());
}
/**
* The spot is one DXSpider-style line: spotter in upper case, frequency in
* kHz with one decimal right-aligned to column 24, the DX call from column
* 27, the locator as comment, HHMMZ from column 71, then BEL BEL CR LF.
* A relative frequency is completed with the configured prefix. What the
* client sends after the prompt is ignored.
*/
@Test
void spotReachesTheClientAsOneDxSpiderLine() throws Exception {
Socket client = connect();
assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length()));
client.getOutputStream().write("DL1TEST\r\n".getBytes(StandardCharsets.US_ASCII));
client.getOutputStream().flush();
statusMessages.poll(5, TimeUnit.SECONDS); // "Listening on ..."
assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("ok1abc", "JO70FD", ".205")));
String spot = read(client, SPOT_LENGTH);
assertEquals("DX de DL0KST: 144205.0 OK1ABC JO70FD ",
spot.substring(0, 70));
assertTrue(spot.substring(70, 75).matches("[0-2]\\d[0-5]\\dZ"), spot);
assertEquals("\u0007\u0007\r\n", spot.substring(75));
ThreadStateMessage status = statusMessages.poll(5, TimeUnit.SECONDS);
assertNotNull(status);
assertEquals("Last spot sent to 1 DX Cluster client(s):\n" + spot, status.getRunningInformation());
}
@Test
void spotIsSentToEveryConnectedClient() throws Exception {
Socket first = connect();
Socket second = connect();
assertEquals(LOGIN_PROMPT, read(first, LOGIN_PROMPT.length()));
assertEquals(LOGIN_PROMPT, read(second, LOGIN_PROMPT.length()));
assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("SP2XYZ", "JO93AB", "432.088")));
String firstSpot = read(first, SPOT_LENGTH);
assertEquals(firstSpot, read(second, SPOT_LENGTH));
assertTrue(firstSpot.startsWith("DX de DL0KST: 432088.0 SP2XYZ "), firstSpot);
}
@Test
void spotWithoutConnectedClientsIsNotDelivered() {
assertFalse(server.hasConnectedClients());
assertFalse(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", "144.205")));
}
/** A spot that cannot be formatted is rejected, and the client receives nothing. */
@Test
void unformattableSpotIsRejectedWithoutWritingToTheClient() throws Exception {
Socket client = connect();
assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length()));
assertFalse(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", "")));
assertFalse(server.broadcastSingleDXClusterEntryToLoggers(
station("OK1ABCDEFGHIJ", "JO70FD", "144.205")));
// The next valid spot is the first thing after the prompt.
assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", "144.205")));
assertTrue(read(client, SPOT_LENGTH).startsWith("DX de DL0KST:"));
}
@Test
void stopClosesTheClientConnectionsAndEndsTheServerThread() throws Exception {
Socket client = connect();
assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length()));
server.stop();
serverThread.join(5_000);
assertFalse(serverThread.isAlive(), "server thread must end after stop()");
assertEquals(-1, client.getInputStream().read(), "client must see end of stream");
assertFalse(server.hasConnectedClients());
}
private Socket connect() throws IOException {
Socket client = new Socket(InetAddress.getLoopbackAddress(), port);
client.setSoTimeout(5_000);
clients.add(client);
return client;
}
private static String read(Socket client, int length) throws IOException {
InputStream input = client.getInputStream();
byte[] bytes = input.readNBytes(length);
assertEquals(length, bytes.length, "connection ended early");
return new String(bytes, StandardCharsets.US_ASCII);
}
private static ChatMember station(String callSign, String locator, String frequency) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setQra(locator);
member.setFrequency(new SimpleValue<>(frequency));
return member;
}
}
@@ -0,0 +1,125 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.ApplicationConstants;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.utils.PlayAudioUtils;
/**
* Drives the autoanswer cooldown of a running {@link MessageBusManagementThread}
* with an injected clock instead of waiting two minutes of wall-clock time.
*
* <p>The clock is advanced by a marker frame ({@code UE}) placed between the
* questions: the thread processes frames strictly in order, and the stubbed
* completion callback of the marker moves the clock before the next question is
* taken from the queue.</p>
*/
class MessageBusManagementThreadAutoAnswerCooldownTest {
@TempDir
Path tempDir;
@Test
@Timeout(10)
void questionIsAnsweredAgainOnceTheCooldownHasElapsed() throws InterruptedException {
AtomicLong clock = new AtomicLong(1_700_000_000_000L);
LinkedBlockingQueue<ChatMessage> txBus = new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> rxQueue = new LinkedBlockingQueue<>();
ChatController controller = controller(txBus);
// Each marker frame advances the clock by one step before the next question.
long[] steps = {
MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS - 1, // still closed
1L, // now exactly elapsed
MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS // elapsed again
};
AtomicLong markerIndex = new AtomicLong();
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
org.mockito.Mockito.doAnswer(invocation -> {
clock.addAndGet(steps[(int) markerIndex.getAndIncrement()]);
return null;
}).when(controller).onOn4KstInitialUserListCompleted(
org.mockito.ArgumentMatchers.anyLong(), any());
MessageBusManagementThread thread = new MessageBusManagementThread(
controller, mock(ThreadStatusCallback.class), 9L, rxQueue,
ignored -> true, clock::get);
rxQueue.add(question()); // t0: answered
rxQueue.add(marker()); // +119_999 ms
rxQueue.add(question()); // t0 + 119_999: suppressed
rxQueue.add(marker()); // +1 ms
rxQueue.add(question()); // t0 + 120_000: answered
rxQueue.add(marker()); // +120_000 ms
rxQueue.add(question()); // t0 + 240_000: answered
rxQueue.add(poisonPill());
thread.start();
thread.join();
assertEquals(3, txBus.size(), "first, third and fourth question are answered");
}
private ChatController controller(LinkedBlockingQueue<ChatMessage> txBus) {
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setStn_loginCallSign("DN9APW");
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true);
prefs.setMYQRGFirstCat("144.300");
ChatController controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.getMessageTXBus()).thenReturn(txBus);
when(controller.getChatCategorySecondChat())
.thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
when(controller.getPlayAudioUtils()).thenReturn(mock(PlayAudioUtils.class));
when(controller.getStationMetricsService())
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> {
ChatMember lookup = invocation.getArgument(0);
ChatMember member = new ChatMember();
member.setCallSign(lookup.getCallSign());
member.setChatCategory(lookup.getChatCategory());
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
});
return controller;
}
private static ChatMessage question() {
return message("CH|2|1700000000|DL1ABC|Hans|0|ur qrg?|DN9APW|");
}
private static ChatMessage marker() {
return message("UE|2|22562|");
}
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;
}
}
@@ -0,0 +1,270 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
/**
* Unit tests for the pure autoanswer decisions extracted from the private-message
* branch of {@link MessageBusManagementThread}. No mocks, no thread, no preferences.
*/
class MessageBusManagementThreadAutoAnswerDecisionTest {
private static final String MARKER = ApplicationConstants.AUTOANSWER_PREFIX;
private static final ChatCategory VUHF = new ChatCategory(ChatCategory.VUHF);
private static final ChatCategory MICROWAVE = new ChatCategory(ChatCategory.MICROWAVE);
// --- matchesQrgQuestion -------------------------------------------------
@ParameterizedTest
@ValueSource(strings = {
"ur qrg?",
"UR QRG?",
"hi, your qrg? 73",
"qrg?",
"freq?",
"GM Hans, PSE QRG for a sked"
})
void recognisesEveryConfiguredQuestionCaseInsensitively(String text) {
assertTrue(MessageBusManagementThread.matchesQrgQuestion(
text, MessageBusManagementThread.QRG_QUESTION_TEXTS));
}
@ParameterizedTest
@ValueSource(strings = {
"my qrg is 144.300",
"qrg",
"what is your frequency",
"pse qrg",
""
})
void ignoresTextsWithoutAConfiguredQuestion(String text) {
assertFalse(MessageBusManagementThread.matchesQrgQuestion(
text, MessageBusManagementThread.QRG_QUESTION_TEXTS));
}
@Test
void matchesQrgQuestionHandlesNullInputs() {
assertFalse(MessageBusManagementThread.matchesQrgQuestion(
null, MessageBusManagementThread.QRG_QUESTION_TEXTS));
assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", null));
assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", Set.of()));
}
@Test
void questionTextsAreTheEstablishedFive() {
assertEquals(
Set.of("ur qrg?", "your qrg?", "qrg?", "freq?", "pse qrg"),
MessageBusManagementThread.QRG_QUESTION_TEXTS);
}
// --- isAutoMessage ------------------------------------------------------
@Test
void detectsTheMarkerAnywhereInTheText() {
assertTrue(MessageBusManagementThread.isAutoMessage(MARKER));
assertTrue(MessageBusManagementThread.isAutoMessage("/CQ DL1ABC " + MARKER + " QRG is: 144.300"));
assertTrue(MessageBusManagementThread.isAutoMessage("ur qrg? " + MARKER));
}
@Test
void plainTextsAreNotAutoMessages() {
assertFalse(MessageBusManagementThread.isAutoMessage(null));
assertFalse(MessageBusManagementThread.isAutoMessage(""));
assertFalse(MessageBusManagementThread.isAutoMessage("ur qrg?"));
// The marker is matched literally, not case-insensitively.
assertFalse(MessageBusManagementThread.isAutoMessage(MARKER.toLowerCase()));
}
// --- selectAutoAnswerQrg ------------------------------------------------
@Test
void secondCategoryGetsTheSecondQrg() {
assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg(
MICROWAVE, MICROWAVE, "144.300", "1296.200"));
// Categories are compared by number, not by identity.
assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg(
new ChatCategory(ChatCategory.MICROWAVE), MICROWAVE, "144.300", "1296.200"));
}
@Test
void everyOtherCategoryFallsBackToTheFirstQrg() {
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
VUHF, MICROWAVE, "144.300", "1296.200"));
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
null, MICROWAVE, "144.300", "1296.200"));
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
MICROWAVE, null, "144.300", "1296.200"));
assertNull(MessageBusManagementThread.selectAutoAnswerQrg(
VUHF, MICROWAVE, null, "1296.200"));
}
// --- buildAutoAnswerMessageText ----------------------------------------
@Test
void buildsTheQrgReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, " 144.300 ", false, null));
}
@Test
void qrgRequestWinsOverTheGenericReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, "144.300", true, "not qrv"));
}
@Test
void qrgRequestWithoutQrgYieldsNoReply() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, null, true, "not qrv"));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, " ", true, "not qrv"));
// The category is used for diagnostics only; a null category must not fail.
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", null, true, "", false, null));
}
@Test
void buildsTheGenericReplyTrimmed() {
assertEquals(
"/CQ DL1ABC " + MARKER + " not qrv, back at 18z",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, " not qrv, back at 18z "));
}
@Test
void genericReplyNeedsTextAndTheSwitch() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, null));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, " "));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", false, "not qrv"));
}
@Test
void missingSenderCallSignYieldsNoReply() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
null, VUHF, true, "144.300", true, "not qrv"));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
" ", VUHF, true, "144.300", true, "not qrv"));
}
@Test
void senderCallSignIsTrimmedButNotUpperCased() {
assertEquals(
"/CQ dl1abc " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
" dl1abc ", VUHF, true, "144.300", false, null));
}
@Test
void replyContainingTheFieldSeparatorIsRejected() {
// On4KstProtocol.messageText refuses '|'; the reply is dropped, not repaired.
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, "144.300|432.200", false, null));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, null, true, "not qrv | back later"));
}
@Test
void replyContainingALineBreakIsRejected() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, null, true, "not qrv\nback later"));
}
// --- autoAnswerCooldownKey ---------------------------------------------
@Test
void cooldownKeyIsUpperCaseCallAndMessageCategory() {
ChatMessage incoming = incoming("dl1abc", VUHF, MICROWAVE);
assertEquals("DL1ABC|2", MessageBusManagementThread.autoAnswerCooldownKey(incoming));
}
@Test
void cooldownKeyFallsBackToTheSenderCategory() {
ChatMessage incoming = incoming("DL1ABC", null, MICROWAVE);
assertEquals("DL1ABC|3", MessageBusManagementThread.autoAnswerCooldownKey(incoming));
}
@Test
void cooldownKeyKeepsSuffixVariantsApart() {
// Cooldown identity must not collapse DL1ABC and DL1ABC-2 (AGENTS.md).
assertEquals("DL1ABC-2|2", MessageBusManagementThread.autoAnswerCooldownKey(
incoming("DL1ABC-2", VUHF, null)));
assertEquals("DL1ABC/P|2", MessageBusManagementThread.autoAnswerCooldownKey(
incoming("DL1ABC/P", VUHF, null)));
}
@Test
void cooldownKeyHasSafeDefaults() {
assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(null));
assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(new ChatMessage()));
ChatMessage withoutCall = new ChatMessage();
withoutCall.setSender(new ChatMember());
withoutCall.setChatCategory(VUHF);
assertEquals("UNKNOWN|2", MessageBusManagementThread.autoAnswerCooldownKey(withoutCall));
}
// --- isCooldownElapsed -------------------------------------------------
@Test
void cooldownIsElapsedWithoutAnEarlierReply() {
assertTrue(MessageBusManagementThread.isCooldownElapsed(null, 0L));
}
@Test
void cooldownBoundaryIsInclusive() {
long last = 1_000_000L;
long cooldown = MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS;
assertEquals(120_000L, cooldown);
assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last));
assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown - 1));
assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown));
assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown + 1));
}
/**
* ODDITY (pinned, not fixed): a clock that runs backwards, for example after an
* NTP correction, does not re-open the cooldown; the difference is simply
* negative and therefore below the threshold until the wall clock catches up.
*/
@Test
void clockRunningBackwardsKeepsTheCooldownClosed() {
assertFalse(MessageBusManagementThread.isCooldownElapsed(1_000_000L, 500_000L));
}
// -----------------------------------------------------------------------
private static ChatMessage incoming(
String senderCall, ChatCategory messageCategory, ChatCategory senderCategory) {
ChatMember sender = new ChatMember();
sender.setCallSign(senderCall);
sender.setChatCategory(senderCategory);
ChatMessage incoming = new ChatMessage();
incoming.setSender(sender);
incoming.setChatCategory(messageCategory);
return incoming;
}
}
@@ -0,0 +1,384 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
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.when;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
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.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.utils.PlayAudioUtils;
/**
* Characterises the automatic reply to private ON4KST chat messages by running
* real {@code CH} frames through a started {@link MessageBusManagementThread}.
*
* <p>The thread reads from its receive queue until it takes the poison pill, so
* every test enqueues its frames followed by the pill and joins the thread. The
* transmit bus is a real queue: an automatic reply is observable as exactly one
* element on it.</p>
*
* <p>Frame layout, as split on {@code |} by the thread:
* {@code CH|category|epoch|senderCall|senderName|0|text|receiverCall|}; a
* receiver of {@code 0} marks a public message.</p>
*/
class MessageBusManagementThreadAutoAnswerTest {
private static final String MY_CALL = "DN9APW";
private static final String REMOTE_CALL = "DL1ABC";
private static final String FIRST_QRG = "144.300";
private static final String SECOND_QRG = "1296.200";
@TempDir
Path tempDir;
private ChatController controller;
private ChatPreferences prefs;
private LinkedBlockingQueue<ChatMessage> txBus;
private LinkedBlockingQueue<ChatMessage> rxQueue;
private ChatCategory mainCategory;
private ChatCategory secondCategory;
@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(SECOND_QRG);
mainCategory = new ChatCategory(ChatCategory.VUHF);
secondCategory = new ChatCategory(ChatCategory.MICROWAVE);
txBus = new LinkedBlockingQueue<>();
rxQueue = new LinkedBlockingQueue<>();
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(mock(PlayAudioUtils.class));
when(controller.getStationMetricsService())
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer())
.thenReturn(mock(DXClusterThreadPooledServer.class));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
@Test
@Timeout(10)
void answersQrgQuestionAddressedToMeExactlyOnce() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
List<ChatMessage> replies = new ArrayList<>(txBus);
assertEquals(1, replies.size(), "exactly one automatic reply expected");
ChatMessage reply = replies.get(0);
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
reply.getMessageText());
assertEquals(MY_CALL, reply.getSender().getCallSign());
assertEquals(REMOTE_CALL, reply.getReceiver().getCallSign());
assertSame(mainCategory, reply.getChatCategory());
// The incoming private message itself is still published to the UI.
ArgumentCaptor<ChatMessage> published = ArgumentCaptor.forClass(ChatMessage.class);
verify(controller, times(1)).publishChatMessage(published.capture());
assertEquals("ur qrg?", published.getValue().getMessageText());
assertEquals(REMOTE_CALL, published.getValue().getSender().getCallSign());
}
@Test
@Timeout(10)
void matchesQuestionTextCaseInsensitivelyInsideLongerMessage()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "GM Hans, PSE QRG for a sked", MY_CALL));
assertEquals(1, txBus.size());
}
@Test
@Timeout(10)
void secondQuestionFromSameStationWithinCooldownIsNotAnswered()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL),
chatFrame(2, REMOTE_CALL, "qrg?", MY_CALL));
assertEquals(1, txBus.size(), "cooldown must suppress the second reply");
verify(controller, times(2)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void cooldownIsKeyedByStationAndCategory() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL),
chatFrame(2, "DL2XYZ", "ur qrg?", MY_CALL),
chatFrame(3, REMOTE_CALL, "ur qrg?", MY_CALL));
List<ChatMessage> replies = new ArrayList<>(txBus);
assertEquals(3, replies.size(),
"another station and another category each get their own reply");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + SECOND_QRG,
replies.get(2).getMessageText(),
"the second-category question is answered with the second QRG");
assertSame(secondCategory, replies.get(2).getChatCategory());
}
@Test
@Timeout(10)
void questionCarryingTheAutoAnswerMarkerIsNeverAnswered()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL,
ApplicationConstants.AUTOANSWER_PREFIX + " QRG is: 432.200 ur qrg?",
MY_CALL));
assertTrue(txBus.isEmpty(), "automatic messages must not be answered");
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void publicQuestionIsNotAnswered() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", "0"));
assertTrue(txBus.isEmpty());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void questionToAnotherStationIsNotAnswered() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", "DL2XYZ"));
assertTrue(txBus.isEmpty());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void disabledQrgAutoAnswerStaysSilent() throws InterruptedException {
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(false);
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertTrue(txBus.isEmpty());
}
@Test
@Timeout(10)
void emptyQrgIsNotAnsweredAndDoesNotConsumeTheCooldown()
throws InterruptedException {
prefs.setMYQRGFirstCat("");
MessageBusManagementThread thread = newThread();
CountDownLatch markerProcessed = new CountDownLatch(1);
doAnswer(invocation -> {
markerProcessed.countDown();
return null;
}).when(controller).onOn4KstInitialUserListCompleted(anyLong(), any());
thread.start();
rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
// The thread handles frames strictly in order: once the marker frame has
// been processed, the question above has been decided without a QRG.
rxQueue.add(message("UE|2|22562|"));
assertTrue(markerProcessed.await(5, TimeUnit.SECONDS));
assertTrue(txBus.isEmpty(), "no reply without a QRG");
prefs.setMYQRGFirstCat(FIRST_QRG);
rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
rxQueue.add(poisonPill());
thread.join();
assertEquals(1, txBus.size(),
"the rejected reply must not have started the cooldown");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
txBus.peek().getMessageText());
}
@Test
@Timeout(10)
void genericAutoAnswerRepliesToOtherPrivateMessages() throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat(" not qrv, back at 18z ");
runThrough(
chatFrame(2, REMOTE_CALL, "hi, sked on 144?", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " not qrv, back at 18z",
txBus.peek().getMessageText(),
"the configured text is trimmed");
}
@Test
@Timeout(10)
void qrgQuestionWinsOverGenericAnswer() throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat("not qrv");
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
txBus.peek().getMessageText());
}
@Test
@Timeout(10)
void genericAnswerWithBlankTextIsRejectedWithoutConsumingCooldown()
throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat(" ");
runThrough(
chatFrame(2, REMOTE_CALL, "hi", MY_CALL),
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertEquals(1, txBus.size(),
"the blank generic reply is dropped; the QRG reply still goes out");
assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG));
}
@Test
@Timeout(10)
void versionRequestIsAnsweredOutsideTheCooldown() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "//ver", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF",
txBus.peek().getMessageText());
}
/**
* ODDITY (pinned, not fixed): the receiver callsign of a CH frame is upper-cased
* by {@link ChatMember#setCallSign(String)}, but it is compared with
* {@code equals} against the login callsign exactly as configured. With a
* lower-case login callsign a private message is therefore not recognised as
* addressed to the local station, and no automatic reply is sent. The
* "sent by me" branch directly below compares both sides upper-cased.
*/
@Test
@Timeout(10)
void lowerCaseLoginCallSignDefeatsTheToMeDetection() throws InterruptedException {
prefs.setStn_loginCallSign(MY_CALL.toLowerCase());
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertTrue(txBus.isEmpty());
verify(controller, never()).getPlayAudioUtils();
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void autoMarkerInMyOwnEchoIsNotAnswered() throws InterruptedException {
// The server echoes our own directed message; the sender equals the login.
runThrough(
chatFrame(2, MY_CALL, "ur qrg?", REMOTE_CALL));
assertTrue(txBus.isEmpty());
ArgumentCaptor<ChatMessage> published = ArgumentCaptor.forClass(ChatMessage.class);
verify(controller, times(1)).publishChatMessage(published.capture());
assertEquals("(>" + REMOTE_CALL + ")ur qrg?", published.getValue().getMessageText());
}
// ----------------------------------------------------------------------
private void runThrough(ChatMessage... frames) throws InterruptedException {
MessageBusManagementThread thread = newThread();
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
rxQueue.add(poisonPill());
thread.start();
thread.join();
}
private MessageBusManagementThread newThread() {
return new MessageBusManagementThread(
controller,
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
}
private static ChatMessage chatFrame(
int category, String senderCall, String text, String receiverCall) {
return message("CH|" + category + "|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: every remote station is known with a locator. */
private static ChatMember activeMemberFor(ChatMember lookup) {
ChatMember member = new ChatMember();
member.setCallSign(lookup.getCallSign());
member.setChatCategory(lookup.getChatCategory());
member.setQra("JO31AA");
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
}
}
@@ -0,0 +1,223 @@
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.assertTrue;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import kst4contest.controller.MessageBusManagementThread.DetectedChatFrequency;
import kst4contest.model.Band;
/**
* Unit tests for the pure frequency detection extracted from
* {@link MessageBusManagementThread#smartFrequencyExtraction}. No mocks, no thread,
* no preferences; the clock is an explicit parameter.
*/
class MessageBusManagementThreadDetectFrequenciesTest {
private static final long NOW = 1_000_000_000_000L;
private static final long THIRTY_MINUTES = 1_800_000L;
private static final double EPSILON = 1e-9;
private static List<DetectedChatFrequency> detect(String text) {
return MessageBusManagementThread.detectFrequencies(text, Map.of(), null, NOW);
}
private static List<DetectedChatFrequency> detect(
String text, Map<Band, Long> context, String fallbackPrefix) {
return MessageBusManagementThread.detectFrequencies(text, context, fallbackPrefix, NOW);
}
private static void assertSingle(
List<DetectedChatFrequency> result, Band band, double frequencyMHz) {
assertEquals(1, result.size(), () -> "detections: " + result);
assertEquals(band, result.get(0).band());
assertEquals(frequencyMHz, result.get(0).frequencyMHz(), EPSILON);
}
// --- complete frequencies -----------------------------------------------
@Test
void detectsDottedFullFrequency() {
List<DetectedChatFrequency> result = detect("pse qsy 144.300");
assertSingle(result, Band.B_144, 144.300);
assertFalse(result.get(0).derivedFromShortForm());
assertEquals("144.300", result.get(0).rawMatch());
}
@Test
void detectsCommaAsDecimalSeparator() {
List<DetectedChatFrequency> result = detect("qrg 432,210");
assertSingle(result, Band.B_432, 432.210);
assertEquals("432.210", result.get(0).rawMatch());
}
@Test
void detectsCompactDigitOnlyFrequency() {
assertSingle(detect("pse try 10368100"), Band.B_10G, 10368.100);
}
@Test
void detectsKstStyleSecondDecimalSeparator() {
assertSingle(detect("cq on 144.300.03"), Band.B_144, 144.30003);
}
@Test
void fullFrequencyIgnoresBandContextAndFallback() {
Map<Band, Long> context = Map.of(Band.B_432, NOW);
assertSingle(detect("144.300", context, "1296"), Band.B_144, 144.300);
}
@Test
void ignoresFullFrequencyOutsideEverySupportedBand() {
assertTrue(detect("try 146.500").isEmpty());
assertTrue(detect("cq 28.500").isEmpty());
assertSingle(detect("146.500 or 433.900"), Band.B_432, 433.900);
}
@Test
void reportsAllFrequenciesInMessageOrder() {
List<DetectedChatFrequency> result = detect("144.300 or 432.200");
assertEquals(2, result.size());
assertEquals(Band.B_144, result.get(0).band());
assertEquals(Band.B_432, result.get(1).band());
}
/*
* Pinned oddity: any decimal number between 50.0 and 54.0 (or 70.0 and 70.5)
* is read as a frequency, so a distance such as "52,3 km" becomes 52.3 MHz.
*/
@Test
void pinsThatAPlainDecimalInsideTheSixMetreRangeCountsAsFrequency() {
assertSingle(detect("qrb 52,3 km"), Band.B_50, 52.3);
}
// --- relative and bare values -------------------------------------------
@Test
void relativeValueWithoutContextUsesTheConfiguredFallback() {
List<DetectedChatFrequency> result = detect("pse .210", Map.of(), "432");
assertSingle(result, Band.B_432, 432.210);
assertTrue(result.get(0).derivedFromShortForm());
assertEquals("210", result.get(0).rawMatch());
}
@ParameterizedTest
@ValueSource(strings = {"", "28", "abc"})
void unknownFallbackPrefixFallsBackTo144(String prefix) {
assertSingle(detect("pse .210", Map.of(), prefix), Band.B_144, 144.210);
}
@Test
void missingFallbackPrefixFallsBackTo144() {
assertSingle(detect("pse .210", null, null), Band.B_144, 144.210);
}
@Test
void relativeValueWithCommaIsAccepted() {
assertSingle(detect("pse ,210"), Band.B_144, 144.210);
}
@Test
void bareValueNeedsFrequencyContext() {
assertTrue(detect("ur 599 tnx").isEmpty());
assertTrue(detect("144 tonight").isEmpty());
assertSingle(detect("qrg 210"), Band.B_144, 144.210);
assertSingle(detect("210 MHz"), Band.B_144, 144.210);
}
@Test
void relativeValueUsesFreshSenderBandBeforeFallback() {
Map<Band, Long> context = Map.of(Band.B_1296, NOW - 60_000L);
assertSingle(detect("pse .210", context, "432"), Band.B_1296, 1296.210);
}
@Test
void newestFreshBandWins() {
Map<Band, Long> context = new HashMap<>();
context.put(Band.B_144, NOW - 120_000L);
context.put(Band.B_432, NOW - 60_000L);
context.put(Band.B_1296, NOW - 180_000L);
assertSingle(detect("pse .210", context, null), Band.B_432, 432.210);
}
@Test
void contextExactlyThirtyMinutesOldIsStillUsed() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - THIRTY_MINUTES);
assertSingle(detect("pse .210", context, "144"), Band.B_432, 432.210);
}
@Test
void contextOlderThanThirtyMinutesIsIgnored() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - THIRTY_MINUTES - 1L);
assertSingle(detect("pse .210", context, "144"), Band.B_144, 144.210);
}
@Test
void contextBandIsSkippedWhenTheCompletedValueLeavesTheBand() {
// 70.900 is outside 70.000-70.500, so the older 432 context is used.
Map<Band, Long> context = new EnumMap<>(Band.class);
context.put(Band.B_70, NOW - 1_000L);
context.put(Band.B_432, NOW - 5_000L);
assertSingle(detect("pse .900", context, "144"), Band.B_432, 432.900);
}
@Test
void relativeValueOutsideTheFallbackBandIsDropped() {
// 70.900 lies outside the 70 MHz band and nothing else is tried.
assertTrue(detect("pse .900", Map.of(), "70").isEmpty());
}
@Test
void earlierDetectionInTheSameMessageBecomesContextForALaterRelativeValue() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - 60_000L);
List<DetectedChatFrequency> result =
detect("10368.100 then .150", context, "144");
assertEquals(2, result.size());
assertEquals(Band.B_10G, result.get(1).band());
assertEquals(10368.150, result.get(1).frequencyMHz(), EPSILON);
}
@Test
void doesNotModifyTheGivenContext() {
Map<Band, Long> context = new EnumMap<>(Band.class);
context.put(Band.B_432, NOW - 60_000L);
detect("10368.100 then .150", context, "144");
assertEquals(Map.of(Band.B_432, NOW - 60_000L), context);
}
// --- edge cases ---------------------------------------------------------
@Test
void nullOrTextWithoutNumbersYieldsNothing() {
assertTrue(MessageBusManagementThread.detectFrequencies(null, null, null, NOW).isEmpty());
assertTrue(detect("gm all, tnx for qso").isEmpty());
}
@Test
void callsignDigitsAndLocatorsAreNotFrequencies() {
assertTrue(detect("DL144ABC in JO62QM").isEmpty());
}
}
@@ -0,0 +1,162 @@
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity;
import kst4contest.controller.MessageBusManagementThread.DxClusterSpotDecision;
import kst4contest.model.ChatMember;
import kst4contest.observe.SimpleValue;
/**
* Unit tests for the directional-opportunity and DX Cluster spot decisions
* extracted from the message branch for traffic between two other stations.
* No mocks, no thread, no preferences.
*/
class MessageBusManagementThreadDirectionalSpotDecisionTest {
// Local station between a western sender and an eastern receiver, all on one latitude.
private static final String OWN = "JO50LL";
private static final String WEST = "JO30LL";
private static final String EAST = "JO70LL";
private static final String FAR_WEST = "IO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
// --- classifyDirectionalOpportunity -------------------------------------
@Test
void senderBeamingAcrossTheLocalStationIsAnOpportunity() {
assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, EAST, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
@Test
void senderBeamingAwayFromTheLocalStationIsNoOpportunity() {
assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, FAR_WEST, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
@Test
void senderBeyondTheMaximumDistanceIsNoOpportunity() {
assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, EAST, 50.0, BEAM_WIDTH_DEG));
}
@ParameterizedTest
@CsvSource(value = {
"NULL, JO70LL",
"'', JO70LL",
"' ', JO70LL",
"JO30LL, NULL",
"JO30LL, ''",
"NULL, NULL"
}, nullValues = "NULL")
void missingOrBlankLocatorSkipsTheGeometry(String senderLocator, String receiverLocator) {
// The own locator is invalid on purpose: it must not be evaluated at all.
assertEquals(DirectionalOpportunity.MISSING_LOCATOR,
MessageBusManagementThread.classifyDirectionalOpportunity(
null, senderLocator, receiverLocator, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
// --- shouldPlayDirectionalTick ------------------------------------------
@Test
void tickOnlyOnTheTransitionIntoAnOpportunityAndOnlyWithSounds() {
assertTrue(MessageBusManagementThread.shouldPlayDirectionalTick(true, false));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(true, true));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, false));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, true));
}
// --- decideDxClusterSpot ------------------------------------------------
@Test
void knownFrequencyAndNotWorkedIsSpotted() {
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(senderOn("144.300")));
}
@Test
void workedOnTheBandOfTheFrequencyIsSuppressed() {
ChatMember sender = senderOn("144.300");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.ALREADY_WORKED,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
@Test
void workedOnlyOnAnotherBandIsSpotted() {
ChatMember sender = senderOn("432.200");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
@Test
void missingFrequencyIsNeitherSpottedNorReportedAsWorked() {
ChatMember noWrapper = new ChatMember();
noWrapper.setCallSign("DL1ABC");
noWrapper.setFrequency(null);
noWrapper.setWorked(true);
ChatMember noValue = new ChatMember();
noValue.setCallSign("DL1ABC");
noValue.setFrequency(new SimpleValue<>());
noValue.setWorked(true);
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(noWrapper));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(noValue));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(senderOn(" ")));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(null));
}
/*
* Pinned oddity: the frequency text is only checked for being non-blank. A value
* that names no supported band is spotted, even for a station flagged as worked.
*/
@Test
void pinsThatAnUnparseableFrequencyIsStillSpotted() {
ChatMember sender = senderOn("999.9");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
// --- buildDxClusterSpotEntry --------------------------------------------
@Test
void spotEntryCarriesCallsignFrequencyAndCommentInTheLocatorField() {
ChatMember sender = senderOn("144.300");
sender.setQra("jo51hk");
ChatMember entry = MessageBusManagementThread.buildDxClusterSpotEntry(sender);
assertEquals("DL1ABC", entry.getCallSign());
assertSame(sender.getFrequency(), entry.getFrequency());
assertEquals("JO51HK", entry.getQra());
}
private static ChatMember senderOn(String frequencyMHz) {
ChatMember sender = new ChatMember();
sender.setCallSign("DL1ABC");
sender.setFrequency(new SimpleValue<>(frequencyMHz));
return sender;
}
}
@@ -0,0 +1,104 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Covers the context rule that decides whether a bare three-digit value such as
* {@code 210} is read as a frequency. Restored from the test file that sat under
* {@code src/main} before #62 and was therefore never executed.
*/
class MessageBusManagementThreadFrequencyContextTest {
private static final Pattern THREE_DIGIT_VALUE =
Pattern.compile("\\b\\d{3}\\b");
@ParameterizedTest
@ValueSource(strings = {
"qrg 210",
"QRG: 210",
"freq is 210",
"frequency = 210",
"on 210",
"pse 210",
"at 210",
"210 MHz",
"210 qrg",
"210 freq"
})
void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
Matcher matcher = findThreeDigitValue(messageText);
assertTrue(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@ParameterizedTest
@ValueSource(strings = {
"599",
"144",
"serial 210",
"score 210",
"worked 210 stations",
"210 stations worked"
})
void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
Matcher matcher = findThreeDigitValue(messageText);
assertFalse(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@Test
void contextWindowIsLimitedToNearbyText() {
// "qrg" is present but more than 32 characters before the value.
String messageText =
"qrg will follow later in this long sentence about nothing 210";
Matcher matcher = findThreeDigitValue(messageText);
assertFalse(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@Test
void rejectsInvalidIndices() {
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext(null, 0, 3));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", -1, 3));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", 5, 4));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", 4, 8));
}
private Matcher findThreeDigitValue(String messageText) {
Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
assertTrue(matcher.find(), "Test message must contain a three-digit value");
assertNotNull(matcher.group());
return matcher;
}
}
@@ -22,15 +22,20 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.testing.ManualScheduler;
import java.nio.file.Path;
class On4KstConnectionProbeTest {
@TempDir
Path tempDir;
@Test
void separatesClientProbeServerProbeAndInternalResponses() {
assertEquals("CK|", On4KstProtocol.clientLivenessProbe());
@@ -357,7 +362,7 @@ class On4KstConnectionProbeTest {
}
private ChatPreferences localPreferences() {
ChatPreferences preferences = new ChatPreferences();
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setStn_on4kstServersDns("127.0.0.1");
preferences.setStn_loginCallSign("DL1ABC");
preferences.setStn_loginPassword("test-password");
@@ -5,13 +5,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import kst4contest.model.ChatPreferences;
import java.nio.file.Path;
class On4KstProtocolTest {
@TempDir
Path tempDir;
@Test
void buildsLoginWithReplayOverlap() {
assertEquals(
@@ -72,7 +78,7 @@ class On4KstProtocolTest {
@Test
void resolvesBeaconVariablesBeforeApplyingProtocolValidation() {
ChatPreferences preferences = new ChatPreferences();
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setMYQRGFirstCat("144.300");
ChatController controller = new ChatController();
@@ -92,7 +98,7 @@ class On4KstProtocolTest {
@Test
void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() {
ChatPreferences preferences = new ChatPreferences();
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setMYQRGFirstCat("");
ChatController controller = new ChatController();
@@ -110,7 +116,7 @@ class On4KstProtocolTest {
@Test
void rejectsEmptyOverlongAndProtocolBreakingBeaconText() {
ChatPreferences preferences = new ChatPreferences();
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
@@ -0,0 +1,58 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Locale;
import org.junit.jupiter.api.Test;
/** The azimuth sent to PSTRotator: one decimal, dot separator, within [0, 360). */
class PstRotatorClientAzimuthFormatTest {
@Test
void keepsOneDecimalInsteadOfTruncating() {
assertEquals("145.7", PstRotatorClient.formatAzimuth(145.7));
assertEquals("145.0", PstRotatorClient.formatAzimuth(145.0));
assertEquals("0.0", PstRotatorClient.formatAzimuth(0.0));
}
@Test
void roundsToTheNearestTenth() {
assertEquals("145.7", PstRotatorClient.formatAzimuth(145.66));
assertEquals("145.7", PstRotatorClient.formatAzimuth(145.70000000000002));
assertEquals("12.3", PstRotatorClient.formatAzimuth(12.34));
}
@Test
void aValueRoundingUpTo360IsSentAsZero() {
assertEquals("0.0", PstRotatorClient.formatAzimuth(359.96));
assertEquals("359.9", PstRotatorClient.formatAzimuth(359.94));
}
@Test
void wrapsAnyFiniteValueIntoOneTurn() {
assertEquals("0.0", PstRotatorClient.formatAzimuth(720.0));
assertEquals("10.0", PstRotatorClient.formatAzimuth(370.0));
assertEquals("350.0", PstRotatorClient.formatAzimuth(-10.0));
assertEquals("0.0", PstRotatorClient.formatAzimuth(-0.0));
}
@Test
void refusesAValueWithoutAHeading() {
assertThrows(IllegalArgumentException.class, () -> PstRotatorClient.formatAzimuth(Double.NaN));
assertThrows(IllegalArgumentException.class,
() -> PstRotatorClient.formatAzimuth(Double.POSITIVE_INFINITY));
}
@Test
void usesADotEvenUnderAGermanDefaultLocale() {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(Locale.GERMANY);
assertEquals("145.7", PstRotatorClient.formatAzimuth(145.7));
} finally {
Locale.setDefault(previous);
}
}
}
@@ -0,0 +1,207 @@
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.assertTrue;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.model.ThreadStateMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Loopback tests for the PSTRotator UDP client. A socket on an ephemeral
* loopback port plays PSTRotator; the client is built with the explicit-port
* constructor and local port 0, so it never binds the real PSTRotator ports.
*
* <p>The reply format used here ({@code AZ:145.0}, {@code EL:010.0},
* {@code MODE:1}, each terminated by a carriage return) is derived from the
* client's parser and from the PSTRotator UDP documentation, which describes
* the answer as {@code AZ:xxx.x<CR>} on the command port plus one. It is not
* taken from a capture of a running PSTRotator; none was available.</p>
*/
class PstRotatorClientLoopbackTest {
private static final String POLL_AZIMUTH = "<PST>AZ?</PST>";
private static final String POLL_MODE = "<PST>MODE?</PST>";
private final List<ThreadStateMessage> statusMessages = new CopyOnWriteArrayList<>();
private final RecordingListener listener = new RecordingListener();
private DatagramSocket pstRotator;
private PstRotatorClient client;
@BeforeEach
void startClientAgainstFakePstRotator() throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
pstRotator = new DatagramSocket(new InetSocketAddress(loopback, 0));
pstRotator.setSoTimeout(5_000);
client = new PstRotatorClient(loopback.getHostAddress(), pstRotator.getLocalPort(), 0, listener,
(threadName, message) -> statusMessages.add(message));
client.start();
}
/**
* The client does not expose its listener thread, so it is found by the
* name start() gives it, which contains the remote port of this test's
* fake PSTRotator.
*/
@AfterEach
void stopClient() throws Exception {
String listenerName = "PSTRotator-Listener-" + pstRotator.getLocalPort();
List<Thread> listeners = Thread.getAllStackTraces().keySet().stream()
.filter(thread -> thread.getName().equals(listenerName))
.toList();
try {
client.stop();
assertEquals(1, listeners.size(), "exactly one listener thread expected");
for (Thread listenerThread : listeners) {
listenerThread.join(2_000);
assertFalse(listenerThread.isAlive(), "listener thread must end after stop()");
}
} finally {
pstRotator.close();
}
}
/**
* The explicit-port constructor once left the status callback null, so
* start() threw before it could report anything (#115).
*/
@Test
void explicitPortConstructorReportsInitializedAfterStart() {
assertFalse(statusMessages.isEmpty(), "start() must report its status");
ThreadStateMessage first = statusMessages.get(0);
assertEquals("PSTRotator", first.getThreadNickName());
assertEquals("initialized", first.getRunningInformation());
assertTrue(first.isRunning());
assertFalse(first.isCriticalState());
}
/** The poller asks for azimuth, then mode, as two separate datagrams. */
@Test
void pollerQueriesAzimuthThenMode() throws Exception {
assertEquals(POLL_AZIMUTH, receive().text);
assertEquals(POLL_MODE, receive().text);
}
@Test
void controlCommandsAreSentAsPstXmlFrames() throws Exception {
client.setAzimuth(145.7);
client.setElevation(10.5);
client.stopRotor();
client.park();
client.setTrackingMode(true);
client.setTrackingMode(false);
// The azimuth keeps one decimal; it used to be truncated to whole degrees.
assertEquals("<PST><AZIMUTH>145.7</AZIMUTH></PST>", receiveCommand());
assertEquals("<PST><ELEVATION>10.5</ELEVATION></PST>", receiveCommand());
assertEquals("<PST><STOP>1</STOP></PST>", receiveCommand());
assertEquals("<PST><PARK>1</PARK></PST>", receiveCommand());
assertEquals("<PST><TRACK>1</TRACK></PST>", receiveCommand());
assertEquals("<PST><TRACK>0</TRACK></PST>", receiveCommand());
}
@Test
void repliesToTheClientSocketReachTheListener() throws Exception {
SocketAddress clientAddress = receive().source;
reply(clientAddress, "AZ:145.0\r");
reply(clientAddress, "EL:010.0\r");
reply(clientAddress, "MODE:1\r");
reply(clientAddress, "MODE:0\r");
assertEquals(145.0, poll(listener.azimuths));
assertEquals(10.0, poll(listener.elevations));
assertEquals(Boolean.TRUE, poll(listener.modes));
assertEquals(Boolean.FALSE, poll(listener.modes));
// The raw message is handed over trimmed, i.e. without the CR.
assertEquals("AZ:145.0", poll(listener.rawMessages));
}
/** An unparsable value is dropped; the receive loop keeps running. */
@Test
void unparsableReplyDoesNotStopTheListener() throws Exception {
SocketAddress clientAddress = receive().source;
reply(clientAddress, "AZ:not-a-number\r");
reply(clientAddress, "AZ:90.0\r");
assertEquals(90.0, poll(listener.azimuths));
assertTrue(listener.azimuths.isEmpty());
}
private Received receive() throws Exception {
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
pstRotator.receive(packet);
return new Received(new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII),
packet.getSocketAddress());
}
/** Receives the next datagram that is not one of the poller's queries. */
private String receiveCommand() throws Exception {
while (true) {
String text = receive().text;
if (!text.equals(POLL_AZIMUTH) && !text.equals(POLL_MODE)) {
return text;
}
}
}
private void reply(SocketAddress target, String text) throws Exception {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
pstRotator.send(new DatagramPacket(bytes, bytes.length, target));
}
private static <T> T poll(BlockingQueue<T> queue) throws InterruptedException {
T value = queue.poll(5, TimeUnit.SECONDS);
assertNotNull(value, "expected a listener callback");
return value;
}
private record Received(String text, SocketAddress source) {
}
private static final class RecordingListener implements PstRotatorEventListener {
final BlockingQueue<Double> azimuths = new LinkedBlockingQueue<>();
final BlockingQueue<Double> elevations = new LinkedBlockingQueue<>();
final BlockingQueue<Boolean> modes = new LinkedBlockingQueue<>();
final BlockingQueue<String> rawMessages = new LinkedBlockingQueue<>();
@Override
public void onAzimuthUpdate(double azimuth) {
azimuths.add(azimuth);
}
@Override
public void onElevationUpdate(double elevation) {
elevations.add(elevation);
}
@Override
public void onModeUpdate(boolean isTracking) {
modes.add(isTracking);
}
@Override
public void onMessageReceived(String rawMessage) {
rawMessages.add(rawMessage);
}
}
}
@@ -0,0 +1,219 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
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.when;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.ApplicationConstants;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Loopback tests for the AirScout UDP listener. The listener runs on a socket
* bound to an ephemeral loopback port, and every datagram goes to the loopback
* address, so the tests neither collide with a running AirScout nor broadcast.
*
* <p>The responses follow the {@code ASNEAREST} format documented in
* {@link ReadUDPbyAirScoutMessageThread#processASUDPMessage(String)}.</p>
*/
class ReadUDPbyAirScoutMessageThreadLoopbackTest {
/** Configured client name with a non-ASCII character: the listener decodes UTF-8 (#115). */
private static final String CLIENT = "KST-Ä";
private static final String SERVER = "AS";
private static final String PLANES = "2023-04-01 21:33:42Z,DO5AMF,JN49GL,G4CLA,IO92JL,2,"
+ "BAW809,M,250,50,18,KLM1678,L,313,75,22";
/** Same response for receiver PA0XYZ, so a wrongly processed copy is detectable. */
private static final String OTHER_RECEIVER_PLANES = "2023-04-01 21:33:42Z,DO5AMF,JN49GL,PA0XYZ,JO22AB,1,"
+ "BAW809,M,250,50,18";
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences preferences = mock(ChatPreferences.class);
private final ScoreService scoreService = mock(ScoreService.class);
private final BlockingQueue<ThreadStateMessage> statusMessages = new LinkedBlockingQueue<>();
private DatagramSocket sender;
private InetSocketAddress target;
private ReadUDPbyAirScoutMessageThread listener;
@BeforeEach
void startListener() throws Exception {
when(controller.getChatPreferences()).thenReturn(preferences);
when(controller.getScoreService()).thenReturn(scoreService);
when(preferences.isAirScout_asUDPListenerEnabled()).thenReturn(true);
when(preferences.getAirScout_asServerNameString()).thenReturn(SERVER);
when(preferences.getAirScout_asClientNameString()).thenReturn(CLIENT);
InetAddress loopback = InetAddress.getLoopbackAddress();
DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0));
target = new InetSocketAddress(loopback, bound.getLocalPort());
sender = new DatagramSocket(new InetSocketAddress(loopback, 0));
listener = new ReadUDPbyAirScoutMessageThread(bound, controller,
(threadName, message) -> statusMessages.add(message));
listener.setDaemon(true);
}
@AfterEach
void stopListener() throws Exception {
listener.interrupt();
listener.join(2_000);
sender.close();
}
@Test
void matchingResponseIsAppliedToEveryActiveVariantOfTheReceiver() throws Exception {
ChatMember plain = new ChatMember();
ChatMember portable = new ChatMember();
when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(plain, portable));
listener.start();
send(response(SERVER, CLIENT, PLANES));
awaitResponseProcessed();
AirPlaneReflectionInfo info = plain.getAirPlaneReflectInfo();
assertSame(info, portable.getAirPlaneReflectInfo());
assertEquals("DO5AMF", info.getSender().getCallSign());
assertEquals("JN49GL", info.getSender().getQra());
assertEquals("G4CLA", info.getReceiver().getCallSign());
assertEquals("IO92JL", info.getReceiver().getQra());
assertEquals(2, info.getAirPlanesReachableCntr());
// Sorted by potential, highest first.
List<AirPlane> planes = info.getRisingAirplanes();
assertEquals(2, planes.size());
assertPlane(planes.get(0), "KLM1678", "L", 313, 75, 22);
assertPlane(planes.get(1), "BAW809", "M", 250, 50, 18);
verify(scoreService).requestRecompute("airscout-update");
}
/**
* KNOWN DEFECT, pinned on purpose: the parser strips every space from the
* datagram, including the one between date and time. The date is only
* shown in toString(), so it was left as it is. Whoever fixes the parser
* flips this expectation to {@code 2023-04-01 21:33:42Z}.
*/
@Test
void pinsKnownDefectDateLosesTheSpaceBetweenDayAndTime() throws Exception {
ChatMember member = new ChatMember();
when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(member));
listener.start();
send(response(SERVER, CLIENT, PLANES));
awaitResponseProcessed();
assertEquals("2023-04-0121:33:42Z", member.getAirPlaneReflectInfo().getDate());
}
/**
* The ignored responses name another receiver than the sentinel that
* follows them. Once the sentinel has been processed, the earlier
* datagrams have been read, assuming in-order delivery from one socket to
* one socket on loopback, which UDP does not promise but loopback provides
* in practice.
*/
@Test
void responsesForAnotherClientOrServerAreIgnored() throws Exception {
when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(new ChatMember()));
listener.start();
send(response(SERVER, "KST-A", OTHER_RECEIVER_PLANES));
send(response("AS2", CLIENT, OTHER_RECEIVER_PLANES));
send(response(SERVER, "kst-ä", OTHER_RECEIVER_PLANES));
send(response(SERVER, CLIENT, PLANES));
awaitResponseProcessed();
send(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
listener.join(5_000);
assertFalse(listener.isAlive(), "the poison pill must end the listener");
verify(controller, never()).findActiveChatMembersByRawCall("PA0XYZ");
verify(controller).findActiveChatMembersByRawCall("G4CLA");
assertTrue(statusMessages.isEmpty(), "only the sentinel may be reported");
}
/**
* The first response arrives while the listener is disabled; the switch is
* then turned on, so the sentinel is processed. Asking the switch once per
* response shows that the first one was received and then discarded.
*/
@Test
void disabledListenerDiscardsResponsesButStillHonoursThePoisonPill() throws Exception {
when(preferences.isAirScout_asUDPListenerEnabled()).thenReturn(false, true);
when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(new ChatMember()));
listener.start();
send(response(SERVER, CLIENT, OTHER_RECEIVER_PLANES));
send(response(SERVER, CLIENT, PLANES));
awaitResponseProcessed();
send(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
listener.join(5_000);
assertFalse(listener.isAlive(), "the poison pill must end the listener");
verify(preferences, times(2)).isAirScout_asUDPListenerEnabled();
verify(controller, never()).findActiveChatMembersByRawCall("PA0XYZ");
verify(controller).findActiveChatMembersByRawCall("G4CLA");
}
/** A malformed response is dropped; the next valid one is still processed. */
@Test
void malformedResponseDoesNotStopTheListener() throws Exception {
ChatMember member = new ChatMember();
when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(member));
listener.start();
send(response(SERVER, CLIENT, "2023-04-01 21:33:42Z,DO5AMF"));
send(response(SERVER, CLIENT, PLANES));
awaitResponseProcessed();
assertEquals("G4CLA", member.getAirPlaneReflectInfo().getReceiver().getCallSign());
assertNull(statusMessages.poll());
}
private static String response(String server, String client, String body) {
return "ASNEAREST: \"" + server + "\" \"" + client + "\" \"" + body + "\"";
}
private void send(String payload) throws Exception {
byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
sender.send(new DatagramPacket(bytes, bytes.length, target));
}
private void awaitResponseProcessed() throws InterruptedException {
ThreadStateMessage message = statusMessages.poll(5, TimeUnit.SECONDS);
assertTrue(message != null && message.getRunningInformation().startsWith("Received AirScout response"),
"listener must report the processed response");
}
private static void assertPlane(AirPlane plane, String callSign, String size, int distanceKm,
int potential, int minutes) {
assertEquals(callSign, plane.getApCallSign());
assertEquals(size, plane.getApSizeCategory());
assertEquals(distanceKm, plane.getDistanceKm());
assertEquals(potential, plane.getPotential());
assertEquals(minutes, plane.getArrivingDurationMinutes());
}
}
@@ -0,0 +1,344 @@
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
/**
* Loopback tests for the UCXLog/DXLog UDP listener. Every socket is bound to
* port 0 and every datagram goes to the loopback address, so the tests neither
* collide with a running logger nor send anything to a broadcast address.
*/
class ReadUDPbyUCXMessageThreadLoopbackTest {
/** RadioInfo that sets the first-category QRG to 1296.200.00; used as an ordering marker. */
private static final String MARKER_PACKET = "<RadioInfo><Freq>129620000</Freq><Mode>CW</Mode></RadioInfo>";
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences preferences = mock(ChatPreferences.class);
private final BlockingQueue<ThreadStateMessage> statusMessages = new LinkedBlockingQueue<>();
/**
* Makes {@link #statusCallback} throw after recording the safety net's
* dropped-packet report, to simulate a failing controller there. Other
* reports pass, so the ordering marker can still be processed.
*/
private volatile boolean statusCallbackThrows;
private final ThreadStatusCallback statusCallback = (threadName, message) -> {
statusMessages.add(message);
if (statusCallbackThrows
&& message.getRunningInformation().startsWith("packet dropped after error")) {
throw new IllegalStateException("callback boom");
}
};
/** First-category QRG that the RadioInfo ordering marker sets. */
private final SimpleValue<String> markerQrg = new SimpleValue<>("");
/**
* A processed RadioInfo packet shows that the receive loop is running,
* instead of a sleep. The receive timeout is 2 s, so ending within 1 s
* shows that interrupt() does not wait for the next timeout. Whether the
* thread sat inside receive() or between two receives at that moment is
* not observable; the closed socket and the interrupt flag end the loop
* in either case.
*/
@Test
void interruptEndsTheReceiveLoopBeforeTheReceiveTimeout() throws Exception {
givenRadioInfoMarkerEnabled();
CountDownLatch marker = markerLatch();
InetAddress loopback = InetAddress.getLoopbackAddress();
DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0));
ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(bound, controller, statusCallback);
listener.setDaemon(true);
listener.start();
try (DatagramSocket sender = new DatagramSocket(new InetSocketAddress(loopback, 0))) {
send(sender, new InetSocketAddress(loopback, bound.getLocalPort()), MARKER_PACKET);
assertTrue(marker.await(5, TimeUnit.SECONDS), "marker packet must be processed");
}
listener.interrupt();
listener.join(1_000);
assertFalse(listener.isAlive(), "listener must stop once interrupted");
}
/**
* A DatagramPacket remembers the length of the last datagram it received.
* Without resetting it before every receive(), a long RadioInfo packet that
* follows a short one is cut to the short one's length and fails to parse.
* The QRG probe needs no database: RadioInfo only sets the first-category
* frequency through the UI dispatcher.
*/
@Test
void longDatagramAfterShortOneIsProcessedCompletely() throws Exception {
when(controller.getChatPreferences()).thenReturn(preferences);
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true);
SimpleValue<String> qrg = new SimpleValue<>("");
when(preferences.getMYQRGFirstCat()).thenReturn(qrg);
CountDownLatch firstQrg = new CountDownLatch(1);
CountDownLatch secondQrg = new CountDownLatch(1);
qrg.addListener(value -> {
if (value.equals("144.200.00")) firstQrg.countDown();
if (value.equals("432.200.00")) secondQrg.countDown();
});
String shortPacket = "<RadioInfo><Freq>14420000</Freq><Mode>CW</Mode></RadioInfo>";
String longPacket = "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<RadioInfo><StationName>CONTEST-PC-WITH-A-LONG-NAME</StationName>"
+ "<RadioNr>1</RadioNr><Freq>43220000</Freq><TXFreq>43220000</TXFreq>"
+ "<Mode>SSB</Mode><OpCall>DL0TEST</OpCall><IsRunning>False</IsRunning>"
+ "<FocusEntry>0</FocusEntry><Antenna>1</Antenna><Rotors>1</Rotors>"
+ "<FocusRadioNr>1</FocusRadioNr><IsStereo>False</IsStereo>"
+ "<ActiveRadioNr>1</ActiveRadioNr></RadioInfo>";
assertTrue(longPacket.length() > shortPacket.length());
InetAddress loopback = InetAddress.getLoopbackAddress();
DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0));
ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(bound, controller, statusCallback);
listener.setDaemon(true);
listener.start();
try (DatagramSocket sender = new DatagramSocket(new InetSocketAddress(loopback, 0))) {
InetSocketAddress target = new InetSocketAddress(loopback, bound.getLocalPort());
send(sender, target, shortPacket);
assertTrue(firstQrg.await(5, TimeUnit.SECONDS), "short packet must be processed");
assertEquals("144.200.00", qrg.get());
send(sender, target, longPacket);
assertTrue(secondQrg.await(5, TimeUnit.SECONDS), "long packet must be processed");
assertEquals("432.200.00", qrg.get());
} finally {
listener.interrupt();
listener.join(2_000);
}
}
/**
* A UCXLog QSO packet marks the station worked, registers the gross field
* and stores an unknown station in the database and the UDP backup file.
* Bytes before the XML declaration are transport framing and are dropped.
*/
@Test
void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception {
Path backupFile = tempDir.resolve("udp-backup.txt");
DBController database = givenQsoProcessingEnabled(backupFile);
when(database.updateWkdInfoOnChatMember(any())).thenReturn(false);
sendThenAwaitMarker("\u0001\u0002UCX"
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<contactinfo><app>UCXLog</app><mycall>DL0TEST</mycall><band>144</band>"
+ "<rxfreq>14420000</rxfreq><txfreq>14420000</txfreq><mode>CW</mode>"
+ "<call>OK1ABC</call><snt>599</snt><sntnr>001</sntnr><rcv>599</rcv>"
+ "<rcvnr>001JO70FD</rcvnr><gridsquare>jo70fd</gridsquare><points>350</points>"
+ "</contactinfo>");
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).applyExternalLoggedQso(qso.capture());
assertEquals("OK1ABC", qso.getValue().getCallSign());
assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand());
assertEquals("JO70FD", qso.getValue().getLocator());
assertEquals("UCXLOG", qso.getValue().getSource());
verify(controller).registerWorkedGrossField(
eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG"));
ArgumentCaptor<ChatMember> stored = ArgumentCaptor.forClass(ChatMember.class);
verify(database).storeChatMember(stored.capture());
assertEquals("OK1ABC", stored.getValue().getCallSign());
assertEquals("unknown", stored.getValue().getName());
assertEquals("JO70FD", stored.getValue().getQra());
assertTrue(stored.getValue().isWorked());
assertTrue(stored.getValue().isWorked144());
String backup = Files.readString(backupFile, StandardCharsets.UTF_8);
assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup);
}
/**
* DXLog sends contactreplace when it broadcasts the whole log. Without a
* gridsquare the locator comes from the received exchange, and a station
* already in the database is updated rather than stored again.
*/
@Test
void contactReplaceTakesTheLocatorFromTheReceivedExchange(@TempDir Path tempDir) throws Exception {
DBController database = givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
when(database.updateWkdInfoOnChatMember(any())).thenReturn(true);
sendThenAwaitMarker("<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<contactreplace><app>DXLog</app><band>432</band><call>SP2XYZ</call>"
+ "<rcvnr>042JO93AB</rcvnr></contactreplace>");
verify(controller).registerWorkedGrossField(
eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG"));
verify(database).updateWkdInfoOnChatMember(any());
verify(database, never()).storeChatMember(any());
}
@Test
void contactInfoIsIgnoredWhileLogSyncIsDisabled(@TempDir Path tempDir) throws Exception {
DBController database = givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
when(preferences.isLogsynch_ucxUDPWkdCallListenerEnabled()).thenReturn(false);
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
verify(controller, never()).applyExternalLoggedQso(any());
verify(database, never()).updateWkdInfoOnChatMember(any());
}
/**
* An unwritable backup path (here: a directory) used to throw a
* NullPointerException that ended the listener for good. The backup write
* is skipped now; the QSO has already reached the controller and the
* database, and the following packet is still processed.
*/
@Test
void unwritableBackupFileDoesNotStopQsoProcessingOrTheListener(@TempDir Path tempDir) throws Exception {
DBController database = givenQsoProcessingEnabled(tempDir);
when(database.updateWkdInfoOnChatMember(any())).thenReturn(false);
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call>"
+ "<gridsquare>JO70FD</gridsquare></contactinfo>");
verify(controller).applyExternalLoggedQso(any());
verify(database).storeChatMember(any());
}
/** A RadioInfo without Freq used to end the listener with a NullPointerException. */
@Test
void radioInfoWithoutFrequencyIsSkippedAndTheListenerKeepsRunning(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
sendThenAwaitMarker("<RadioInfo><Mode>CW</Mode></RadioInfo>");
assertEquals("1296.200.00", markerQrg.get());
}
/**
* Any runtime exception while processing one packet drops that packet,
* reports a non-critical status and leaves the receive loop running.
*/
@Test
void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
ThreadStateMessage dropped = statusMessages.stream()
.filter(message -> message.getRunningInformation().startsWith("packet dropped after error"))
.findFirst()
.orElseThrow();
assertTrue(dropped.getRunningInformation().contains("boom"));
assertTrue(dropped.isRunning());
assertFalse(dropped.isCriticalState());
}
/**
* Wires the mocks for QSO processing, including the RadioInfo ordering
* marker of {@link #givenRadioInfoMarkerEnabled()}.
*/
private DBController givenQsoProcessingEnabled(Path backupFile) {
DBController database = mock(DBController.class);
givenRadioInfoMarkerEnabled();
when(controller.getDbHandler()).thenReturn(database);
when(preferences.isLogsynch_ucxUDPWkdCallListenerEnabled()).thenReturn(true);
when(preferences.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup())
.thenReturn(backupFile.toString());
return database;
}
/**
* Enables TRX sync so that {@link #MARKER_PACKET} sets {@link #markerQrg}.
* The listener handles datagrams one at a time on a single thread, so once
* the marker's QRG is set, every datagram it received before the marker
* has been processed completely.
*/
private void givenRadioInfoMarkerEnabled() {
when(controller.getChatPreferences()).thenReturn(preferences);
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true);
when(preferences.getMYQRGFirstCat()).thenReturn(markerQrg);
}
/**
* The safety net reports a dropped packet through the status callback. If
* that callback throws as well, the listener must still keep running.
*/
@Test
void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
statusCallbackThrows = true;
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
assertTrue(statusMessages.stream()
.anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error")));
}
private CountDownLatch markerLatch() {
CountDownLatch marker = new CountDownLatch(1);
markerQrg.addListener(value -> {
if (value.equals("1296.200.00")) marker.countDown();
});
return marker;
}
/**
* Sends the packet and then the marker, and waits for the marker. This
* relies on the two datagrams arriving in the order they were sent. UDP
* does not guarantee that in general, but on the loopback interface, from
* one socket to one socket, they are delivered in order in practice.
*/
private void sendThenAwaitMarker(String packet) throws Exception {
CountDownLatch marker = markerLatch();
InetAddress loopback = InetAddress.getLoopbackAddress();
DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0));
ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(bound, controller, statusCallback);
listener.setDaemon(true);
listener.start();
try (DatagramSocket sender = new DatagramSocket(new InetSocketAddress(loopback, 0))) {
InetSocketAddress target = new InetSocketAddress(loopback, bound.getLocalPort());
send(sender, target, packet);
send(sender, target, MARKER_PACKET);
assertTrue(marker.await(5, TimeUnit.SECONDS), "marker packet must be processed");
} finally {
listener.interrupt();
listener.join(2_000);
}
}
private static void send(DatagramSocket sender, InetSocketAddress target, String payload) throws Exception {
byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
sender.send(new DatagramPacket(bytes, bytes.length, target));
}
}
@@ -0,0 +1,113 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import kst4contest.model.Band;
import kst4contest.model.ContestSked;
import kst4contest.model.ThreadStateMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Loopback tests for the Win-Test sked sender. The production code sends to a
* broadcast address; here the "broadcast address" is the loopback address and
* the receiver is a socket on an ephemeral loopback port, so nothing leaves the
* machine and no running Win-Test sees the datagrams.
*
* <p>The expected frames are written out literally and their checksum is
* computed here independently of {@link WinTestMessage}: the test pins the wire
* format, not the serializer's current implementation.</p>
*/
class WinTestSkedSenderLoopbackTest {
private static final String STATION = "KST4Contest";
private static final long SKED_TIME_MS = 1_760_000_000_000L;
private final List<ThreadStateMessage> statusMessages = new ArrayList<>();
private DatagramSocket winTest;
private WinTestSkedSender sender;
@BeforeEach
void bindFakeWinTest() throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
winTest = new DatagramSocket(new InetSocketAddress(loopback, 0));
winTest.setSoTimeout(5_000);
sender = new WinTestSkedSender(STATION, loopback, winTest.getLocalPort(),
(threadName, message) -> statusMessages.add(message));
}
@AfterEach
void closeFakeWinTest() {
winTest.close();
}
@Test
void pushSendsLockAddAndUnlockFramesInThisOrder() throws Exception {
ContestSked sked = new ContestSked("DL1ABC", 47.0, SKED_TIME_MS, Band.B_432);
sender.pushSkedToWinTest(sked, "DL1ABC", 432200.0, "[JO62QM - 47°] sked via KST4Contest", 0);
assertFrame("LOCKSKED: \"KST4Contest\" \"\" \"KST4Contest\"", receive());
/*
* Unix seconds, frequency in 0.1 kHz, Win-Test band ID 14 (432 MHz),
* mode 0 (CW), then the quoted callsign and notes. The degree sign is
* escaped as the four ASCII characters \260.
*/
assertFrame("ADDSKED: \"KST4Contest\" \"\" 1760000000 4322000 14 0 \"DL1ABC\""
+ " \"[JO62QM - 47\\260] sked via KST4Contest\"", receive());
assertFrame("UNLOCKSKED: \"KST4Contest\" \"\" \"KST4Contest\"", receive());
assertEquals(1, statusMessages.size());
assertEquals("Sked pushed to WT: DL1ABC", statusMessages.get(0).getRunningInformation());
assertFalse(statusMessages.get(0).isCriticalState());
}
@Test
void unexpectedModeIdIsSentAsSsbAndMissingNotesAsEmptyQuotes() throws Exception {
ContestSked sked = new ContestSked("OK1XYZ", 120.0, SKED_TIME_MS, Band.B_144);
sender.pushSkedToWinTest(sked, "OK1XYZ", 144300.0, null, 7);
receive(); // LOCKSKED
assertFrame("ADDSKED: \"KST4Contest\" \"\" 1760000000 1443000 12 1 \"OK1XYZ\" \"\"", receive());
receive(); // UNLOCKSKED
}
private byte[] receive() throws Exception {
byte[] buffer = new byte[2048];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
winTest.receive(packet);
return Arrays.copyOf(packet.getData(), packet.getLength());
}
/**
* Asserts the Win-Test framing: the ASCII text, one checksum byte (the sum
* of all preceding bytes, OR 0x80, truncated to a byte) and a NUL.
*/
private static void assertFrame(String expectedText, byte[] actual) {
byte[] text = expectedText.getBytes(StandardCharsets.US_ASCII);
int sum = 0;
for (byte b : text) {
sum += b & 0xFF;
}
byte[] expected = Arrays.copyOf(text, text.length + 2);
expected[text.length] = (byte) ((sum | 0x80) & 0xFF);
expected[text.length + 1] = 0;
assertEquals(expectedText, new String(actual, 0, Math.max(0, actual.length - 2), StandardCharsets.US_ASCII));
assertArrayEquals(expected, actual);
}
}
@@ -0,0 +1,40 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Covers the fallback-prefix lookup shared by the smart frequency parser and the
* preferences UI. Restored from the frequency-context test file that sat under
* {@code src/main} before #62 and was therefore never executed.
*/
class BandFromPrefixTest {
@Test
void resolvesOnlySupportedFallbackPrefixes() {
assertEquals(Band.B_144, Band.fromPrefix("144"));
assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
assertEquals(Band.B_10G, Band.fromPrefix("10368"));
assertNull(Band.fromPrefix("999"));
assertNull(Band.fromPrefix(null));
}
@Test
void rejectsPrefixesThatAreNotSpelledExactly() {
// The lookup is a trimmed string comparison, not a numeric one.
assertNull(Band.fromPrefix(""));
assertNull(Band.fromPrefix("144.0"));
assertNull(Band.fromPrefix("0144"));
assertNull(Band.fromPrefix("10G"));
}
@ParameterizedTest
@EnumSource(Band.class)
void everyBandResolvesFromItsOwnPrefix(Band band) {
assertEquals(band, Band.fromPrefix(band.getPrefix()));
}
}
@@ -1,21 +1,26 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
/** The interface language is stored per profile, like every other GUI preference. */
class ChatPreferencesLanguageTest {
@TempDir
Path tempDir;
@Test
void theDefaultIsTheSystemLanguage() {
// Empty means "follow the system", which is what the spec asks for on a first start.
assertEquals("", new ChatPreferences().getGuiOptions_language());
assertEquals("", new ChatPreferences(tempDir.resolve("preferences.xml")).getGuiOptions_language());
}
@Test
void aChosenLanguageIsKept() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setGuiOptions_language("de");
@@ -24,7 +29,7 @@ class ChatPreferencesLanguageTest {
@Test
void aNullSetterValueBecomesEmpty() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setGuiOptions_language(null);
@@ -1,6 +1,7 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
@@ -10,6 +11,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
/**
* No default that goes to another radio amateur changes with the interface language.
@@ -22,11 +24,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/
class ChatPreferencesLocaleTest {
@TempDir
Path tempDir;
private Map<String, String> stringFieldsUnder(final Locale locale) throws Exception {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(locale);
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
Map<String, String> values = new LinkedHashMap<>();
for (Field field : ChatPreferences.class.getDeclaredFields()) {
@@ -1,16 +1,21 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
class ChatPreferencesObservableTest {
@TempDir
Path tempDir;
@Test
void theDefaultsMatchTheFormerJavaFxProperties() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
assertEquals(360.0, prefs.getActualQTF().get(), 0.0001,
"actualQTF defaulted to 360 as a SimpleDoubleProperty");
@@ -20,7 +25,7 @@ class ChatPreferencesObservableTest {
@Test
void theQrgValuesStartUnavailable() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
assertNull(prefs.getMYQRGFirstCat().get(),
"an unset own QRG must stay unavailable rather than becoming a fixed band");
@@ -29,7 +34,7 @@ class ChatPreferencesObservableTest {
@Test
void theListSettingsStartEmpty() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedCallSignList().snapshot().isEmpty());
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty());
@@ -1,8 +1,10 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
/**
* The two designs' colour overrides are stored independently.
@@ -13,9 +15,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
*/
class ChatPreferencesPaletteTest {
@TempDir
Path tempDir;
@Test
void theTwoDesignsAreStoredIndependently() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setGuiOptions_paletteOverridesDay("surface=#FFFFFF");
prefs.setGuiOptions_paletteOverridesEvening("surface=#000000");
@@ -27,7 +32,7 @@ class ChatPreferencesPaletteTest {
@Test
void anUnsetOverrideReadsAsEmptyAndNotAsNull() {
// A preferences file from an older release has no such element at all.
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
assertEquals("", prefs.getGuiOptions_paletteOverridesDay());
assertEquals("", prefs.getGuiOptions_paletteOverridesEvening());
@@ -35,7 +40,7 @@ class ChatPreferencesPaletteTest {
@Test
void aNullSetterValueBecomesEmpty() {
ChatPreferences prefs = new ChatPreferences();
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setGuiOptions_paletteOverridesDay(null);
prefs.setGuiOptions_paletteOverridesEvening(null);
@@ -18,7 +18,8 @@ class ChatPreferencesStationMapVisibilityTest {
@Test
void pathAnalysisIsVisibleByDefault() {
ChatPreferences preferences = new ChatPreferences();
ChatPreferences preferences = new ChatPreferences(
temporaryDirectory.resolve("preferences.xml"));
assertTrue(preferences.isGUIstationMapPathAnalysisVisible());
}
@@ -27,9 +28,7 @@ class ChatPreferencesStationMapVisibilityTest {
void hiddenPathAnalysisStateSurvivesXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences writtenPreferences = new ChatPreferences();
writtenPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
ChatPreferences writtenPreferences = new ChatPreferences(preferencesFile);
writtenPreferences.setGUIstationMapPathAnalysisVisible(false);
writtenPreferences.writePreferencesToXmlFile();
@@ -38,9 +37,7 @@ class ChatPreferencesStationMapVisibilityTest {
"<GUIstationMapPathAnalysisVisible>false"
+ "</GUIstationMapPathAnalysisVisible>"));
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
ChatPreferences restoredPreferences = new ChatPreferences(preferencesFile);
restoredPreferences.readPreferencesFromXmlFile();
assertFalse(restoredPreferences.isGUIstationMapPathAnalysisVisible());
@@ -63,9 +60,8 @@ class ChatPreferencesStationMapVisibilityTest {
</praktiKST>
""");
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
legacyPreferencesFile.toString());
ChatPreferences restoredPreferences =
new ChatPreferences(legacyPreferencesFile);
restoredPreferences.readPreferencesFromXmlFile();
assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible());
+1 -1
View File
@@ -99,7 +99,7 @@ Known integration areas include:
- AirScout;
- UCXLog / DXLog UDP XML (`contactinfo`, `contactreplace`);
- Win-Test UDP;
- PSTRotator TCP;
- PSTRotator UDP;
- DXCluster;
- local SQLite persistence.
+6
View File
@@ -32,6 +32,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
- **PSTRotator-Azimut mit einer Nachkommastelle:** Der an PSTRotator gesendete Azimut wurde bisher auf ganze Grad abgeschnitten, sodass eine Station bei 145,7° die Antenne auf 145° drehte. Jetzt wird er auf eine Nachkommastelle gerundet, immer mit Punkt als Trennzeichen.
- **Dark Mode pro Profil:** Im Reiter **GUI** legt jedes Operator-Profil unter **Design** fest, ob KST4Contest hell oder dunkel startet. Die Menüeinträge unter **Windows** schalten weiterhin schnell um, gelten aber nur für die laufende Sitzung. Das Farbschema erfasst jetzt auch das Update-Fenster und die Liste der Top-Kandidaten.
### Behoben
@@ -48,6 +50,10 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
- **Kein Locator für die zweite Kategorie mehr:** Intern gab es eine Einstellung für einen eigenen Locator der zweiten Chat-Kategorie, die weder in der Oberfläche noch in der `preferences.xml` erschien und deshalb immer auf ihrem eingebauten Standardwert stand. Beim Verbindungsaufbau wurde dieser Standardwert mit dem eigenen Locator verglichen und eine Warnung ausgegeben, dass der zweite Locator ignoriert werde. ON4KST verwendet ohnehin einen Locator pro TCP-Sitzung; die Einstellung und die Warnung sind entfernt. Damit ist [Issue #104](https://github.com/praktimarc/kst4contest/issues/104) behoben.
- **Linux-Beta-Pakete lassen sich auf das stabile Paket aktualisieren:** DEB- und RPM-Pakete eines Beta-Tags trugen dieselbe Versionsnummer wie das folgende stabile Paket, sodass `dnf` und `dpkg` die Aktualisierung als gleiche Version ablehnten. Beta-Pakete erhalten jetzt eine Vorab-Release-Nummer (`1.50.0-0.beta.001`) unter der des stabilen Pakets (`1.50.0-1`), und der Release-Lauf bricht ab, wenn der Tag nicht zur `composePackageVersion` passt. Damit ist [Issue #113](https://github.com/praktimarc/kst4contest/issues/113) behoben.
- **UCXLog-Pakete werden nicht mehr abgeschnitten:** Nach einem kurzen UDP-Paket las der Empfänger für UCXLog-kompatible Logprogramme jedes längere Folgepaket nur bis zur Länge des kurzen, sodass Logeinträge unvollständig ankommen konnten. Außerdem schließt das Trennen den Socket des Empfängers jetzt sofort, statt bis zum nächsten Timeout zu warten. UCXLog-Pakete und Antworten von AirScout werden jetzt immer als UTF-8 dekodiert statt im Standardzeichensatz des Systems, sodass Umlaute in Stationsnamen und Operator-Rufzeichen unter Windows nicht mehr verstümmelt ankommen. Damit ist [Issue #115](https://github.com/praktimarc/kst4contest/issues/115) behoben.
- **UCXLog/DXLog-Empfänger läuft weiter:** Eine nicht beschreibbare Sicherungsdatei für empfangene QSOs oder ein RadioInfo-Paket ohne Frequenz oder Betriebsart hielt den Empfänger für UCXLog-kompatible Logprogramme bis zum nächsten Neustart an, sodass weder gearbeitete Stationen noch QRG-Änderungen ankamen. Jetzt wird der Sicherungseintrag beziehungsweise das unvollständige Paket übersprungen, und der Empfänger empfängt weiter. Jeder andere unerwartete Fehler beim Verarbeiten eines Pakets verwirft jetzt nur dieses Paket und erscheint als Statusmeldung, statt den Empfänger anzuhalten.
### Hinweise zur Aktualisierung
- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert.
+6
View File
@@ -32,6 +32,8 @@ Several operators sharing one computer can now use their own callsigns, locators
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
- **PSTRotator azimuth with one decimal:** the azimuth sent to PSTRotator used to be cut to whole degrees, so a station at 145.7° turned the antenna to 145°. It is now rounded to one decimal, always with a dot as separator.
- **Dark mode per profile:** on the **GUI** tab each operator profile sets under **Design** whether KST4Contest starts light or dark. The entries in the **Windows** menu still switch quickly, but only for the running session. The colour scheme now also covers the update window and the top candidates list.
### Fixed
@@ -48,6 +50,10 @@ Several operators sharing one computer can now use their own callsigns, locators
- **No second-category locator any more:** internally there was a setting for a separate locator of the second chat category that appeared neither in the user interface nor in `preferences.xml`, so it always kept its compiled-in default. During login that default was compared with the station's locator and a warning was emitted that the second locator is ignored. ON4KST uses one locator per TCP session anyway; the setting and the warning are removed. This fixes [Issue #104](https://github.com/praktimarc/kst4contest/issues/104).
- **Linux beta packages upgrade to the stable package:** DEB and RPM packages of a beta tag carried the same version number as the following stable package, so `dnf` and `dpkg` refused the upgrade as an equal version. Beta packages now carry a pre-release number (`1.50.0-0.beta.001`) below the stable package (`1.50.0-1`), and the release run aborts when the tag does not match `composePackageVersion`. This fixes [Issue #113](https://github.com/praktimarc/kst4contest/issues/113).
- **UCXLog packets are no longer cut off:** after a short UDP packet, the listener for UCXLog-compatible loggers read every longer following packet only up to the length of the short one, so log entries could arrive incomplete. In addition, disconnecting now closes the listener's socket immediately instead of waiting for the next timeout. UCXLog packets and AirScout replies are now always decoded as UTF-8 instead of the system default, so umlauts in station names and operator callsigns no longer turn into garbage on Windows. This fixes [Issue #115](https://github.com/praktimarc/kst4contest/issues/115).
- **UCXLog/DXLog listener keeps running:** an unwritable backup file for received QSOs or a RadioInfo packet without frequency or mode used to stop the listener for UCXLog-compatible loggers until the next restart, so neither worked stations nor QRG updates arrived any more. The backup entry or the incomplete packet is now skipped, and the listener keeps receiving. Any other unexpected error while processing a packet now drops only that packet and shows a status message instead of stopping the listener.
### Upgrade notes
- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.
+19 -2
View File
@@ -292,12 +292,29 @@ echo "==> Built $DMG"
echo "==> Step 4/4: verification"
# stapler talks to Apple's CloudKit ticket service. On the hosted Intel runners
# that connection drops now and then ("The network connection was lost.",
# exit 68) although the ticket is fine, so a lost connection is retried.
with_retries() {
local attempt=1 max_attempts=5 delay=20
until "$@"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo " giving up after $attempt attempts: $*" >&2
return 1
fi
echo " attempt $attempt failed, retrying in ${delay}s: $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
echo " submitting for notarization (waits for Apple's verdict)"
# Without a timeout a stalled submission would hang a CI job forever.
xcrun notarytool submit "$DMG" "${NOTARY_ARGS[@]}" --wait --timeout 30m
echo " stapling ticket"
xcrun stapler staple "$DMG"
with_retries xcrun stapler staple "$DMG"
else
echo " no notarization credentials set, skipping notarization"
fi
@@ -333,7 +350,7 @@ spctl --assess --type execute --verbose=4 "$SHIPPED_APP" || true
if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
echo "--- stapler validate ---"
xcrun stapler validate "$DMG"
with_retries xcrun stapler validate "$DMG"
fi
echo