mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 07:37:02 +01:00
Compare commits
10
Commits
6b0c80f71e
...
c64e9a9014
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64e9a9014 | ||
|
|
88d10259d4 | ||
|
|
c9b838e946 | ||
|
|
a9d8c013ef | ||
|
|
81b16ad4c5 | ||
|
|
2898c530d9 | ||
|
|
e1e6fe7297 | ||
|
|
4b10d96091 | ||
|
|
2db71ee864 | ||
|
|
4679c708bc |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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) }
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
+384
@@ -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
|
||||
|
||||
+7
-5
@@ -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}")
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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) } }
|
||||
}
|
||||
|
||||
+121
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
/*
|
||||
* 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 {
|
||||
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,15 +160,15 @@ 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);
|
||||
if (socket == null) {
|
||||
socket = new DatagramSocket(udpPortNr);
|
||||
}
|
||||
socket.setSoTimeout(2000); //TODO try for end properly
|
||||
}
|
||||
|
||||
@@ -155,13 +177,23 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
// 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) {
|
||||
/*
|
||||
* 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);
|
||||
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
+270
@@ -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;
|
||||
}
|
||||
}
|
||||
+384
@@ -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;
|
||||
}
|
||||
}
|
||||
+223
@@ -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());
|
||||
}
|
||||
}
|
||||
+162
@@ -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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user