Settings take effect with Apply/Close or Save (#134)

* Add a settings draft that holds edits until they are applied

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

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

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

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

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

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

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

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

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

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

* Ask about unapplied settings when the window is closed

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

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

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

* Document that settings take effect with Apply

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

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

* Repair stored settings once before the settings window is built

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 12:32:31 +02:00
1 parent c64e9a9014
commit f17ae0905f
50 files changed
+3079 -615

No files matched your search

@@ -1,5 +1,5 @@
# German. Held complete by a test, because this is a shipped language.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
@@ -88,6 +88,13 @@ notification.soundsSection=Töne
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
notification.spotterCallsign=Spotter-Rufzeichen
settings.applyAndClose=Übernehmen/Schließen
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
settings.pendingApply=Übernehmen
settings.pendingBack=Zurück zu den Einstellungen
settings.pendingDiscard=Verwerfen
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
settings.pendingTitle=Nicht übernommene Änderungen
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
settings.save=Einstellungen speichern
shortcuts.addButton=Kurztaste hinzufügen
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
@@ -8,7 +8,7 @@
#
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
# none of it is here, and three tests keep it that way.
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
airscout.automaticFrequency=Select AirScout frequency automatically per station
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
airscout.clientIdentifier=KST4Contest client identifier [KST]:
@@ -97,6 +97,13 @@ notification.soundsSection=Sounds
notification.speakCallsign=Speak the callsign on a received private message
notification.spotterCallsign=Spotter callsign
settings.applyAndClose=Apply/Close prefs
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
settings.pendingApply=Apply
settings.pendingBack=Back to the settings
settings.pendingDiscard=Discard
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
settings.pendingTitle=Changes not applied
settings.invalidEntry={0}: "{1}" is not a valid entry.
settings.save=Save settings
shortcuts.addButton=Add shortcut
shortcuts.addSnippet=Add new snippet
@@ -1,4 +1,5 @@
package kst4contest.view;
import kst4contest.view.compose.SettingsDraft;
import kst4contest.view.compose.SettingsHost;
import kst4contest.view.compose.SettingsNotices;
import kst4contest.view.compose.SettingsTabsKt;
@@ -1985,11 +1986,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
if (targetProfile == null) {
return;
}
ApplicationRuntimeLauncher.switchProfile(targetProfile);
/*
* Settings edits not applied yet are asked about first: applied, they land in the
* profile being left, which is the one they were made in; "back" cancels the switch.
* Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(() -> {
if (confirmOperatorProfileSwitch(targetProfile)) {
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
});
}
/**
@@ -2421,6 +2431,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private void closeWindowEvent() {
System.out.println("Window close request ...");
/*
* Settings edits not applied yet are asked about before the quit question; "back"
* cancels the exit. Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(this::confirmAndExit);
}
/** Asks whether to quit, and quits on yes. */
private void confirmAndExit() {
/*
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
@@ -3751,10 +3770,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void openSettingsWindow() {
/*
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
* asks about it when closed with edits pending.
*/
SettingsDraft draft = new SettingsDraft();
SettingsWindow.show(
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
this::settingsConnectionState),
this::settingsConnectionState, draft),
settingsNotices,
draft,
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore,
@@ -3767,7 +3793,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave();
return kotlin.Unit.INSTANCE;
},
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
}
/*
@@ -3839,10 +3865,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getMessageTXBus().add(sendMe);
}
/** Options/Windows -> open or close the settings window. */
/**
* Options/Windows -> open or close the settings window. Closing goes the way of the
* window's own X, so edits not applied yet are asked about rather than dropped.
*/
void menuActionToggleSettingsWindow() {
if (SettingsWindow.isOpen()) {
SettingsWindow.close();
SettingsWindow.requestClose();
} else {
openSettingsWindow();
}
@@ -127,7 +127,7 @@ class ComposeWindowHost(private val threadName: String) {
* [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`.
*/
private fun raise() {
fun raise() {
val frame = this.frame ?: return
EventQueue.invokeLater {
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -240,7 +242,7 @@ class EditableListState(
* reject it and put the stored value back.
*
* @param onChanged called after every committed change — add, edit, removal, move.
* The lists write through, so this is where the roster is filled.
* The settings tabs stage the list here; applying the draft fills the roster.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour.
@@ -300,6 +302,23 @@ fun DataTable(
}
}
/*
* Inside the settings window a row still being edited is committed before the
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
* untouched one would report a change that was never made. A refusal is reported
* by commit() through onRefused, so the registration answers null.
*/
val settingsDraft = LocalSettingsDraft.current
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
DisposableEffect(settingsDraft, entry.key) {
val registration = settingsDraft?.registerTypedEntry {
pendingEdit.value()
null
}
onDispose { registration?.close() }
}
val isSelected = state.selectedIndex == index
/*
@@ -33,10 +33,12 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -124,12 +126,12 @@ object Form {
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value stored right now, read when the field commits. The draft
* is judged against it, and falls back to it on a refusal (what the JavaFX
* fields did with setText). Required, because [stored] alone goes stale when it
* comes from a plain getter: nothing recomposes the caller after a commit, so
* after Enter it still names the old value, and once the preference is reset
* behind the field it names the new one by coincidence only.
* @param current the value in force right now, read when the field commits: inside the
* settings window the value staged in the draft, otherwise the stored one. The
* typed text is judged against it, and falls back to it on a refusal (what the
* JavaFX fields did with setText). Required, because [stored] alone goes stale
* when it comes from a plain getter: nothing need recompose the caller after a
* commit, so after Enter it can still name the old value.
*/
@Composable
fun committed(
@@ -166,6 +168,24 @@ object Form {
}
}
/*
* Inside the settings window the draft can commit this field without a focus change:
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
* otherwise reach the draft only when the closing window removed the field. Through
* an updated state, because commitDraft closes over this composition's parameters.
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
* the registration answers null and the caller sees the refusal as the notice.
*/
val settingsDraft = LocalSettingsDraft.current
val latestCommit = rememberUpdatedState(::commitDraft)
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry {
latestCommit.value()
null
}
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
@@ -304,34 +324,53 @@ object Form {
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().toIntOrNull()?.let(onChange)
},
isError = draft.trim().toIntOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
/**
* The number field behind [int] and [decimal].
*
* The text follows the stored value only when that value is not what the operator's own
* text already says. A settings draft is Compose state, so the value an entry stages comes
* straight back as [value]; resetting the text to `value.toString()` then would turn a
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
*/
@Composable
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
val typed = remember { arrayOf(value.toString()) }
var draft by remember(value) {
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
}
/*
* Text that does not parse stages nothing, so the draft still holds the last valid
* value while the field shows something else in red. Applying then would put a value
* into effect the operator no longer sees; the field refuses instead, naming itself.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
onValueChange = { entered ->
typed[0] = entered
draft = entered
parse(entered)?.let(onChange)
},
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
isError = parse(draft) == null,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,247 @@
package kst4contest.view.compose
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* The edits made in the settings window that have not been applied yet.
*
* One draft exists per opened settings window and is handed to every tab state. A tab
* state stages a change here instead of writing ChatPreferences, and reads its values
* back through [valueOf], so the window shows what the operator entered while the running
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
* closing the window and choosing to discard calls [discard].
*
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
* than its value. Several settings carry an effect on the running session -- the beacon
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
* immediate edit ran, once per changed setting.
*
* The order [apply] keeps is part of the contract, see there. The design and the list of
* settings that deliberately bypass the draft are in
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
*
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
* thread, and every caller -- a control, a button, the close request -- runs there.
* Open for the tests, which use a draft that applies every change at once to pin the
* rules of a tab state without the draft in between.
*/
open class SettingsDraft {
private class Change(
val value: Any?,
val write: () -> Unit,
val effectKey: String?,
val effect: (() -> Unit)?,
)
/*
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
* value is staged. The tab states are plain facades; this is what makes them observable.
*/
private val changes = mutableStateMapOf<String, Change>()
/*
* The order in which settings were first changed. The state map keeps no order, and
* apply() must not run effects in hash order.
*/
private val order = mutableListOf<String>()
private val typedEntries = mutableListOf<() -> String?>()
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
val hasPendingChanges: Boolean
get() = changes.isNotEmpty()
/** The staged value of [key], or the stored one read from [live]. */
@Suppress("UNCHECKED_CAST")
fun <T> valueOf(key: String, live: () -> T): T {
val change = changes[key] ?: return live()
return change.value as T
}
/**
* Records a change of the setting [key]. A later stage of the same key replaces the value
* and keeps the position. Staging the stored value removes the change: a box ticked and
* unticked again leaves nothing to apply and nothing to ask about on closing.
*
* Only valid values may be staged. The fields refuse an invalid entry before it gets
* here, as they always did.
*
* @param live reads the stored value
* @param write stores the value; called by [apply]
* @param effectKey settings with the same key share one effect, which [apply] runs once.
* Defaults to [key].
* @param effect what the running session needs after the write, run by [apply] after all
* writes
*/
open fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String? = null,
effect: (() -> Unit)? = null,
) {
if (value == live()) {
changes.remove(key)
order.remove(key)
return
}
if (!changes.containsKey(key)) {
order += key
}
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
}
/**
* Writes every change, then runs every effect, then forgets what was written.
*
* Writes come first, in the order the settings were first changed, so that each effect
* sees the finished state: enabling the Win-Test listener and moving its port in one go
* would otherwise restart the listener on the old port. Effects follow in the same order,
* each effect key once: two restarts of one socket in quick succession can fail on the
* port the first one has not released yet.
*
* Every write and every effect runs on its own: one that throws is recorded and the rest
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
* next setting unapplied. A change whose write failed stays in the draft, and its effect
* does not run -- it would act on a value that was never stored. A failed effect's write
* did happen, so that change is gone from the draft like every other.
*
* Typed entries still held by a field are not committed here; the caller does that first
* and decides on a refusal before anything is applied, see [commitTypedEntries].
*
* @return what could not be done; empty when everything went through
*/
fun apply(): ApplyOutcome {
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
val failedWrites = mutableListOf<String>()
val failedKeys = HashSet<String>()
staged.forEach { (key, change) ->
try {
change.write()
} catch (failure: Exception) {
failedWrites += describe(key, failure)
failedKeys += key
}
}
val failedEffects = mutableListOf<String>()
val ran = HashSet<String?>()
staged.forEach { (key, change) ->
val effect = change.effect ?: return@forEach
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
try {
effect()
} catch (failure: Exception) {
failedEffects += describe(change.effectKey ?: key, failure)
}
}
staged.forEach { (key, _) ->
if (key !in failedKeys) {
changes.remove(key)
order.remove(key)
}
}
return ApplyOutcome(failedWrites, failedEffects)
}
/** Forgets every change without writing anything. */
fun discard() {
clear()
}
/**
* Registers a field that holds typed text the draft has not seen yet: a field that commits
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
* parse and therefore staged nothing.
*
* Clicking a button does not reliably take the focus from a text field, so without this an
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
* committed only when the closing window removed the field -- into a draft nobody applies
* any more. And a field showing invalid text in red would let Apply go ahead with the last
* valid value, which the operator no longer sees.
*
* @param commit commits what the field holds and returns null, or returns the reason it
* cannot be accepted -- worded for the operator, naming the field
* @return closing it unregisters the field
*/
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
typedEntries += commit
return AutoCloseable { typedEntries.remove(commit) }
}
/**
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
*
* @return the refusals, empty when every field was accepted
*/
fun commitTypedEntries(): List<String> =
typedEntries.toList().mapNotNull { it() }
private fun describe(key: String, failure: Exception): String =
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
private fun clear() {
changes.clear()
order.clear()
}
}
/**
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
*
* @param failedWrites settings that could not be stored; they stay in the draft
* @param failedEffects effects that failed after their settings were stored
*/
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
val failures: List<String>
get() = failedWrites + failedEffects
val isClean: Boolean
get() = failures.isEmpty()
}
/**
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
*
* The key is the owning class and the property name, so two tabs cannot collide and a
* property needs no key spelled out beside it.
*
* @param read reads the stored value
* @param write stores a value; runs on apply
* @param effectKey see [SettingsDraft.stage]
* @param effect see [SettingsDraft.stage]
*/
fun <T> SettingsDraft.setting(
read: () -> T,
effectKey: String? = null,
effect: (() -> Unit)? = null,
write: (T) -> Unit,
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
private fun keyOf(owner: Any, property: KProperty<*>) =
(owner::class.simpleName ?: "settings") + "." + property.name
override fun getValue(thisRef: Any, property: KProperty<*>): T =
valueOf(keyOf(thisRef, property), read)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
}
}
/**
* The draft of the settings window being drawn, or null outside it. Form.committed registers
* its typed entry here; the tab screen tests draw a tab alone and need none.
*/
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
@@ -37,6 +37,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
import kst4contest.view.compose.tabs.repairStoredSettings
/**
* The work the settings window needs from the main window.
@@ -118,6 +119,8 @@ interface SettingsHost {
* @param connectionState the session state as Compose state reads it; the Station tab
* locks the login fields from it. The controller's own getter is not observable,
* so a tab reading it would stay on whatever the link was when the window opened.
* @param draft the edits of this opening of the window; every tab state stages into it,
* and the button bar applies it
*/
fun buildSettingsTabs(
controller: ChatController,
@@ -126,12 +129,17 @@ fun buildSettingsTabs(
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
connectionState: () -> On4KstConnectionState,
draft: SettingsDraft,
): List<SettingsTab> {
val prefs = controller.chatPreferences
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
val stationState = StationTabState(
prefs,
draft,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
@@ -139,20 +147,22 @@ fun buildSettingsTabs(
val notificationState = NotificationTabState(
prefs,
draft,
controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServerEnabled = { enabled ->
applyDxClusterServer = {
/*
* Starting a server that nothing can reach yet is pointless, so the JavaFX
* checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login.
* flag alone decides what happens at the next login. Restarted rather than
* started when enabled: the same call then binds a changed port, and a server
* that was not running is simply started.
*/
if (controller.isConnectedAndLoggedIn) {
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
if (prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
} else {
controller.stopDxClusterServer()
}
}
},
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
@@ -160,12 +170,14 @@ fun buildSettingsTabs(
val shortcutsState = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
)
val beaconState = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
@@ -174,9 +186,9 @@ fun buildSettingsTabs(
val logSynchState = LogSynchTabState(
prefs,
draft,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
@@ -213,12 +225,12 @@ fun buildSettingsTabs(
return listOf(
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
/*
* Selecting this tab re-read the database in the JavaFX window; the list is a
* snapshot and would otherwise show what was true when the window opened.
@@ -227,7 +239,7 @@ fun buildSettingsTabs(
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
/*
* Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement.
@@ -291,11 +303,19 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
* Built here because it needs the controller for its state and the host for its
* actions, and because it belongs with the tabs it sits under. The `close` action comes
* from the window itself: only the window can end its own Compose application.
*
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
* "Save settings" applies and writes the file, and Connect applies before it connects: the
* session would otherwise use the stored server or second-chat switch rather than the one
* the tab shows. A field that refuses what it holds stops the button before anything is
* applied, so the operator sees why instead of a closed window or a saved file; the X still
* offers Discard. A write or effect that fails is reported in a problem notice.
*/
fun settingsButtons(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
draft: SettingsDraft,
): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller)
@@ -311,19 +331,14 @@ fun settingsButtons(
ConnectionBar(
state = barState,
onConnect = {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
if (applyChecked(draft, notices)) {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
}
revision++
},
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(kst4contest.view.i18n.CurrentStrings.get().settingsWriteFailed)
} else {
notices.info(kst4contest.view.i18n.CurrentStrings.get().settingsWrittenTo(target))
}
},
onApplyAndClose = close,
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
@@ -332,3 +347,74 @@ fun settingsButtons(
)
}
}
/**
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
* the field, or discards everything through the window's X, whose question offers Discard
* even while a field refuses (see SettingsQuestion).
*
* A problem notice already standing counts as a refusal too. The click on a button can
* itself take the focus from a field, which then commits -- and is refused, reporting it --
* before the button's action runs. A problem notice from earlier cannot be standing here:
* its dialog covers the window, so nothing in it could have been clicked.
*/
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
val refusals = draft.commitTypedEntries()
if (refusals.isNotEmpty()) {
notices.problem(refusals.joinToString("\n"))
return false
}
return notices.current !is SettingsNotice.Problem
}
/**
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
* applied, the failures named in a problem notice).
*/
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
if (!typedEntriesAccepted(draft, notices)) return false
val outcome = draft.apply()
if (!outcome.isClean) {
notices.problem(applyFailedText(outcome))
return false
}
return true
}
/**
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
* where it went. The window stays open.
*
* A failed effect does not stop the file: its setting was stored, and the file should hold
* it. A failed write does, because the file would then miss what the window shows.
*
* @param save writes the preferences and returns the file, or null when it could not
*/
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
if (!typedEntriesAccepted(draft, notices)) return
val outcome = draft.apply()
if (outcome.failedWrites.isNotEmpty()) {
notices.problem(applyFailedText(outcome))
return
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val target = save()
when {
target == null -> notices.problem(
listOfNotNull(
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
strings.settingsWriteFailed,
).joinToString("\n\n")
)
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
else -> notices.info(strings.settingsWrittenTo(target))
}
}
private fun applyFailedText(outcome: ApplyOutcome): String =
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.awt.EventQueue
import kst4contest.view.i18n.LocalStrings
/**
* One tab of the settings window: a title and the content to show.
@@ -104,6 +109,90 @@ class SettingsNotices {
}
}
/**
* The question about edits not applied yet, asked when the window closes, the profile is
* switched or the client exits.
*
* The decision is taken in [settle], on the calling event thread, not in the window's next
* frame: a minimised window may draw no frames, and an exit from the main window would then
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
* window is raised before it is needed. Compose state, so the open window shows the question.
*/
class SettingsQuestion {
/** What a shown question goes on with. */
class Asked(
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
val then: (() -> Unit)?,
/**
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
* work, so the operator is never trapped behind a field he only wants to get rid of.
*/
val refusal: String?,
)
/** The question being shown, or null. */
var asking: Asked? by mutableStateOf(null)
private set
/** Counts the closes asked for with nothing to settle; the window closes on each. */
var closeRequests by mutableStateOf(0)
private set
/**
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
* and no field refuses what it holds, and shows the question otherwise.
*
* While a question is shown a further request is ignored. Replacing its continuation would
* turn an exit the operator is being asked about into a mere close of the window -- or the
* other way round.
*
* @return true when a question is shown now
*/
fun settle(
draft: SettingsDraft,
notices: SettingsNotices,
then: (() -> Unit)?,
nothingPending: () -> Unit,
): Boolean {
if (asking != null) return true
val refusals = draft.commitTypedEntries()
// A refusal a field reported on its own focus loss is standing as the notice.
val standing = notices.current as? SettingsNotice.Problem
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
if (refusal == null && !draft.hasPendingChanges) {
nothingPending()
return false
}
// The reason moves into the question, so two dialogs do not stand on top of each other.
if (standing != null) notices.dismiss()
asking = Asked(then, refusal)
return true
}
/** The X of the window: close when nothing waits, ask otherwise. */
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
}
/** The question was answered; called before acting on the answer. */
fun answered() {
asking = null
}
}
/**
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
* profile switch or an exit must not run twice because a dialog was clicked twice.
*/
internal fun runOnce(action: Runnable): Runnable {
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
}
/**
* The settings window.
*
@@ -112,9 +201,11 @@ class SettingsNotices {
* most once — a second instance would let two windows write the same preferences
* against each other.
*
* Saving does not collect values. Every control writes into ChatPreferences as it
* changes; the button bar only persists, which is what the JavaFX "Save settings"
* button did.
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
* the window, switching the operator profile or leaving the client while edits are waiting
* first asks whether to apply or discard them, or to go back, which cancels the close, the
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
* follows -- and a JVM shutdown hook drop them without asking.
*/
object SettingsWindow {
@@ -135,11 +226,67 @@ object SettingsWindow {
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
/** What the open window was built with, if one is open. */
private class OpenWindow(
val draft: SettingsDraft,
val notices: SettingsNotices,
val question: SettingsQuestion,
)
@Volatile
private var openWith: OpenWindow? = null
/**
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
* operator's close; that one goes through [requestClose].
*/
@JvmStatic
fun close() = host.close()
/**
* Closes the window the way its X does: at once when nothing waits to be applied,
* otherwise after asking.
*/
@JvmStatic
fun requestClose() {
val open = openWith
if (open == null || !host.isOpen) return
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
host.raise()
}
}
/**
* Runs [action] once the window's pending edits are settled: at once when the window is
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
* that neither drops edits the operator has not decided about. While a question is
* already shown the request is ignored, see [SettingsQuestion.settle].
*
* Must be called on the event thread, and never waits there. The decision is taken here
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
* draws again -- and the question set as Compose state. The action then runs from the
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
* confirmation, the window teardown -- does not run inside the click handler. It runs at
* most once.
*/
@JvmStatic
fun settleThen(action: Runnable) {
val once = runOnce(action)
val open = openWith
if (open == null || !host.isOpen) {
once.run()
return
}
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
host.raise()
}
}
/**
* @param draft the edits of this opening of the window; the tabs and the buttons were
* built with the same one
* @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application
*/
@@ -147,6 +294,7 @@ object SettingsWindow {
fun show(
tabs: List<SettingsTab>,
notices: SettingsNotices,
draft: SettingsDraft,
darkMode: Boolean,
baseFontSizeSp: Float,
/**
@@ -164,6 +312,15 @@ object SettingsWindow {
onResized: (Float, Float) -> Unit,
buttons: @Composable (close: () -> Unit) -> Unit,
) {
if (host.isOpen) {
// Raises the open window; the new draft belongs to no window and is dropped.
host.raise()
return
}
val question = SettingsQuestion()
openWith = OpenWindow(draft, notices, question)
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
darkMode = darkMode,
@@ -173,68 +330,101 @@ object SettingsWindow {
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
// The X is clicked in a window that is showing, so its frames run.
onCloseRequest = { question.settleClose(draft, notices) },
) { close ->
SettingsContent(tabs, notices, buttons, close)
SettingsContent(tabs, notices, buttons, close, draft, question)
}
}
}
/**
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
* a typed entry can be committed into it before an apply.
* @param question the question about the pending edits, and the closes asked for without one
*/
@Composable
internal fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit,
draft: SettingsDraft = SettingsDraft(),
question: SettingsQuestion = SettingsQuestion(),
) {
var selected by remember { mutableStateOf(0) }
val closes = question.closeRequests
LaunchedEffect(closes) {
if (closes > 0) close()
}
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
CompositionLocalProvider(LocalSettingsDraft provides draft) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
buttons(close)
}
buttons(close)
}
}
question.asking?.let { asked ->
val proceed = asked.then ?: close
PendingChangesDialog(
refusal = asked.refusal,
onApply = {
question.answered()
// A refusal or a failure stops here, with the reason shown; nothing goes on.
if (applyChecked(draft, notices)) proceed()
},
onDiscard = {
question.answered()
draft.discard()
proceed()
},
onBack = { question.answered() },
)
}
notices.current?.let { notice ->
AlertDialog(
onDismissRequest = notices::dismiss,
@@ -252,6 +442,46 @@ internal fun SettingsContent(
}
}
/**
* Asks what becomes of the edits not applied yet when the operator closes the window,
* switches the profile or exits: apply them, discard them, or go back to the window.
* Dismissing it goes back -- nothing is lost by a stray click beside it.
*
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
* disabled. Discard and Back stay available, so a field the operator only wants to get
* rid of never blocks the close, the switch or the exit.
*/
@Composable
internal fun PendingChangesDialog(
refusal: String?,
onApply: () -> Unit,
onDiscard: () -> Unit,
onBack: () -> Unit,
) {
val strings = LocalStrings.current
AlertDialog(
onDismissRequest = onBack,
title = { Text(strings.settingsPendingTitle) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(strings.settingsPendingQuestion)
// Why Apply is not offered; Discard and Back still are.
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
},
dismissButton = {
Row {
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
}
},
)
}
/**
* The row of tab buttons.
*
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
/**
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
*
* Labels, defaults and the closing note are the JavaFX ones, including that the server
* identifier, the client identifier and the frequency mode take effect at once while a
* Labels and defaults are the JavaFX ones. The closing note says that the server
* identifier, the client identifier and the frequency mode take effect when applied while a
* changed UDP port needs a reconnect.
*
* The identifier, port and band fields keep what the operator typed and mark it as an
@@ -1,14 +1,16 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Airscout tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*
* Three properties are guarded before the write, and the guard is not decoration. The
* Three properties are guarded before they are staged, and the guard is not decoration. The
* ChatPreferences setters do not keep the previous value on bad input, they substitute
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
* becomes 9872, a band value that is not a positive number becomes "1440000". The
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
* value are protocol values and neither the defaults nor the bounds are touched.
*/
class AirscoutTabState(private val prefs: ChatPreferences) {
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
var airScout_asUDPListenerEnabled: Boolean
get() = prefs.isAirScout_asUDPListenerEnabled()
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isAirScout_asUDPListenerEnabled() },
) { prefs.setAirScout_asUDPListenerEnabled(it) }
/*
* The drafted values behind the guarded properties below. Only a valid, normalised value
* is staged, so neither the draft nor apply can hand the setters what they would replace
* with the factory default.
*/
private var serverName: String by draft.setting(
{ prefs.getAirScout_asServerNameString() },
) { prefs.setAirScout_asServerNameString(it) }
private var clientName: String by draft.setting(
{ prefs.getAirScout_asClientNameString() },
) { prefs.setAirScout_asClientNameString(it) }
private var communicationPort: Int by draft.setting(
{ prefs.getAirScout_asCommunicationPort() },
) { prefs.setAirScout_asCommunicationPort(it) }
private var bandString: String by draft.setting(
{ prefs.getAirScout_asBandString() },
) { prefs.setAirScout_asBandString(it) }
/** Logical identifier of the target AirScout server; invalid input is ignored. */
var airScout_asServerNameString: String
get() = prefs.getAirScout_asServerNameString()
get() = serverName
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asServerNameString(value.trim())
serverName = value.trim()
}
}
/** Identifier of this KST4Contest instance; invalid input is ignored. */
var airScout_asClientNameString: String
get() = prefs.getAirScout_asClientNameString()
get() = clientName
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asClientNameString(value.trim())
clientName = value.trim()
}
}
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* that changes here.
*/
var airScout_asCommunicationPort: Int
get() = prefs.getAirScout_asCommunicationPort()
get() = communicationPort
set(value) {
if (isValidPort(value)) {
prefs.setAirScout_asCommunicationPort(value)
communicationPort = value
}
}
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* Pick the AirScout frequency per station instead of forcing one value. Uses the
* station's current QRG first, then station-name and chat-category evidence.
*/
var airScout_autoBandSelectionEnabled: Boolean
get() = prefs.isAirScout_autoBandSelectionEnabled()
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
{ prefs.isAirScout_autoBandSelectionEnabled() },
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
/**
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
* value produced in the JavaFX field.
*/
var airScout_asBandString: String
get() = prefs.getAirScout_asBandString()
get() = bandString
set(value) {
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
parseBandValue(value)?.let { bandString = it }
}
/**
@@ -3,6 +3,8 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the beacon tab: the two CQ templates and the one interval they share.
@@ -10,34 +12,57 @@ import kst4contest.view.i18n.CurrentStrings
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it.
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
* @param restartBeaconTimer called only after the interval actually changed; the
* JavaFX code restarted the shared timer there and nowhere else.
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
* restarted the shared timer there and nowhere else.
*/
class BeaconTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val validateBeaconTemplate: (String) -> Unit,
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
) {
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledMainCat() },
) { prefs.setBcn_beaconsEnabledMainCat(it) }
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledSecondCat() },
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
) { prefs.setBcn_beaconTextMainCat(it) }
private var textSecondCat: String by draft.setting(
{ prefs.getBcn_beaconTextSecondCat() },
) { prefs.setBcn_beaconTextSecondCat(it) }
/*
* Both stored intervals written together, as applySharedBeaconInterval did. The
* second-category value is legacy XML which nothing reads any more, but it is written
* to keep the two in step.
*/
private var interval: Int by draft.setting(
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
effect = { restartBeaconTimer() },
) { minutes ->
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
}
val beaconTextMainCat: String
get() = prefs.getBcn_beaconTextMainCat()
get() = textMainCat
val beaconTextSecondCat: String
get() = prefs.getBcn_beaconTextSecondCat()
get() = textSecondCat
/**
* The interval both categories share, never below the minimum.
@@ -50,16 +75,16 @@ class BeaconTabState(
val beaconIntervalMinutes: Int
get() = maxOf(
ChatController.MIN_BEACON_INTERVAL_MINUTES,
prefs.getBcn_beaconIntervalInMinutesMainCat(),
interval,
)
/** @return null when stored, otherwise the reason it was refused. */
/** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextMainCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
commitBeaconText(template) { textMainCat = it }
/** @return null when stored, otherwise the reason it was refused. */
/** @return null when staged, otherwise the reason it was refused. */
fun commitBeaconTextSecondCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
commitBeaconText(template) { textSecondCat = it }
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
try {
@@ -78,9 +103,9 @@ class BeaconTabState(
}
/**
* Applies the shared interval to both categories and restarts the timer.
* Stages the shared interval for both categories; applying it restarts the timer.
*
* @return null when applied, otherwise the reason it was refused.
* @return null when staged, otherwise the reason it was refused.
*/
fun commitBeaconInterval(entered: String): String? {
val minutes = entered.trim().toIntOrNull()
@@ -89,16 +114,7 @@ class BeaconTabState(
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
}
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
/*
* The second-category value is legacy XML which nothing reads any more, but
* it is written to keep the two in step, exactly as applySharedBeaconInterval
* did.
*/
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
restartBeaconTimer()
interval = minutes
return null
}
}
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.AwtWindow
import java.awt.FileDialog
@@ -36,8 +37,7 @@ import java.io.File
* back on bad input. Writing through on every keystroke without that guard would be
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
* identifiers, the AirScout port and the AirScout band value substitute the factory
* default on bad input instead of keeping the previous value. It also reports focus
* loss, which is where a port change may rebind a socket.
* default on bad input instead of keeping the previous value.
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
*/
@Composable
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
enabled: Boolean = true,
accepts: (String) -> Boolean = { true },
onAccepted: (String) -> Unit,
onCommit: () -> Unit = {},
) {
/*
* Keyed on the stored value: the draft is only reset when the stored value really
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
* value such as a trailing space that is trimmed away, leaves the typed text alone.
*/
var draft by remember(value) { mutableStateOf(value) }
var hadFocus by remember { mutableStateOf(false) }
/*
* A refused entry stages nothing, so the draft keeps the last accepted value while the
* field shows the refused text in red. Inside the settings window the field refuses the
* apply instead of letting that hidden value go into effect.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
LabelledRow(label) {
CompactTextField(
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
},
enabled = enabled,
isError = !accepts(draft),
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (hadFocus && !focusState.isFocused) {
onCommit()
}
hadFocus = focusState.isFocused
},
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -1,42 +1,34 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.LanguageStore
/**
* The GUI tab as a typed facade over ChatPreferences.
*
* Writes through immediately and has no apply(), like every settings tab: the
* JavaFX controls wrote as they changed and "Save settings" only persisted.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The language is the exception: it switches at once, because the switch is
* itself what the operator wants to see.
*/
class GuiOptionsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val languageStore: LanguageStore,
) {
var gUI_darkModeActiveByDefault: Boolean
get() = prefs.isGUI_darkModeActiveByDefault()
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
var guiOptions_defaultFilterNothing: Boolean
get() = prefs.isGuiOptions_defaultFilterNothing()
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
var guiOptions_defaultFilterPmToMe: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToMe()
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
var guiOptions_defaultFilterPmToOther: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToOther()
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
var guiOptions_defaultFilterPublicMsgs: Boolean
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
var guiOptions_showFreshCallHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
/**
* The interface language, as the picker offers it: a language code, or the empty string
@@ -50,8 +42,6 @@ class GuiOptionsTabState(
get() = languageStore.stored
set(value) { languageStore.use(value) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
}
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
*
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did. "Save settings" persists, it does not collect.
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
* chooser writes the chosen path at once.
*
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
* disabled while its listener is off, as it was there.
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
value = state.logsynch_wintestNetworkPort.toString(),
enabled = state.logsynch_wintestNetworkListenerEnabled,
accepts = { it.trim().toIntOrNull() != null },
// The socket is rebound when the port is applied, not per digit.
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
// Rebinding the socket belongs to the finished entry, not to every digit.
onCommit = { state.commitWintestNetworkPort() },
)
GuardedTextField(
@@ -1,23 +1,24 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Log synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The log file path is the exception: it changes through a file chooser,
* an action, and is written when chosen.
*
* Two things this tab controlled are not settings at all but effects on the running
* session, and no coverage test can see them because they are not ChatPreferences
* setters. They are taken as callbacks rather than from a ChatController so the rules
* stay testable without opening a database:
* One thing this tab controls is not a setting at all but an effect on the running session,
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
* per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
* ChatController so the rules stay testable without opening a database.
*
* - the Win-Test UDP listener is started and stopped with its setting;
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
* default, which the JavaFX window did while building the settings pane.
* The broadcast address the JavaFX window auto-detected while building the pane is detected
* by repairStoredSettings now, before the window is built, not by this state.
*
* Ports, transports and framing are untouched: the accepted values, the defaults and
* the moment a socket is rebound are the ones the JavaFX window had.
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
* ChatController.restartWintestUdpListenerIfEnabled()
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
class LogSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val restartWintestListener: () -> Unit,
private val stopWintestListener: () -> Unit,
private val detectWintestBroadcastAddress: () -> String?,
) {
init {
autoDetectBroadcastAddressWhileStillDefault()
}
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
/**
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
@@ -53,75 +48,73 @@ class LogSynchTabState(
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
/**
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
* accepted any number here and rebound nothing — that listener comes up at start —
* so neither a range nor a restart is added.
*/
var logsynch_ucxUDPWkdCallListenerPort: Int
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
var logsynch_wintestNetworkListenerEnabled: Boolean
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
set(value) {
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
if (value) restartWintestListener() else stopWintestListener()
}
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
/**
* UDP port for the Win-Test listener, default 9871. The setting is written at once
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
* and unbind a UDP port for every digit on the way to the intended one.
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
* for every number on the way to the intended one.
*/
var logsynch_wintestNetworkPort: Int
get() = prefs.getLogsynch_wintestNetworkPort()
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
var logsynch_wintestNetworkPort: Int by draft.setting(
{ prefs.getLogsynch_wintestNetworkPort() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkPort(it) }
/** Applies the entered port to the running listener, if there is one. */
fun commitWintestNetworkPort() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
restartWintestListener()
}
}
/*
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
* trailing space does not count as a change.
*/
private var stationNameOfKst: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfKST() },
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
private var broadcastAddress: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
/** The name KST4Contest uses as source of Win-Test SKED packets. */
var logsynch_wintestNetworkStationNameOfKST: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
get() = stationNameOfKst
set(value) { stationNameOfKst = value.trim() }
/** UDP broadcast address used for sending to Win-Test. */
var logsynch_wintestNetworkBroadcastAddress: String
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
get() = broadcastAddress
set(value) { broadcastAddress = value.trim() }
/**
* Replaces the catch-all default 255.255.255.255 with the detected subnet
* broadcast address. An address the operator or a previous detection chose is
* left alone, and a failed detection keeps the default: guessing an address here
* would send SKED packets somewhere nobody is listening.
* Brings the Win-Test listener in line with the applied switch and port: rebound while
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
* once and binds the new port; a port changed while the listener is off meets a listener
* that is not running, and stopping that does nothing.
*/
private fun autoDetectBroadcastAddressWhileStillDefault() {
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
return
}
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (detected != null && detected.isNotBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
private fun applyWintestListener() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
}
companion object {
/** The stored value that still means "not configured for this network". */
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
/** The effect key the Win-Test switch and port share. */
private const val WINTEST_LISTENER = "wintestListener"
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the message-handling tab.
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
* that: a tab that wrote only the main category would leave the second chat on its
* previous value, which no part of the window ever displayed.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*/
class MessageHandlingTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applyDebugModeToFile: (Boolean) -> Unit = {},
) {
/** Drives both category flags, as the single JavaFX checkbox did. */
var autoAnswerEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerEnabled()
set(value) {
prefs.setMessageHandling_autoAnswerEnabled(value)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
}
var autoAnswerEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerEnabled() },
) { enabled ->
prefs.setMessageHandling_autoAnswerEnabled(enabled)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
}
/** Drives both category texts, as the single JavaFX text field did. */
var autoAnswerText: String
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
set(value) {
prefs.setMessageHandling_autoAnswerTextMainCat(value)
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
}
var autoAnswerText: String by draft.setting(
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
) { text ->
prefs.setMessageHandling_autoAnswerTextMainCat(text)
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
}
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
var autoAnswerToQRGRequestEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
/**
* Records the whole session to file. Beyond the preference it opens or closes the
* error-log handler and the history recorder, which only the application can sequence;
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
*/
var debugModeToFileEnabled: Boolean
get() = prefs.isMessageHandling_debugModeToFileEnabled()
set(value) {
prefs.setMessageHandling_debugModeToFileEnabled(value)
applyDebugModeToFile(value)
}
var debugModeToFileEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_debugModeToFileEnabled() },
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
}
@@ -10,121 +10,129 @@ import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import java.util.Locale
/**
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
* server and the QSO monitoring list.
*
* Three of these reach past the preferences into the running session, which the
* settings coverage test cannot see, so they are constructor callbacks rather than
* plain properties: starting and stopping the server, restarting it after a port
* change, and sending the test spot.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them; the monitoring list too. The test spot is an action and is sent at once.
*
* Two of these reach past the preferences into the running session, which the settings
* coverage test cannot see, so they are constructor callbacks rather than plain
* properties: bringing the server in line with its switch and port, and sending the test
* spot.
*
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
* it lives on the controller, not in the preferences, which is why it is
* passed separately.
* @param applyDxClusterServerEnabled starts or stops the server, the work the
* JavaFX checkbox did beyond writing the flag.
* @param applyDxClusterServerPort restarts the server; called only when the port
* actually changed, as the JavaFX field did.
* @param applyDxClusterServer brings the running server in line with the applied switch
* and port: restarted while enabled, stopped otherwise. The switch and the port
* share it, so one apply restarts the server once, on the new port.
* @param broadcastTestSpot delivers the spot and returns null on success or the
* reason it could not be delivered.
*/
class NotificationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val monitoredRoster: SimpleRoster<String>,
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
private val applyDxClusterServerPort: (Int) -> Unit,
private val applyDxClusterServer: () -> Unit,
private val broadcastTestSpot: (ChatMember) -> String?,
) {
var notify_playSimpleSounds: Boolean
get() = prefs.isNotify_playSimpleSounds()
set(value) { prefs.setNotify_playSimpleSounds(value) }
var notify_playSimpleSounds: Boolean by draft.setting(
{ prefs.isNotify_playSimpleSounds() },
) { prefs.setNotify_playSimpleSounds(it) }
var notify_playCWCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
var notify_bandUpgradeHintOnLogEnabled: Boolean
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
var notify_bandUpgradePriorityBoostEnabled: Boolean
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
private var dxClusterServerEnabled: Boolean by draft.setting(
{ prefs.isNotify_dxClusterServerEnabled() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxClusterServerEnabled(it) }
private var dxClusterServerPort: Int by draft.setting(
{ prefs.getNotify_dxclusterServerPort() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxclusterServerPort(it) }
private var spotterCallSign: String by draft.setting(
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
val notify_dxClusterServerEnabled: Boolean
get() = prefs.isNotify_dxClusterServerEnabled()
get() = dxClusterServerEnabled
val notify_dxclusterServerPort: Int
get() = prefs.getNotify_dxclusterServerPort()
get() = dxClusterServerPort
val notify_DXCSrv_SpottersCallSign: String
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
get() = spotterCallSign
/**
* The band a spot falls back to when its frequency carries no band information.
*
* Stored as a prefix string, so an unreadable value is possible; reading it
* repairs the stored value to 144, which is what the JavaFX window did when it
* built the combo box (Kst4ContestApplication:11349).
/*
* Stored as a prefix string, so an unreadable value is possible. It is shown as 144 here
* and written back as 144 by repairStoredSettings before the window is built -- never
* from this read, which runs during composition.
*/
private var fallbackBand: Band by draft.setting(
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
/** The band a spot falls back to when its frequency carries no band information. */
var notify_optionalFrequencyPrefix: Band
get() {
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
if (stored == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
return Band.B_144
}
return stored
}
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
get() = fallbackBand
set(value) { fallbackBand = value }
val monitoredCallSigns = EditableListState(
monitoredRoster.snapshot(),
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
)
/** Writes the flag and lets the running session start or stop the server. */
/** Stages the server switch; applying it starts or stops the server. */
fun enableDxClusterServer(enabled: Boolean) {
prefs.setNotify_dxClusterServerEnabled(enabled)
applyDxClusterServerEnabled(enabled)
dxClusterServerEnabled = enabled
}
/**
* @return null when applied, otherwise the reason the port was refused.
* @return null when staged, otherwise the reason the port was refused.
*/
fun commitDxClusterServerPort(entered: String): String? {
val previousPort = prefs.getNotify_dxclusterServerPort()
val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) {
return CurrentStrings.get().notificationInvalidPort(entered.trim())
}
prefs.setNotify_dxclusterServerPort(port)
/*
* Restarting on an unchanged port would drop a connected logger for nothing;
* the JavaFX field guarded the restart the same way.
* The stored port staged again is no change, so it restarts nothing: restarting on
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
* guarded the restart the same way.
*/
if (port != previousPort) {
applyDxClusterServerPort(port)
}
dxClusterServerPort = port
return null
}
/**
* @return null when stored, otherwise the reason the callsign was refused.
* @return null when staged, otherwise the reason the callsign was refused.
*/
fun commitSpotterCallSign(entered: String): String? {
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
@@ -133,13 +141,12 @@ class NotificationTabState(
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
}
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
this.spotterCallSign = spotterCallSign
return null
}
/**
* Appends a callsign to the monitoring list and fills the roster at once, as the
* JavaFX dialog did.
* Appends a callsign to the monitoring list and stages the list.
*
* @return null when added, otherwise the reason it was refused.
*/
@@ -163,9 +170,14 @@ class NotificationTabState(
return null
}
/** Fills the monitoring roster from the list. */
/** Stages the list; applying it fills the monitoring roster. */
fun commitMonitoredCallSigns() {
monitoredCallSigns.commitTo(monitoredRoster)
draft.stage(
MONITORED_CALL_SIGNS,
monitoredCallSigns.texts.toList(),
live = { monitoredRoster.snapshot() },
write = { monitoredCallSigns.commitTo(monitoredRoster) },
)
}
/**
@@ -211,4 +223,11 @@ class NotificationTabState(
return CommitOutcome.Stored(baseCall)
}
private companion object {
/** The effect key the server switch and port share. */
const val DX_CLUSTER_SERVER = "dxClusterServer"
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
}
}
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.SettingsDraft
/**
* State of the shortcuts-and-snippets tab: the two single-column lists from
* `grdPnlShorts`.
*
* Both lists write through to their roster on every committed change. The JavaFX
* tables did the same — their Add buttons called `mutate` on the roster directly —
* and the shortcut buttons above the message field are rebuilt from the roster, so
* a list that only wrote on Save would leave those buttons stale.
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
* message field, the snippet menus -- once per apply, however many edits went before.
*
* @param refreshShortcutButtons rebuilds the button row above the message field,
* as `refreshShortcutButtons` did after every edit commit and move.
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
*/
class ShortcutsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val refreshShortcutButtons: () -> Unit,
private val refreshTextSnippetContextMenus: () -> Unit,
) {
@@ -27,16 +28,26 @@ class ShortcutsTabState(
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
/** Fills the shortcut roster and rebuilds the buttons built from it. */
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
fun commitShortcuts() {
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
refreshShortcutButtons()
draft.stage(
SHORTCUTS,
shortcuts.texts.toList(),
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
effect = refreshShortcutButtons,
)
}
/** Fills the snippet roster and rebuilds the context menus built from it. */
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
fun commitSnippets() {
snippets.commitTo(prefs.getLst_txtSnipList())
refreshTextSnippetContextMenus()
draft.stage(
SNIPPETS,
snippets.texts.toList(),
live = { prefs.getLst_txtSnipList().snapshot() },
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
effect = refreshTextSnippetContextMenus,
)
}
companion object {
@@ -47,5 +58,8 @@ class ShortcutsTabState(
*/
const val NEW_ENTRY_TEMPLATE =
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
private const val SNIPPETS = "ShortcutsTabState.snippets"
}
}
@@ -11,8 +11,8 @@ import kst4contest.view.compose.Form
/**
* The Station tab: own station, server, active bands and path-analysis defaults.
*
* No confirmation callback: StationTabState writes through to ChatPreferences, the
* way the JavaFX controls did. "Save settings" persists, it does not collect.
* The controls stage their edits in the window's draft (see StationTabState); only the
* login credentials write through at once.
*/
@Composable
fun StationTab(state: StationTabState) {
@@ -3,23 +3,25 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Station tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
* change behaviour an operator may rely on: today a changed setting takes effect
* in the running session at once, whether or not it is ever written to disk.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
* and still write through at once: callsign, names, locator, password and both chat
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
* one they take effect at the next login anyway, so holding them back would only make
* Connect use something other than what the tab shows.
*
* The property list was derived mechanically from the setters the JavaFX tab
* called, intersected with the setters ChatPreferences declares.
*/
class StationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
/**
* Applies the chat category to the running session. Passed in rather than taken
* from a ChatController so the rules stay testable without building a controller,
@@ -43,45 +45,25 @@ class StationTabState(
get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double
get() = prefs.getStn_antennaBeamWidthDeg()
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
var stn_bandActive10G: Boolean
get() = prefs.isStn_bandActive10G()
set(value) { prefs.setStn_bandActive10G(value) }
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
var stn_bandActive1240: Boolean
get() = prefs.isStn_bandActive1240()
set(value) { prefs.setStn_bandActive1240(value) }
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
var stn_bandActive144: Boolean
get() = prefs.isStn_bandActive144()
set(value) { prefs.setStn_bandActive144(value) }
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
var stn_bandActive2300: Boolean
get() = prefs.isStn_bandActive2300()
set(value) { prefs.setStn_bandActive2300(value) }
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
var stn_bandActive3400: Boolean
get() = prefs.isStn_bandActive3400()
set(value) { prefs.setStn_bandActive3400(value) }
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
var stn_bandActive432: Boolean
get() = prefs.isStn_bandActive432()
set(value) { prefs.setStn_bandActive432(value) }
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
var stn_bandActive50: Boolean
get() = prefs.isStn_bandActive50()
set(value) { prefs.setStn_bandActive50(value) }
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
var stn_bandActive5600: Boolean
get() = prefs.isStn_bandActive5600()
set(value) { prefs.setStn_bandActive5600(value) }
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
var stn_bandActive70: Boolean
get() = prefs.isStn_bandActive70()
set(value) { prefs.setStn_bandActive70(value) }
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign()
@@ -103,57 +85,31 @@ class StationTabState(
get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double
get() = prefs.getStn_maxQRBDefault()
set(value) { prefs.setStn_maxQRBDefault(value) }
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
var stn_on4kstServersDns: String
get() = prefs.getStn_on4kstServersDns()
set(value) { prefs.setStn_on4kstServersDns(value) }
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
var stn_on4kstServersPort: Int
get() = prefs.getStn_on4kstServersPort()
set(value) { prefs.setStn_on4kstServersPort(value) }
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
var stn_pathAnalysisDemRootDirectory: String
get() = prefs.getStn_pathAnalysisDemRootDirectory()
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
var stn_pathAnalysisOwnAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
var stn_pathAnalysisOwnTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
var stn_pstRotatorEnabled: Boolean
get() = prefs.isStn_pstRotatorEnabled()
set(value) { prefs.setStn_pstRotatorEnabled(value) }
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
var stn_pstRotatorHost: String
get() = prefs.getStn_pstRotatorHost()
set(value) { prefs.setStn_pstRotatorHost(value) }
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
var stn_pstRotatorPort: Int
get() = prefs.getStn_pstRotatorPort()
set(value) { prefs.setStn_pstRotatorPort(value) }
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { prefs.setStn_pstRotatorPort(it) }
var stn_qtfDefault: Double
get() = prefs.getStn_qtfDefault()
set(value) { prefs.setStn_qtfDefault(value) }
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
/**
* The chat category is held twice: in ChatPreferences and in ChatController.
@@ -170,9 +126,7 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean
get() = prefs.isLoginToSecondChatEnabled()
set(value) { prefs.setLoginToSecondChatEnabled(value) }
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
@@ -0,0 +1,41 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
/**
* Corrects stored settings the settings window cannot show as they are, once, before the
* window is built.
*
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
* did while building its panes. They used to happen inside the tab states -- one during a
* read in composition, one in a constructor -- where a write to the preferences is easy to
* miss and runs whenever Compose decides to read. Here they run at one known moment.
*
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
* while it is still the catch-all default 255.255.255.255. An address the operator or a
* previous detection chose is left alone, and a failed detection keeps the default:
* guessing an address would send SKED packets somewhere nobody is listening.
*
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
}
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (!detected.isNullOrBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
}
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current".
*
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did.
* The controls stage their edits in the window's draft (see TrxSynchTabState).
*/
@Composable
fun TrxSynchTab(state: TrxSynchTabState) {
@@ -1,12 +1,14 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The TRX synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*
* The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
@@ -19,33 +21,37 @@ import kst4contest.model.ChatPreferences
* cannot read a checked box as "my QRG is being kept current".
*
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller.
*/
class TrxSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applyOwnQrgFollower: (Boolean) -> Unit,
) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
set(value) {
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
var logsynch_wintestQrgSyncEnabled: Boolean
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
set(value) {
prefs.setLogsynch_wintestQrgSyncEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean
get() = prefs.isLogsynch_wintestUsePassQrg()
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
{ prefs.isLogsynch_wintestUsePassQrg() },
) { prefs.setLogsynch_wintestUsePassQrg(it) }
// Staged trimmed, so the draft holds what will be stored.
private var stationFilter: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
/**
* Win-Test station name filter. An empty filter accepts every station, which is
@@ -53,8 +59,13 @@ class TrxSynchTabState(
* way the JavaFX field committed it.
*/
var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
get() = stationFilter
set(value) { stationFilter = value.trim() }
/* Read from the stored values: it runs on apply, after both switches are written. */
private fun applyFollower() {
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
}
/**
* True when at least one source may update the own QRG. One source switched off is
@@ -74,5 +85,8 @@ class TrxSynchTabState(
* would read whichever one the tab happened to use.
*/
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
/** The effect key both QRG sources share. */
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
}
}
@@ -56,7 +56,7 @@ class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
// The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) })
@@ -0,0 +1,26 @@
package kst4contest.view.compose
/**
* A draft that applies every change the moment it is staged.
*
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
* an edit triggers -- without the draft in between. The draft itself is pinned by
* SettingsDraftTest, and that every drafted setting really waits for Apply by
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
* over one unnoticed.
*/
internal class ImmediateDraft : SettingsDraft() {
override fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String?,
effect: (() -> Unit)?,
) {
super.stage(key, value, live, write, effectKey, effect)
val outcome = apply()
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
}
}
@@ -0,0 +1,224 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The settings draft: edits wait in it, and only apply() writes them and runs their effects.
*/
class SettingsDraftTest {
/** A stored setting with a log of what happened to it. */
private class Stored(var value: Int, private val log: MutableList<String>, private val name: String) {
fun write(v: Int) {
value = v
log += "write $name=$v"
}
}
@Test
fun `a staged value is shown but not written`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write)
assertEquals(9872, draft.valueOf("port") { port.value })
assertEquals(9871, port.value, "a staged value reached the stored setting")
assertTrue(draft.hasPendingChanges)
assertTrue(log.isEmpty())
}
@Test
fun `an unstaged key reads the stored value`() {
val draft = SettingsDraft()
assertEquals(42, draft.valueOf("anything") { 42 })
assertFalse(draft.hasPendingChanges)
}
@Test
fun `apply writes and runs the effect, then nothing is pending`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.apply()
assertEquals(9872, port.value)
assertEquals(listOf("write port=9872", "restart"), log)
assertFalse(draft.hasPendingChanges)
}
@Test
fun `discard writes nothing and runs nothing`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.discard()
assertEquals(9871, port.value)
assertTrue(log.isEmpty())
assertFalse(draft.hasPendingChanges)
assertEquals(9871, draft.valueOf("port") { port.value })
}
@Test
fun `staging the stored value again leaves nothing pending`() {
val log = mutableListOf<String>()
val flag = Stored(0, log, "flag")
val draft = SettingsDraft()
draft.stage("flag", 1, live = { flag.value }, write = flag::write, effect = { log += "effect" })
draft.stage("flag", 0, live = { flag.value }, write = flag::write, effect = { log += "effect" })
assertFalse(draft.hasPendingChanges, "a box ticked and unticked again is no change")
draft.apply()
assertTrue(log.isEmpty(), "apply ran something for a setting that did not change")
}
@Test
fun `the last staged value of a key wins`() {
val log = mutableListOf<String>()
val port = Stored(1, log, "port")
val draft = SettingsDraft()
draft.stage("port", 2, live = { port.value }, write = port::write)
draft.stage("port", 3, live = { port.value }, write = port::write)
draft.apply()
assertEquals(listOf("write port=3"), log, "one write per setting, with the final value")
}
/*
* Every write lands before the first effect, so an effect sees the finished state: a
* listener restarted between "enabled" and "port" would bind the old port.
*/
@Test
fun `all writes come before any effect, in the order first changed`() {
val log = mutableListOf<String>()
val a = Stored(0, log, "a")
val b = Stored(0, log, "b")
val draft = SettingsDraft()
draft.stage("b", 1, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.stage("a", 1, live = { a.value }, write = a::write, effect = { log += "effect a" })
draft.stage("b", 2, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.apply()
assertEquals(listOf("write b=2", "write a=1", "effect b", "effect a"), log)
}
@Test
fun `settings sharing an effect key run that effect once`() {
val log = mutableListOf<String>()
val enabled = Stored(0, log, "enabled")
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("enabled", 1, live = { enabled.value }, write = enabled::write,
effectKey = "listener", effect = { log += "restart" })
draft.stage("port", 9872, live = { port.value }, write = port::write,
effectKey = "listener", effect = { log += "restart" })
draft.apply()
assertEquals(listOf("write enabled=1", "write port=9872", "restart"), log)
}
@Test
fun `a failing effect is reported and the other effects still run`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var a = 0
var b = 0
draft.stage("a", 1, live = { a }, write = { a = it }, effect = { error("socket refused") })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "timer" })
val outcome = draft.apply()
assertEquals(1, a, "the write before the failing effect happened")
assertEquals(listOf("timer"), log, "one failing effect stopped the others")
assertEquals(listOf("a: socket refused"), outcome.failedEffects)
assertTrue(outcome.failedWrites.isEmpty())
assertFalse(draft.hasPendingChanges, "a stored setting would be applied a second time")
}
@Test
fun `a failing write stays pending, skips its effect, and the rest is applied`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var b = 0
draft.stage("a", 1, live = { 0 }, write = { error("disk full") }, effect = { log += "effect a" })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "effect b" })
val outcome = draft.apply()
assertEquals(1, b)
assertEquals(listOf("effect b"), log, "an effect ran for a value that was never stored")
assertEquals(listOf("a: disk full"), outcome.failedWrites)
assertFalse(outcome.isClean)
assertTrue(draft.hasPendingChanges, "the failed setting was dropped")
assertEquals(1, draft.valueOf("a") { 0 })
}
private class TwoSettings(draft: SettingsDraft, val log: MutableList<String>) {
var storedA = 1
var storedB = 1
var a: Int by draft.setting({ storedA }) { storedA = it; log += "a=$it" }
var b: Int by draft.setting({ storedB }, effect = { log += "effect b" }) { storedB = it; log += "b=$it" }
}
@Test
fun `a drafted property reads through the draft and stages its writes`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.a = 7
assertEquals(7, tab.a)
assertEquals(1, tab.storedA)
assertEquals(1, tab.b, "staging one property touched another")
draft.apply()
assertEquals(7, tab.storedA)
assertEquals(listOf("a=7"), tab.log)
}
@Test
fun `a drafted property carries its effect`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.b = 3
draft.apply()
assertEquals(listOf("b=3", "effect b"), tab.log)
}
@Test
fun `typed entries are committed on request and report their refusals`() {
val draft = SettingsDraft()
var stored = 0
val entry = draft.registerTypedEntry {
draft.stage("k", 5, live = { stored }, write = { stored = it })
null
}
draft.registerTypedEntry { "Port: \"98x\" is not a valid entry." }
val refusals = draft.commitTypedEntries()
assertEquals(listOf("Port: \"98x\" is not a valid entry."), refusals)
assertTrue(draft.hasPendingChanges, "the accepted entry was not staged")
assertEquals(0, stored, "committing an entry applied it")
draft.discard()
entry.close()
draft.commitTypedEntries()
assertFalse(draft.hasPendingChanges, "a removed field was still committed")
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -10,9 +12,13 @@ import java.nio.file.Path
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* The rules below are pinned with a draft that applies at once; what the draft itself
* changes is pinned by the two tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state by lazy { AirscoutTabState(prefs) }
val state by lazy { AirscoutTabState(prefs, draft) }
}
@Test
@@ -34,7 +40,39 @@ class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
fun `an edit waits for apply`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asCommunicationPort(9872)
s.state.airScout_asCommunicationPort = 9873
s.state.airScout_asServerNameString = " AS3 "
assertEquals(9872, s.prefs.getAirScout_asCommunicationPort(), "an edit took effect before Apply")
assertEquals(9873, s.state.airScout_asCommunicationPort, "the tab shows the edit")
assertEquals("AS3", s.state.airScout_asServerNameString, "the draft holds the trimmed value")
s.draft.apply()
assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
assertEquals("AS3", s.prefs.getAirScout_asServerNameString())
}
@Test
fun `a discarded edit changes nothing, and an invalid one never enters the draft`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asBandString("1440000")
s.state.airScout_asBandString = "-1"
assertFalse(s.draft.hasPendingChanges, "a refused value reached the draft")
s.state.airScout_asBandString = "4320000"
s.draft.discard()
assertEquals("1440000", s.prefs.getAirScout_asBandString())
}
@Test
fun `writing reaches the preferences on apply`() {
val s = Session()
s.state.airScout_asUDPListenerEnabled = true
@@ -2,6 +2,8 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -13,13 +15,21 @@ import java.nio.file.Path
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session(private val templateRefusal: String? = null) {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(
private val templateRefusal: String? = null,
val draft: SettingsDraft = ImmediateDraft(),
) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var timerRestarts = 0
var validated = mutableListOf<String>()
fun state() = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template ->
validated.add(template)
templateRefusal?.let { throw IllegalArgumentException(it) }
@@ -31,7 +41,46 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the enable flags write through at once`() {
fun `beacon edits wait for apply, and the timer restarts only then`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
assertEquals("CQ MYCALL", state.beaconTextMainCat)
s.draft.apply()
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
assertEquals(1, s.timerRestarts)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
val state = s.state()
state.commitBeaconTextMainCat("CQ MYCALL MYQRG")
state.commitBeaconInterval("9")
s.draft.discard()
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(0, s.timerRestarts)
}
@Test
fun `the enable flags reach the preferences`() {
val s = Session()
val state = s.state()
@@ -0,0 +1,221 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.Locale
/**
* Every drafted setting of every tab, with a real [SettingsDraft]: unchanged in the
* preferences after the edit, changed after apply, unchanged after discard.
*
* The tab tests pin each tab's rules with a draft that applies at once, so they cannot tell
* a setting that goes through the draft from one that writes ChatPreferences directly. This
* table can, setter by setter. A setting added to a tab belongs in it; one that is meant to
* stay immediate (login credentials, language, colours, actions) belongs in the spec instead.
*/
class DraftedSettingsTableTest {
@TempDir
lateinit var directory: Path
/** All drafted tabs over one preferences file and one draft. */
private class Fixture(val prefs: ChatPreferences, val monitored: SimpleRoster<String> = SimpleRoster()) {
val draft = SettingsDraft()
val station = StationTabState(prefs, draft, { }, { })
val logSynch = LogSynchTabState(prefs, draft, { }, { })
val trx = TrxSynchTabState(prefs, draft) { }
val airscout = AirscoutTabState(prefs, draft)
val notification = NotificationTabState(prefs, draft, monitored, { }, { null })
val shortcuts = ShortcutsTabState(prefs, draft, { }, { })
val beacon = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
val messages = MessageHandlingTabState(prefs, draft) { }
val gui = GuiOptionsTabState(prefs, draft, LanguageStore({ "en" }, { }, Locale.UK))
}
/** One drafted setting: how the tab changes it, and how the stored value is read. */
private class Case(val name: String, val change: (Fixture) -> Unit, val stored: (Fixture) -> Any?)
private fun flag(name: String, read: (Fixture) -> Boolean, write: (Fixture, Boolean) -> Unit) =
Case(name, { f -> write(f, !read(f)) }, read)
private fun cases(): List<Case> = listOf(
// Station, all but the login credentials
Case("station beam width", { it.station.stn_antennaBeamWidthDeg += 1.5 }, { it.prefs.getStn_antennaBeamWidthDeg() }),
flag("station 10G", { it.station.stn_bandActive10G }) { f, v -> f.station.stn_bandActive10G = v },
flag("station 1240", { it.station.stn_bandActive1240 }) { f, v -> f.station.stn_bandActive1240 = v },
flag("station 144", { it.station.stn_bandActive144 }) { f, v -> f.station.stn_bandActive144 = v },
flag("station 2300", { it.station.stn_bandActive2300 }) { f, v -> f.station.stn_bandActive2300 = v },
flag("station 3400", { it.station.stn_bandActive3400 }) { f, v -> f.station.stn_bandActive3400 = v },
flag("station 432", { it.station.stn_bandActive432 }) { f, v -> f.station.stn_bandActive432 = v },
flag("station 50", { it.station.stn_bandActive50 }) { f, v -> f.station.stn_bandActive50 = v },
flag("station 5600", { it.station.stn_bandActive5600 }) { f, v -> f.station.stn_bandActive5600 = v },
flag("station 70", { it.station.stn_bandActive70 }) { f, v -> f.station.stn_bandActive70 = v },
Case("station max QRB", { it.station.stn_maxQRBDefault += 1.5 }, { it.prefs.getStn_maxQRBDefault() }),
Case("station server host", { it.station.stn_on4kstServersDns += "x" }, { it.prefs.getStn_on4kstServersDns() }),
Case("station server port", { it.station.stn_on4kstServersPort += 1 }, { it.prefs.getStn_on4kstServersPort() }),
Case("station target gain", { it.station.stn_pathAnalysisDefaultTargetAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }),
Case("station target power", { it.station.stn_pathAnalysisDefaultTargetTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }),
Case("station DEM directory", { it.station.stn_pathAnalysisDemRootDirectory += "x" },
{ it.prefs.getStn_pathAnalysisDemRootDirectory() }),
Case("station own gain", { it.station.stn_pathAnalysisOwnAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaGainDbi() }),
Case("station own height", { it.station.stn_pathAnalysisOwnAntennaHeightMeters += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
flag("station second chat", { it.station.loginToSecondChatEnabled }) { f, v -> f.station.loginToSecondChatEnabled = v },
// Log synch, all but the file chooser
flag("log file polling", { it.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled }) { f, v ->
f.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled = v
},
flag("log UCX listener", { it.logSynch.logsynch_ucxUDPWkdCallListenerEnabled }) { f, v ->
f.logSynch.logsynch_ucxUDPWkdCallListenerEnabled = v
},
Case("log UCX port", { it.logSynch.logsynch_ucxUDPWkdCallListenerPort += 1 },
{ it.prefs.getLogsynch_ucxUDPWkdCallListenerPort() }),
flag("log Win-Test listener", { it.logSynch.logsynch_wintestNetworkListenerEnabled }) { f, v ->
f.logSynch.logsynch_wintestNetworkListenerEnabled = v
},
Case("log Win-Test port", { it.logSynch.logsynch_wintestNetworkPort += 1 }, { it.prefs.getLogsynch_wintestNetworkPort() }),
Case("log Win-Test station name", { it.logSynch.logsynch_wintestNetworkStationNameOfKST += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfKST() }),
Case("log Win-Test broadcast", { it.logSynch.logsynch_wintestNetworkBroadcastAddress = "10.9.8.255" },
{ it.prefs.getLogsynch_wintestNetworkBroadcastAddress() }),
// TRX synch
flag("trx UCX QRG", { it.trx.trxSynch_ucxLogUDPListenerEnabled }) { f, v -> f.trx.trxSynch_ucxLogUDPListenerEnabled = v },
flag("trx Win-Test QRG", { it.trx.logsynch_wintestQrgSyncEnabled }) { f, v -> f.trx.logsynch_wintestQrgSyncEnabled = v },
flag("trx pass QRG", { it.trx.logsynch_wintestUsePassQrg }) { f, v -> f.trx.logsynch_wintestUsePassQrg = v },
Case("trx station filter", { it.trx.logsynch_wintestNetworkStationNameOfWintestClient1 += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() }),
// Airscout
flag("airscout listener", { it.airscout.airScout_asUDPListenerEnabled }) { f, v -> f.airscout.airScout_asUDPListenerEnabled = v },
Case("airscout server", { it.airscout.airScout_asServerNameString += "X" }, { it.prefs.getAirScout_asServerNameString() }),
Case("airscout client", { it.airscout.airScout_asClientNameString += "X" }, { it.prefs.getAirScout_asClientNameString() }),
Case("airscout port", { it.airscout.airScout_asCommunicationPort += 1 }, { it.prefs.getAirScout_asCommunicationPort() }),
flag("airscout auto band", { it.airscout.airScout_autoBandSelectionEnabled }) { f, v ->
f.airscout.airScout_autoBandSelectionEnabled = v
},
Case("airscout band", { f -> f.airscout.airScout_asBandString = ((f.airscout.airScout_asBandString.toLong()) + 1).toString() },
{ it.prefs.getAirScout_asBandString() }),
// Notification, all but the test spot
flag("sound simple", { it.notification.notify_playSimpleSounds }) { f, v -> f.notification.notify_playSimpleSounds = v },
flag("sound CW", { it.notification.notify_playCWCallsignsOnRxedPMs }) { f, v -> f.notification.notify_playCWCallsignsOnRxedPMs = v },
flag("sound voice", { it.notification.notify_playVoiceCallsignsOnRxedPMs }) { f, v ->
f.notification.notify_playVoiceCallsignsOnRxedPMs = v
},
flag("band upgrade hint", { it.notification.notify_bandUpgradeHintOnLogEnabled }) { f, v ->
f.notification.notify_bandUpgradeHintOnLogEnabled = v
},
flag("band upgrade boost", { it.notification.notify_bandUpgradePriorityBoostEnabled }) { f, v ->
f.notification.notify_bandUpgradePriorityBoostEnabled = v
},
Case("cluster server", { f -> f.notification.enableDxClusterServer(!f.notification.notify_dxClusterServerEnabled) },
{ it.prefs.isNotify_dxClusterServerEnabled() }),
Case("cluster port", { f -> f.notification.commitDxClusterServerPort((f.notification.notify_dxclusterServerPort + 1).toString()) },
{ it.prefs.getNotify_dxclusterServerPort() }),
Case("cluster spotter", { f ->
f.notification.commitSpotterCallSign(if (f.notification.notify_DXCSrv_SpottersCallSign == "DL1ABC") "DO5AMF" else "DL1ABC")
}, { it.prefs.getNotify_DXCSrv_SpottersCallSign().get() }),
Case("cluster fallback band", { f ->
f.notification.notify_optionalFrequencyPrefix =
if (f.notification.notify_optionalFrequencyPrefix == Band.B_432) Band.B_144 else Band.B_432
}, { it.prefs.getNotify_optionalFrequencyPrefix().get() }),
Case("monitoring list", { it.notification.addMonitoredCallSign("DL1ABC") }, { it.monitored.snapshot() }),
// Shortcuts
Case("shortcut buttons", { f ->
f.shortcuts.shortcuts.addAtTop("cq")
f.shortcuts.commitShortcuts()
}, { it.prefs.getLst_txtShortCutBtnList().snapshot() }),
Case("text snippets", { f ->
f.shortcuts.snippets.addAtTop("73")
f.shortcuts.commitSnippets()
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
{ it.prefs.getBcn_beaconTextSecondCat() }),
Case("beacon interval", { it.beacon.commitBeaconInterval((it.beacon.beaconIntervalMinutes + 1).toString()) },
{ it.prefs.getBcn_beaconIntervalInMinutesMainCat() }),
// Message handling
flag("auto answer", { it.messages.autoAnswerEnabled }) { f, v -> f.messages.autoAnswerEnabled = v },
Case("auto answer text", { it.messages.autoAnswerText += " x" }, { it.prefs.getMessageHandling_autoAnswerTextMainCat() }),
flag("QRG answer", { it.messages.autoAnswerToQRGRequestEnabled }) { f, v -> f.messages.autoAnswerToQRGRequestEnabled = v },
flag("debug to file", { it.messages.debugModeToFileEnabled }) { f, v -> f.messages.debugModeToFileEnabled = v },
// GUI, all but the language
flag("gui dark by default", { it.gui.gUI_darkModeActiveByDefault }) { f, v -> f.gui.gUI_darkModeActiveByDefault = v },
flag("gui filter nothing", { it.gui.guiOptions_defaultFilterNothing }) { f, v -> f.gui.guiOptions_defaultFilterNothing = v },
flag("gui filter pm to me", { it.gui.guiOptions_defaultFilterPmToMe }) { f, v -> f.gui.guiOptions_defaultFilterPmToMe = v },
flag("gui filter pm to other", { it.gui.guiOptions_defaultFilterPmToOther }) { f, v ->
f.gui.guiOptions_defaultFilterPmToOther = v
},
flag("gui filter public", { it.gui.guiOptions_defaultFilterPublicMsgs }) { f, v -> f.gui.guiOptions_defaultFilterPublicMsgs = v },
flag("gui fresh call hint", { it.gui.guiOptions_showFreshCallHintInBandColumns }) { f, v ->
f.gui.guiOptions_showFreshCallHintInBandColumns = v
},
flag("gui gross field hint", { it.gui.guiOptions_showGrossFieldWorkedHintInBandColumns }) { f, v ->
f.gui.guiOptions_showGrossFieldWorkedHintInBandColumns = v
},
)
@TestFactory
fun everyDraftedSettingWaitsForApply(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
assertEquals(before, storedNow(case, f), "${case.name} took effect before Apply")
f.draft.apply()
assertNotEquals(before, storedNow(case, f), "${case.name} did not take effect with Apply")
}
}
@TestFactory
fun everyDraftedSettingIsLeftAloneByDiscard(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
f.draft.discard()
assertEquals(before, storedNow(case, f), "${case.name} changed although discarded")
}
}
/*
* The stored value, read past the draft: a flag case reads through the tab, which shows
* the draft, so it reads from tab states over the same preferences with a draft of their own.
*/
private fun storedNow(case: Case, f: Fixture): Any? = case.stored(Fixture(f.prefs, f.monitored))
private fun fresh(): Path = Files.createTempDirectory(directory, "case")
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LanguageStoreFactory
import kst4contest.view.i18n.SYSTEM_LANGUAGE
@@ -36,12 +38,14 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
assertEquals("de", state.language)
assertEquals("de", prefs.guiOptions_language)
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
assertFalse(draft.hasPendingChanges)
}
@@ -50,16 +54,29 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
}
@Test
fun `writing reaches the preferences at once`() {
fun `an edit waits for apply, and discarding drops it`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, languageStore())
state.gUI_darkModeActiveByDefault = !prefs.isGUI_darkModeActiveByDefault()
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
assertFalse(prefs.isGuiOptions_defaultFilterPublicMsgs(), "an edit took effect before Apply")
assertTrue(state.guiOptions_defaultFilterPublicMsgs, "the tab shows the edit")
val darkBefore = prefs.isGUI_darkModeActiveByDefault()
draft.discard()
assertEquals(darkBefore, prefs.isGUI_darkModeActiveByDefault())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
draft.apply()
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
@@ -71,7 +88,7 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, languageStore())
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_defaultFilterPmToMe = true
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -54,7 +55,7 @@ class ListTabsScreenTest {
}
var shortcutRefreshes = 0
var snippetRefreshes = 0
val state = ShortcutsTabState(prefs, { shortcutRefreshes++ }, { snippetRefreshes++ })
val state = ShortcutsTabState(prefs, ImmediateDraft(), { shortcutRefreshes++ }, { snippetRefreshes++ })
}
@Test
@@ -1,6 +1,7 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -10,17 +11,17 @@ import java.nio.file.Path
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session(detected: String? = null) {
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var restarts = 0
var stops = 0
val detectedBroadcast = detected
val draft = SettingsDraft()
fun state() = LogSynchTabState(
prefs,
draft,
restartWintestListener = { restarts++ },
stopWintestListener = { stops++ },
detectWintestBroadcastAddress = { detectedBroadcast },
)
}
@@ -44,23 +45,44 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
fun `an edit waits for apply, the chosen log file does not`() {
val s = Session()
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(false)
val state = s.state()
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
state.logsynch_ucxUDPWkdCallListenerEnabled = true
state.logsynch_ucxUDPWkdCallListenerPort = 12060
state.logsynch_wintestNetworkPort = 9871
state.logsynch_ucxUDPWkdCallListenerPort = 12061
state.logsynch_wintestNetworkPort = 9879
// The file chooser is an action: the chosen path is written when chosen.
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertFalse(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled(), "an edit took effect before Apply")
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(12061, state.logsynch_ucxUDPWkdCallListenerPort, "the tab shows the edit")
s.draft.apply()
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(12061, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
assertEquals(9879, s.prefs.getLogsynch_wintestNetworkPort())
}
@Test
fun `a discarded edit changes nothing and touches no listener`() {
val s = Session()
val state = s.state()
val enabled = s.prefs.isLogsynch_wintestNetworkListenerEnabled()
state.logsynch_wintestNetworkListenerEnabled = !enabled
state.logsynch_wintestNetworkPort = 9879
s.draft.discard()
assertEquals(enabled, s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(0, s.restarts + s.stops)
}
@Test
@@ -68,91 +90,84 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
// The JavaFX fields committed getText().trim(); a stray space in a station
// name or a broadcast address is a protocol value, not decoration.
val s = Session()
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
val state = s.state()
state.logsynch_wintestNetworkStationNameOfKST = " KST "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
state.logsynch_wintestNetworkStationNameOfKST = "KST "
assertFalse(s.draft.hasPendingChanges, "a trailing space is no change")
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
state.logsynch_wintestNetworkStationNameOfKST = " KST2 "
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
s.draft.apply()
assertEquals("KST2", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `enabling the Win-Test listener starts it and disabling stops it`() {
fun `applying the Win-Test switch starts or stops the listener`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkListenerEnabled = true
assertEquals(0, s.restarts, "the listener followed the box before Apply")
s.draft.apply()
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.restarts, "the running listener must follow the setting")
state.logsynch_wintestNetworkListenerEnabled = false
s.draft.apply()
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
assertEquals(1, s.stops)
}
@Test
fun `a committed port change rebinds the listener only while it is enabled`() {
// Deliberately not on every keystroke: the port write goes through at once,
// but rebinding a UDP socket is bound to the commit, as the JavaFX field's
// focus-lost handler did.
fun `an applied port change rebinds the listener only while it is enabled`() {
val s = Session()
val state = s.state()
/*
* Switched off explicitly: ChatPreferences ships
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
* a fresh configuration already listens.
*/
state.logsynch_wintestNetworkListenerEnabled = false
val restartsAfterDisable = s.restarts
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
val state = s.state()
state.logsynch_wintestNetworkPort = 9872
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(0, s.restarts, "a disabled listener has nothing to rebind")
state.commitWintestNetworkPort()
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
state.logsynch_wintestNetworkListenerEnabled = true
val restartsAfterEnable = s.restarts
s.prefs.setLogsynch_wintestNetworkListenerEnabled(true)
state.logsynch_wintestNetworkPort = 9873
state.commitWintestNetworkPort()
assertEquals(0, s.restarts, "a keystroke must not rebind the socket")
s.draft.apply()
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
assertEquals(restartsAfterEnable + 1, s.restarts)
assertEquals(1, s.restarts)
}
/*
* Switch and port in one apply: one restart, on the new port. Two restarts in a row can
* fail on the port the first has not released yet.
*/
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
fun `switch and port applied together rebind once, on the new port`() {
val s = Session()
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
var portAtRestart = -1
val state = LogSynchTabState(
s.prefs, s.draft,
restartWintestListener = { s.restarts++; portAtRestart = s.prefs.getLogsynch_wintestNetworkPort() },
stopWintestListener = { s.stops++ },
)
s.state()
state.logsynch_wintestNetworkListenerEnabled = true
state.logsynch_wintestNetworkPort = 9874
s.draft.apply()
assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
"the JavaFX window auto-detected the subnet broadcast while the stored "
+ "value was still the catch-all default")
}
@Test
fun `a broadcast address the operator chose is never overwritten`() {
val s = Session(detected = "192.168.178.255")
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
s.state()
assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val s = Session(detected = null)
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
s.state()
assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
assertEquals(1, s.restarts)
assertEquals(9874, portAtRestart)
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -17,19 +19,53 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
assertTrue(state.autoAnswerEnabled)
assertTrue(state.autoAnswerToQRGRequestEnabled)
assertEquals("qrv 144.300", state.autoAnswerText)
}
@Test
fun `an edit waits for apply, and the recording follows only then`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs, draft) { recording += it }
state.autoAnswerText = "qrv 432.200"
state.debugModeToFileEnabled = !prefs.isMessageHandling_debugModeToFileEnabled()
val wanted = state.debugModeToFileEnabled
assertEquals("qrv 432.200", state.autoAnswerText, "the tab shows the edit")
assertTrue(recording.isEmpty(), "the recording followed before Apply")
draft.apply()
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
assertEquals(listOf(wanted), recording)
}
@Test
fun `a discarded edit changes nothing`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
prefs.setMessageHandling_autoAnswerEnabled(false)
val draft = SettingsDraft()
val state = MessageHandlingTabState(prefs, draft)
state.autoAnswerEnabled = true
draft.discard()
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
assertFalse(state.autoAnswerEnabled)
}
@Test
fun `one checkbox drives both category flags`() {
// The JavaFX window offered a single checkbox and wrote both preferences from
// it; keeping only the main one would leave the second chat on its old value.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerEnabled = true
@@ -45,7 +81,7 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `one text field drives both category texts`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerText = "qrv 432.200"
@@ -54,9 +90,9 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the QRG reply flag writes through on its own`() {
fun `the QRG reply flag is stored on its own`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = MessageHandlingTabState(prefs)
val state = MessageHandlingTabState(prefs, ImmediateDraft())
state.autoAnswerToQRGRequestEnabled = true
@@ -15,6 +15,8 @@ import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.observe.SimpleRoster
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
@@ -60,7 +62,7 @@ class MessagingTabsScreenTest {
isMessageHandling_debugModeToFileEnabled = false
}
val recording = mutableListOf<Boolean>()
val state = MessageHandlingTabState(prefs) { recording += it }
val state = MessageHandlingTabState(prefs, ImmediateDraft()) { recording += it }
showTab { MessageHandlingTab(state) }
onNodeWithText("Automatic answer to private messages").assertExists()
@@ -87,7 +89,7 @@ class MessagingTabsScreenTest {
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
var storedLanguage = "en"
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
val state = GuiOptionsTabState(prefs, languages)
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languages)
showTab { GuiOptionsTab(state) }
onNodeWithText("Startup design").assertExists()
@@ -117,11 +119,21 @@ class MessagingTabsScreenTest {
// ------------------------------------------------------------------ Beacon
private class Beacon(val prefs: ChatPreferences, val refuse: Boolean = false) {
/*
* A draft that applies at once by default, so the tests below keep pinning the field's
* commit rules (#128) against the stored value; the drafted behaviour is pinned by the
* tests that pass a real draft.
*/
private class Beacon(
val prefs: ChatPreferences,
val refuse: Boolean = false,
val draft: SettingsDraft = ImmediateDraft(),
) {
var restarts = 0
val refusals = mutableListOf<String>()
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = { restarts++ },
mainCategoryName = "Microwave",
@@ -226,15 +238,15 @@ class MessagingTabsScreenTest {
}
/*
* The stored value can change under a field without the field's own key changing: the
* field commits 9 over 5, nothing recomposes the tab, then a profile switch puts 5 back
* and the tab recomposes with 5 -- the same `stored` it was first drawn with. The field
* still shows 9, so leaving it must commit 9 again; judged against what the field itself
* last committed, it looked unchanged and the shown value silently differed from the
* stored one.
* The stored value can change under a field -- a profile switch puts 5 back after the field
* committed 9. The field used to keep showing 9 then, because nothing recomposed the tab
* after a commit, and #128 made leaving it commit the shown 9 again. Since the edits go
* through the settings draft, which is Compose state, a commit recomposes the tab: the field
* follows the stored value, shows the 5, and leaving it commits nothing. Either way what the
* field shows is what is stored once the operator has left it.
*/
@Test
fun aValueResetBehindTheFieldIsCommittedAgainWhenTheFieldIsLeft() = runComposeUiTest {
fun aValueResetBehindTheFieldIsShownAndNotCommittedAgain() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
val generation = mutableStateOf(0)
@@ -251,13 +263,13 @@ class MessagingTabsScreenTest {
generation.value++
}
waitForIdle()
fieldBeside("Interval in whole minutes").assertTextEquals("9")
fieldBeside("Interval in whole minutes").assertTextEquals("5")
fieldBeside("Beacon message").performScrollTo().performClick()
waitForIdle()
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the shown 9 was not committed again")
assertEquals(2, s.restarts)
assertEquals(5, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the field committed a value it no longer showed")
assertEquals(1, s.restarts)
}
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
@@ -281,7 +293,7 @@ class MessagingTabsScreenTest {
// ------------------------------------------------------------------ Notification
private class Notification(val prefs: ChatPreferences) {
private class Notification(val prefs: ChatPreferences, val draft: SettingsDraft = ImmediateDraft()) {
init {
prefs.isNotify_dxClusterServerEnabled = false
}
@@ -292,15 +304,19 @@ class MessagingTabsScreenTest {
val refusals = mutableListOf<String>()
val state = NotificationTabState(
prefs,
draft,
monitored,
applyDxClusterServerEnabled = { serverEnabled += it },
applyDxClusterServerPort = { ports += it },
// What the server is brought to on apply: its switch, and the port it binds.
applyDxClusterServer = {
serverEnabled += prefs.isNotify_dxClusterServerEnabled
ports += prefs.notify_dxclusterServerPort
},
broadcastTestSpot = { spots += it; "no logger connected" },
)
}
@Test
fun notificationWritesThroughAndStartsTheClusterServer() = runComposeUiTest {
fun notificationStoresItsFieldsAndStartsTheClusterServer() = runComposeUiTest {
val s = Notification(preferences())
showTab { NotificationTab(s.state) { s.refusals += it } }
@@ -314,6 +330,7 @@ class MessagingTabsScreenTest {
toggle("Run the DX Cluster server")
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
assertEquals(listOf(true), s.serverEnabled)
s.ports.clear()
typeAndCommit("Spotter callsign", "dn9apw")
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
@@ -4,6 +4,8 @@ import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -16,25 +18,99 @@ import java.nio.file.Path
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val monitored = SimpleRoster<String>()
var serverEnabledApplied: Boolean? = null
var portApplied: Int? = null
var serverApplies = 0
var enabledAtApply: Boolean? = null
var portAtApply: Int? = null
var spotSent: ChatMember? = null
var spotRefusal: String? = null
fun state() = NotificationTabState(
prefs,
draft,
monitored,
applyDxClusterServerEnabled = { serverEnabledApplied = it },
applyDxClusterServerPort = { portApplied = it },
applyDxClusterServer = {
serverApplies++
enabledAtApply = prefs.isNotify_dxClusterServerEnabled()
portAtApply = prefs.getNotify_dxclusterServerPort()
},
broadcastTestSpot = { spotSent = it; spotRefusal },
)
}
@Test
fun `the sound settings read from and write to the preferences at once`() {
fun `an edit waits for apply, and discarding drops it`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
state.notify_playSimpleSounds = false
state.notify_optionalFrequencyPrefix = Band.B_432
assertNull(state.commitSpotterCallSign("DO5AMF"))
assertTrue(s.prefs.isNotify_playSimpleSounds(), "an edit took effect before Apply")
assertFalse(state.notify_playSimpleSounds, "the tab shows the edit")
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
s.draft.discard()
assertTrue(s.prefs.isNotify_playSimpleSounds())
assertTrue(state.notify_playSimpleSounds)
state.notify_playSimpleSounds = false
s.draft.apply()
assertFalse(s.prefs.isNotify_playSimpleSounds())
}
@Test
fun `the server is touched on apply only, and once for switch and port together`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_dxClusterServerEnabled(false)
s.prefs.setNotify_dxclusterServerPort(8000)
val state = s.state()
state.enableDxClusterServer(true)
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(0, s.serverApplies, "the server moved before Apply")
s.draft.apply()
assertEquals(1, s.serverApplies, "two restarts in a row can fail on the port")
assertEquals(true, s.enabledAtApply)
assertEquals(7300, s.portAtApply, "the server must come up on the new port")
}
@Test
fun `the monitoring list reaches the roster on apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.addMonitoredCallSign("DN9APW"))
assertTrue(s.monitored.snapshot().isEmpty(), "the list took effect before Apply")
assertTrue(s.draft.hasPendingChanges)
s.draft.apply()
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
}
@Test
fun `the test spot is an action and needs no apply`() {
val s = Session(SettingsDraft())
val state = s.state()
assertNull(state.sendTestSpot())
assertNotNull(s.spotSent)
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `the sound settings read from and write to the preferences`() {
val s = Session()
s.prefs.setNotify_playSimpleSounds(true)
val state = s.state()
@@ -51,7 +127,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `the band-upgrade hints write through`() {
fun `the band-upgrade hints reach the preferences`() {
val s = Session()
val state = s.state()
@@ -65,12 +141,13 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
@Test
fun `enabling the DX cluster server also tells the running session`() {
val s = Session()
s.prefs.setNotify_dxClusterServerEnabled(false)
val state = s.state()
state.enableDxClusterServer(true)
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
assertEquals(true, s.serverEnabledApplied,
assertEquals(true, s.enabledAtApply,
"the JavaFX checkbox started or stopped the server as well, and the "
+ "settings coverage test cannot see that call")
}
@@ -83,7 +160,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitDxClusterServerPort("7300"))
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
assertEquals(7300, s.portApplied)
assertEquals(7300, s.portAtApply)
}
@Test
@@ -96,7 +173,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
assertNull(s.portApplied, "an refused port must not restart the server")
assertEquals(0, s.serverApplies, "an refused port must not restart the server")
}
@Test
@@ -117,7 +194,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitDxClusterServerPort("8000"))
assertNull(s.portApplied,
assertEquals(0, s.serverApplies,
"the JavaFX field only restarted the server when the port actually changed")
}
@@ -155,15 +232,15 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `an unsupported stored prefix falls back to 144 and is written back`() {
val s = Session()
fun `an unsupported stored prefix is shown as 144 without writing anything`() {
val s = Session(SettingsDraft())
s.prefs.setNotify_optionalFrequencyPrefix("4711")
val state = s.state()
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
assertEquals("4711", s.prefs.getNotify_optionalFrequencyPrefix().get(),
"reading the tab wrote the preferences; repairStoredSettings does that, once")
}
@Test
@@ -184,7 +261,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
"monitoring works on the base call, so every SSID maps to the same entry")
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
"the JavaFX button added to the roster at once")
"applied, the roster holds the entry")
}
@Test
@@ -0,0 +1,579 @@
package kst4contest.view.compose.tabs
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.Form
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.SettingsQuestion
import kst4contest.view.compose.runOnce
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotice
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.applyAndSave
import kst4contest.view.compose.SettingsWindow
import kst4contest.view.compose.applyChecked
import kst4contest.view.compose.awaitGone
import kst4contest.view.compose.awaitNode
import kst4contest.view.compose.inDialog
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The settings window around its tabs: edits wait in the draft, Apply/Close applies them,
* Save applies before it writes, and the X asks about what is still waiting.
*
* Drawn with the Beacon tab because it has all three kinds of control that matter here: a
* checkbox, a field that commits on Enter or focus loss, and an effect (the timer restart).
* The button bar is a stand-in: the real one needs a ChatController, which opens a database;
* it calls the same [applyChecked] and [applyAndSave] this one does.
*/
@OptIn(ExperimentalTestApi::class)
class SettingsApplyScreenTest {
@TempDir
lateinit var directory: Path
private inner class Window(refuse: Boolean = false, timerFails: Boolean = false) {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
bcn_beaconIntervalInMinutesMainCat = 5
isBcn_beaconsEnabledMainCat = false
}
val draft = SettingsDraft()
val notices = SettingsNotices()
val question = SettingsQuestion()
var closed = false
var restarts = 0
/** The title bar's X. */
fun x() = question.settleClose(draft, notices)
/** A profile switch or the exit, as SettingsWindow.settleThen hands it on. */
fun switchOrExit(action: () -> Unit) = question.settle(draft, notices, then = action, nothingPending = action)
val state = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
restartBeaconTimer = {
restarts++
if (timerFails) throw IllegalStateException("timer gone")
},
mainCategoryName = "Microwave",
secondCategoryName = "144/432 MHz",
)
val station = StationTabState(prefs, draft, { }, { })
val airscout = AirscoutTabState(prefs, draft)
val tabs = listOf(
SettingsTab("Beacon") {
androidx.compose.foundation.layout.Column {
BeaconTab(state, notices::problem)
StationTab(station)
}
},
)
}
private fun androidx.compose.ui.test.ComposeUiTest.show(w: Window, tabs: List<SettingsTab> = w.tabs) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = tabs,
notices = w.notices,
buttons = { close ->
Form.button(APPLY_AND_CLOSE) { if (applyChecked(w.draft, w.notices)) close() }
},
close = { w.closed = true },
draft = w.draft,
question = w.question,
)
}
}
waitForIdle()
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/*
* A click on a button leaves the focus in the text field, so the field has not committed
* when the button runs. Apply commits it first; otherwise the entry would reach the draft
* only when the closing window removed the field -- after the apply, into nothing.
*/
@Test
fun applyAndCloseTakesTheEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
assertEquals(1, w.restarts)
assertTrue(w.closed)
}
/* A refused entry stops the button, so the reason is not closed away with the window. */
@Test
fun aRefusedEntryKeepsTheWindowOpenAndShowsWhy() = runComposeUiTest {
val w = Window(refuse = true)
show(w)
val field = fieldBeside("Beacon message")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("cq %foo%")
waitForIdle()
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed over a refused entry")
assertTrue(w.notices.current is SettingsNotice.Problem)
}
/*
* A refusal stops the apply before anything goes into effect: the valid edit beside the
* refused one is still pending, so the operator can still discard everything.
*/
@Test
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
/*
* A number field showing text that does not parse holds the last valid value in the draft.
* Applying that value would put something into effect the operator no longer sees.
*/
@Test
fun anInvalidNumberRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
val storedPort = w.prefs.stn_on4kstServersPort
show(w)
type("Server port", "23005")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the window closed with the old port applied")
assertEquals(storedPort, w.prefs.stn_on4kstServersPort)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("Server port") &&
notice.message.contains("23x"), "the notice does not name the field: $notice")
}
/* The same for the guarded fields of the external interfaces. */
@Test
fun anInvalidGuardedEntryRefusesTheApplyAndNamesTheField() = runComposeUiTest {
val w = Window()
show(w, listOf(SettingsTab("Airscout") { AirscoutTab(w.airscout) }))
type(AIRSCOUT_PORT, "0")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains(AIRSCOUT_PORT),
"the notice does not name the field: $notice")
}
/* A failing effect is reported, the window stays open, and the other changes are in. */
@Test
fun aFailingEffectIsReportedAndTheRestIsApplied() = runComposeUiTest {
val w = Window(timerFails = true)
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed, "the failure was closed away")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
val notice = w.notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone"), "$notice")
}
@Test
fun theXClosesAtOnceWhenNothingIsPending() = runComposeUiTest {
val w = Window()
show(w)
runOnIdle { w.x() }
waitForIdle()
assertTrue(w.closed)
}
@Test
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed, "the window closed without asking")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.closed)
}
@Test
fun discardingOnTheXChangesNothing() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
assertFalse(w.draft.hasPendingChanges)
}
@Test
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
/* An entry typed just before the X counts as pending, although no Enter committed it. */
@Test
fun theXAsksAboutAnEntryStillInTheFocusedField() = runComposeUiTest {
val w = Window()
show(w)
val field = fieldBeside("Interval in whole minutes")
field.performScrollTo().performClick()
waitForIdle()
field.performTextReplacement("9")
waitForIdle()
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
assertFalse(w.closed)
}
/* A list row still being edited counts too: the lists commit a row on Enter or focus loss. */
@Test
fun theXAsksAboutARowStillBeingEdited() = runComposeUiTest {
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
}
val draft = SettingsDraft()
val question = SettingsQuestion()
val notices = SettingsNotices()
var closed = false
val state = ShortcutsTabState(prefs, draft, { }, { })
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SettingsContent(
tabs = listOf(SettingsTab("Shortcuts") { ShortcutsTab(state) { } }),
notices = notices,
buttons = { },
close = { closed = true },
draft = draft,
question = question,
)
}
}
waitForIdle()
val row = onNode(hasSetTextAction() and hasText("pse sked"))
row.performScrollTo().performClick()
waitForIdle()
row.performTextReplacement("pse sked 432")
waitForIdle()
runOnIdle { question.settleClose(draft, notices) }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertEquals(listOf("MYQRG", "pse sked 432"), prefs.lst_txtShortCutBtnList.snapshot())
assertTrue(closed)
}
/*
* A profile switch or the exit asks the same question. Apply applies the edits to the
* profile still in force and goes on; the window itself is left to the teardown.
*/
@Test
fun aSwitchOrExitAsksAndApplyGoesOnWithTheEditsApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@Test
fun goingBackCancelsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "the switch or exit went ahead after Back")
assertTrue(w.draft.hasPendingChanges)
}
@Test
fun discardingGoesOnWithNothingApplied() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
}
@Test
fun aRefusalAtTheQuestionStopsTheSwitchOrExit() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
type("Server port", "23x")
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
}
@Test
fun withNothingPendingTheSwitchOrExitGoesOnAtOnce() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
runOnIdle { w.switchOrExit { wentOn = true } }
waitForIdle()
assertTrue(wentOn)
assertFalse(w.closed)
}
/*
* An X while the exit or switch question is open must not replace its continuation:
* Apply would then only close the window, and the exit would be dropped without a word.
*/
@Test
fun theXDoesNotReplaceAnOpenSwitchOrExitQuestion() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
runOnIdle { w.x() }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn, "the exit or switch was dropped")
assertFalse(w.closed)
}
/*
* Invalid text in a field refuses Apply, but must not trap the operator: the question is
* shown anyway, with the reason, and Discard closes.
*/
@Test
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
type("Server port", "23x")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
awaitNode(inDialog("Apply")).assertIsNotEnabled()
onNode(hasText("23x", substring = true) and hasAnyAncestor(isDialog())).assertExists()
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@Test
fun anInvalidFieldStillLetsASwitchOrExitDiscard() = runComposeUiTest {
val w = Window()
var wentOn = false
show(w)
type("Server port", "23x")
runOnIdle { w.switchOrExit { wentOn = true } }
assertFalse(wentOn, "went on past a field that refuses its text")
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
}
@Test
fun aContinuationRunsOnlyOnce() {
var runs = 0
val once = runOnce { runs++ }
once.run()
once.run()
assertEquals(1, runs)
}
/* Without a settings window there is nothing to ask: the action runs at once, synchronously. */
@Test
fun settleThenRunsAtOnceWithoutAnOpenWindow() {
var ran = false
SettingsWindow.settleThen { ran = true }
assertTrue(ran)
}
/* A failed effect does not keep the file from being written; its setting was stored. */
@Test
fun saveStillWritesWhenOnlyAnEffectFailed() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { throw IllegalStateException("timer gone") }, "Microwave", "144/432 MHz")
var saved = false
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
saved = true
"preferences.xml"
}
assertTrue(saved)
val notice = notices.current
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone") &&
notice.message.contains("preferences.xml"), "$notice")
}
/* Save applies first, so the file holds what the window shows; the window stays open. */
@Test
fun saveAppliesBeforeItWrites() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.bcn_beaconIntervalInMinutesMainCat = 5
val draft = SettingsDraft()
val notices = SettingsNotices()
val state = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
var intervalWhenSaved = -1
state.commitBeaconInterval("9")
applyAndSave(draft, notices) {
intervalWhenSaved = prefs.bcn_beaconIntervalInMinutesMainCat
"preferences.xml"
}
assertEquals(9, intervalWhenSaved, "the file was written before the draft was applied")
assertFalse(draft.hasPendingChanges)
assertTrue(notices.current is SettingsNotice.Info)
}
private companion object {
const val APPLY_AND_CLOSE = "Apply/Close prefs"
const val AIRSCOUT_PORT = "AirScout UDP port [9872] — reconnect after changing:"
const val PENDING_QUESTION =
"Some settings have been changed but not applied yet. Apply them first?"
}
}
@@ -1,6 +1,8 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
@@ -10,13 +12,18 @@ import java.nio.file.Path
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
var shortcutRefreshes = 0
var snippetRefreshes = 0
fun state() = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = { shortcutRefreshes++ },
refreshTextSnippetContextMenus = { snippetRefreshes++ },
)
@@ -35,7 +42,42 @@ class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `a new shortcut lands at the top and reaches the roster at once`() {
fun `a list edit waits for apply and rebuilds the buttons once`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
state.shortcuts.addAtTop("cq")
state.commitShortcuts()
state.shortcuts.addAtTop("tnx")
state.commitShortcuts()
assertEquals(listOf("alt"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
"the list took effect before Apply")
assertEquals(0, s.shortcutRefreshes)
s.draft.apply()
assertEquals(listOf("tnx", "cq", "alt"), s.prefs.getLst_txtShortCutBtnList().snapshot())
assertEquals(1, s.shortcutRefreshes)
}
@Test
fun `a discarded list edit leaves the roster alone`() {
val s = Session(SettingsDraft())
s.prefs.getLst_txtSnipList().setAll(listOf("73"))
val state = s.state()
state.snippets.commitAt(0, "")
state.commitSnippets()
s.draft.discard()
assertEquals(listOf("73"), s.prefs.getLst_txtSnipList().snapshot())
assertEquals(0, s.snippetRefreshes)
}
@Test
fun `a new shortcut lands at the top and reaches the roster`() {
val s = Session()
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
val state = s.state()
@@ -12,6 +12,8 @@ import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.runtime.mutableStateOf
import kst4contest.controller.On4KstConnectionState
@@ -19,6 +21,7 @@ import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.feed.ConnectionStateFeed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -60,7 +63,8 @@ class StationTabScreenTest {
}
val mainApplied = mutableListOf<ChatCategory?>()
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
val state = StationTabState(prefs, { mainApplied += it }, { }, { link.value })
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { mainApplied += it }, { }, { link.value })
}
@Test
@@ -77,9 +81,11 @@ class StationTabScreenTest {
checkBeside("10 GHz").assertIsOff()
}
/* The credentials write through; every other field waits for Apply. */
@Test
fun editingTheTextFieldsWritesThroughToThePreferences() = runComposeUiTest {
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
val s = Session(preferences())
val storedHost = s.prefs.stn_on4kstServersDns
showTab { StationTab(s.state) }
type("Callsign", "DO5AMF")
@@ -88,6 +94,10 @@ class StationTabScreenTest {
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
assertEquals(storedHost, s.prefs.stn_on4kstServersDns, "the host took effect before Apply")
fieldBeside("Server host name").assertTextEquals("www.on4kst.info")
runOnIdle { s.draft.apply() }
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
fieldBeside("Callsign").assertTextEquals("DO5AMF")
}
@@ -99,28 +109,77 @@ class StationTabScreenTest {
showTab { StationTab(s.state) }
type("Server port", "23000")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort)
type("Server port", "23x")
runOnIdle { s.draft.apply() }
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
type("QTF", "135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
/*
* The draft is Compose state, so a staged number comes straight back to its field. The
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
* before the next key would make "135,5" impossible to type.
*/
@Test
fun aDecimalCanBeTypedKeyByKey() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val field = fieldBeside("QTF")
field.performScrollTo().performClick()
field.performTextReplacement("")
"135,5".forEach { key ->
field.performTextInput(key.toString())
waitForIdle()
}
field.assertTextEquals("135,5")
runOnIdle { s.draft.apply() }
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
}
@Test
fun aBandCheckboxWritesThrough() = runComposeUiTest {
fun aBandCheckboxIsStagedAndApplied() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
toggle("10 GHz")
assertTrue(s.prefs.isStn_bandActive10G)
checkBeside("10 GHz").assertIsOn()
assertFalse(s.prefs.isStn_bandActive10G, "the box took effect before Apply")
toggle("144 MHz")
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertFalse(s.prefs.isStn_bandActive144)
}
/*
* In a contest the session is up. Everything but the credentials stays editable then,
* and an edit still waits for Apply.
*/
@Test
fun theDraftedFieldsCanBeEditedWhileConnected() = runComposeUiTest {
val s = Session(preferences())
s.link.value = On4KstConnectionState.ONLINE
showTab { StationTab(s.state) }
fieldBeside("Callsign").assertIsNotEnabled()
fieldBeside("Server port").assertIsEnabled()
toggle("10 GHz")
type("Server port", "23005")
assertFalse(s.prefs.isStn_bandActive10G)
runOnIdle { s.draft.apply() }
assertTrue(s.prefs.isStn_bandActive10G)
assertEquals(23005, s.prefs.stn_on4kstServersPort)
}
@Test
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
val s = Session(preferences())
@@ -181,7 +240,7 @@ class StationTabScreenTest {
override fun isUiThread(): Boolean = true
}
val feed = ConnectionStateFeed(surroundings, immediately)
val state = StationTabState(preferences(), { }, { }, { surroundings.connectionState })
val state = StationTabState(preferences(), SettingsDraft(), { }, { }, { surroundings.connectionState })
showTab { StationTab(state) }
fieldBeside("Callsign").assertIsEnabled()
@@ -3,7 +3,10 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
@@ -16,8 +19,10 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
var mainApplied: ChatCategory? = null
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val draft = SettingsDraft()
val state = StationTabState(
prefs,
draft,
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
)
@@ -34,18 +39,55 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
// The JavaFX window had no value-collecting confirmation: each control wrote
// straight into ChatPreferences, and "Save settings" only persisted them.
fun `a station setting waits in the draft until it is applied`() {
val s = Session()
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_on4kstServersPort = 23002
assertFalse(s.prefs.isStn_pstRotatorEnabled(), "an edit took effect before Apply")
assertNotEquals(23002, s.prefs.getStn_on4kstServersPort())
assertTrue(s.state.stn_pstRotatorEnabled, "the tab shows the edited value")
assertEquals(135.0, s.state.stn_qtfDefault, 0.001)
s.draft.apply()
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
}
@Test
fun `a discarded station setting leaves the preferences alone`() {
val s = Session()
s.prefs.setStn_bandActive144(true)
s.state.stn_bandActive144 = false
s.state.stn_bandActive432 = true
s.draft.discard()
assertTrue(s.prefs.isStn_bandActive144())
assertFalse(s.prefs.isStn_bandActive432())
assertTrue(s.state.stn_bandActive144, "after discarding the tab shows the stored value")
}
/*
* The credentials stay immediate: they are locked while a session exists, and without
* one Connect must use what the tab shows.
*/
@Test
fun `the login credentials still write through at once`() {
val s = Session()
s.state.stn_loginCallSign = "DO5AMF"
s.state.stn_pstRotatorEnabled = true
s.state.stn_qtfDefault = 135.0
s.state.stn_loginLocatorMainCat = "JO51IJ"
s.state.stn_loginPassword = "secret"
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
assertEquals("JO51IJ", s.prefs.getStn_loginLocatorMainCat())
assertEquals("secret", s.prefs.getStn_loginPassword())
assertFalse(s.draft.hasPendingChanges, "a credential went into the draft")
}
@Test
@@ -54,6 +96,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
s.state.stn_bandActive144 = true
s.state.stn_bandActive432 = false
s.draft.apply()
assertTrue(s.prefs.isStn_bandActive144())
assertEquals(false, s.prefs.isStn_bandActive432())
@@ -83,6 +126,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
assertNull(s.prefs.getLoginChatCategorySecond(),
"the JavaFX window wrote null for 'no second chat'")
assertTrue(s.secondWasApplied, "the running session must learn about it too")
assertFalse(s.draft.hasPendingChanges, "a chat category went into the draft")
}
@Test
@@ -90,9 +134,11 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
// next to the category pickers, and it decides what the Connect button does.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = StationTabState(prefs, { }, { })
val draft = SettingsDraft()
val state = StationTabState(prefs, draft, { }, { })
state.loginToSecondChatEnabled = true
draft.apply()
assertTrue(prefs.isLoginToSecondChatEnabled())
assertEquals(true, state.loginToSecondChatEnabled)
@@ -0,0 +1,59 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The corrections made once before the settings window is built, outside any draft. */
class StoredSettingsRepairTest {
@TempDir
lateinit var directory: Path
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
@Test
fun `an unsupported fallback band prefix is written back as 144`() {
val prefs = preferences()
prefs.setNotify_optionalFrequencyPrefix("4711")
repairStoredSettings(prefs) { null }
assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get(),
"the JavaFX window repaired the stored value the same way")
}
@Test
fun `a still-default broadcast address is replaced by the detected one`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("192.168.178.255", prefs.getLogsynch_wintestNetworkBroadcastAddress(),
"the JavaFX window auto-detected the subnet broadcast while the stored "
+ "value was still the catch-all default")
}
@Test
fun `a broadcast address the operator chose is never overwritten`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
repairStoredSettings(prefs) { "192.168.178.255" }
assertEquals("10.0.0.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
@Test
fun `a failed detection leaves the default in place`() {
val prefs = preferences()
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
repairStoredSettings(prefs) { throw IllegalStateException("no interface") }
assertEquals("255.255.255.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
}
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -46,7 +47,11 @@ class SynchTabsScreenTest {
}
var restarts = 0
var stops = 0
val state = LogSynchTabState(prefs, { restarts++ }, { stops++ }, { detected })
val state = run {
// What the settings window does once before it builds the tabs.
repairStoredSettings(prefs) { detected }
LogSynchTabState(prefs, ImmediateDraft(), { restarts++ }, { stops++ })
}
}
@Test
@@ -63,7 +68,7 @@ class SynchTabsScreenTest {
}
@Test
fun logSynchWritesThroughAndStartsTheWinTestListener() = runComposeUiTest {
fun logSynchStoresItsFieldsAndStartsTheWinTestListener() = runComposeUiTest {
val s = LogSynch(preferences())
showTab { LogSynchTab(s.state) }
@@ -97,10 +102,10 @@ class SynchTabsScreenTest {
// ------------------------------------------------------------------ TRX synch
@Test
fun trxSynchWritesThroughAndSwitchesTheQrgFollower() = runComposeUiTest {
fun trxSynchStoresItsFieldsAndSwitchesTheQrgFollower() = runComposeUiTest {
val prefs = preferences()
val follower = mutableListOf<Boolean>()
val state = TrxSynchTabState(prefs) { follower += it }
val state = TrxSynchTabState(prefs, ImmediateDraft()) { follower += it }
showTab { TrxSynchTab(state) }
onNodeWithText("Receive UCXLog TRX info").assertExists()
@@ -123,7 +128,7 @@ class SynchTabsScreenTest {
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
// The link defaults to on; start from off so a tick is a visible change.
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
val state = AirscoutTabState(prefs)
val state = AirscoutTabState(prefs, ImmediateDraft())
showTab { AirscoutTab(state) }
onNodeWithText("AirScout UDP communication").assertExists()
@@ -1,6 +1,7 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -13,7 +14,8 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val followerCalls = mutableListOf<Boolean>()
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
val draft = SettingsDraft()
val state by lazy { TrxSynchTabState(prefs, draft) { followerCalls.add(it) } }
}
@Test
@@ -31,45 +33,66 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `writing reaches the preferences at once, without a confirmation step`() {
fun `an edit waits for apply`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = true
s.state.logsynch_wintestUsePassQrg = true
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled(), "an edit took effect before Apply")
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled, "the tab shows the edit")
assertTrue(s.followerCalls.isEmpty(), "the follower moved before Apply")
s.draft.apply()
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
}
@Test
fun `a discarded edit changes nothing`() {
val s = Session()
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.draft.discard()
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
assertTrue(s.followerCalls.isEmpty())
}
@Test
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
// Empty means "accept all" — the JavaFX label said so explicitly.
val s = Session()
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
s.draft.apply()
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
s.draft.apply()
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
}
@Test
fun `the own-QRG follower follows whether any source is enabled`() {
fun `the own-QRG follower follows whether any source is enabled, once per apply`() {
val s = Session()
// Both sources in one apply: one call, with the finished state.
s.state.trxSynch_ucxLogUDPListenerEnabled = true
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
assertEquals(listOf(true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = true
assertEquals(listOf(true, true), s.followerCalls)
// One source off is not all sources off: the follower must stay attached.
s.state.logsynch_wintestQrgSyncEnabled = true
s.draft.apply()
s.state.trxSynch_ucxLogUDPListenerEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true), s.followerCalls)
s.state.logsynch_wintestQrgSyncEnabled = false
s.draft.apply()
assertEquals(listOf(true, true, true, false), s.followerCalls)
}
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsContent
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.SettingsNotices
import kst4contest.view.compose.SettingsTab
import kst4contest.view.compose.StationColumns
@@ -37,9 +38,10 @@ class RemainingTextsFollowTheLanguageTest {
var stored = "en"
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, store)
val state = GuiOptionsTabState(prefs, SettingsDraft(), store)
val beacon = BeaconTabState(
prefs,
SettingsDraft(),
validateBeaconTemplate = {},
restartBeaconTimer = {},
mainCategoryName = "Microwave",
+15
View File
@@ -276,6 +276,21 @@ section records only the durable architecture and operational boundaries.
## Important Decisions and Workarounds
- **Settings take effect with Apply, not while they are edited** (release 1.50,
`docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`): the settings window
stages every edit in a `SettingsDraft` that the tab states read through; Apply/Close, Save
and Connect apply it; the X, a profile switch and the exit ask about what is pending first
(`SettingsWindow.settleThen`, Back cancels). Fields still holding text -- typed but not
committed, or invalid -- are checked before anything is applied, so a refusal leaves Discard
possible. Apply writes everything first and then runs each effect key once, in first-changed
order, so a listener or server whose switch and port both changed restarts once, on the new
port; a failing write or effect is reported and the rest still runs.
Immediate on purpose: login credentials (locked while connected), language, colours, and the
actions (profiles, worked database, test spot, file chooser, stylesheet export). A new
setting goes through `draft.setting(...)` and gets a row in `DraftedSettingsTableTest`; one
that writes `ChatPreferences` directly bypasses Apply. Corrections of stored values that are
no edit (fallback band, Win-Test broadcast detection) run once in `repairStoredSettings`
before the window is built, never inside a tab state.
- **The interface language has four kinds of text, and the rule is the addressee** (release
1.50): interface text is translated; protocol text (the pipe-framed opcodes in
`On4KstProtocol`, and the `/`-prefixed chat commands) and amateur shorthand (the
@@ -0,0 +1,167 @@
# Entwurf — Einstellungen frei bearbeiten, mit „Übernehmen" anwenden
Freigegeben vom Maintainer am 2026-10-16. Baut auf PR #128 auf (kein Festschreiben beim
Öffnen, gesperrte Anmeldefelder während einer Sitzung).
## Warum
Bis hierher schrieb jedes Steuerelement des Einstellungsfensters beim Ändern sofort in
`ChatPreferences`, und manche lösten dabei sofort eine Wirkung in der laufenden Sitzung aus:
der Bakentimer startete neu, ein UDP-Listener wurde neu gebunden, der lokale
DX-Cluster-Server gestartet oder gestoppt. Das war die Übernahme des JavaFX-Fensters, und es
hat zwei Folgen, die im Contest stören:
- **Jeder Zwischenstand wirkt.** Wer einen Port von 9871 auf 9872 ändert, tippt über 987 und
98 hinweg; wer zwei zusammengehörige Häkchen setzt, hat zwischendurch einen halben Stand
aktiv. Ein Fehlgriff wirkt sofort und lässt sich nicht verwerfen.
- **Es gibt kein „doch nicht".** Das Fenster hat keinen Weg zurück. „Übernehmen/Schließen"
schloss nur, und wer etwas ausprobiert hatte, musste es von Hand zurückdrehen.
Das neue Verhalten ist das, das ein Operateur von einem Einstellungsdialog erwartet:
bearbeiten, dann anwenden oder verwerfen.
## Verhalten
1. Das Öffnen des Fensters beginnt einen Entwurf. Felder, Häkchen und Auswahlen aller Reiter
schreiben in den Entwurf statt in die Einstellungen; das Fenster zeigt den Entwurfswert.
Bearbeiten geht auch während einer Sitzung.
2. **Übernehmen/Schließen** wendet alle ausstehenden Änderungen an — Schreibvorgang samt
Wirkung — und schließt das Fenster.
3. **Einstellungen speichern** wendet zuerst an, dann schreibt es die Datei. Das Fenster
bleibt offen.
4. Das **X** der Titelleiste (und das Schließen über das Fenster-Menü) fragt bei
ausstehenden Änderungen: Übernehmen, Verwerfen oder zurück zum Fenster. Ohne ausstehende
Änderungen schließt es wie bisher.
5. Ungültige Eingaben werden wie bisher am Feld abgewiesen und erreichen den Entwurf nie.
Steht beim Übernehmen, Speichern oder Verbinden noch ungültiger Text in einem Feld (rot),
gilt das als Abweisung: nichts wird angewendet, das Fenster bleibt offen, die Meldung nennt
das Feld. Sonst ginge still der letzte gültige Wert in Kraft, den der Operateur nicht mehr
sieht — und Verwerfen wäre nicht mehr möglich, weil die übrigen Änderungen schon wirkten.
Erst wenn kein Feld etwas abweist, wird angewendet. Damit ein solches Feld nicht
einsperrt, erscheint beim X, beim Profilwechsel und beim Beenden die Frage trotzdem: mit
dem Grund, Übernehmen gesperrt, Verwerfen und Zurück verfügbar.
6. **Verbinden** wendet ebenfalls zuerst an. Sonst verbände der Knopf mit dem gespeicherten
statt mit dem sichtbaren Serverport oder Zweitchat-Häkchen — der Operateur sieht den einen
Wert und bekommt den anderen.
7. **Profilwechsel und Programmende** stellen dieselbe Frage, bevor sie weitergehen
(`SettingsWindow.settleThen`): Übernehmen wendet die Änderungen auf das **bisherige**
Profil an — das, in dem sie gemacht wurden — und setzt den Wechsel bzw. das Beenden fort;
Verwerfen setzt ohne sie fort; Zurück bricht Wechsel bzw. Beenden ab. Ohne offene
Änderungen geht es sofort und wie bisher weiter. Solange eine Frage offen ist, werden
weitere Anfragen ignoriert — ein X darf die Fortsetzung eines Beendens nicht durch ein
bloßes Schließen ersetzen —, und jede Fortsetzung läuft höchstens einmal. Erst der anschließende Abbau
(`SettingsWindow.close`) und ein JVM-Shutdown-Hook verwerfen ohne Frage.
8. **Scheitert beim Anwenden ein Schreibvorgang oder eine Wirkung**, laufen alle übrigen
trotzdem; die Fehler stehen danach in einer Problem-Meldung, das Fenster bleibt offen. Eine
Einstellung, deren Schreibvorgang scheiterte, bleibt im Entwurf, ihre Wirkung läuft nicht.
Speichern schreibt die Datei, wenn alle Schreibvorgänge gelangen, auch wenn eine Wirkung
scheiterte — deren Einstellung ist gespeichert und gehört in die Datei.
## Was sofort wirkt, wie bisher
- **Farben** (Reiter Farben) und **Sprache** (Reiter GUI). Beide sind sichtbar, sobald sie
wirken; ein Entwurf hätte dort nichts zu zeigen.
- **Anmeldefelder** des Reiters Station: Rufzeichen, beide Namen, Locator, Passwort, beide
Chatkategorien. Sie sind laut #128 während einer Sitzung gesperrt; ohne Sitzung wirken sie
ohnehin erst beim nächsten Verbinden.
- **Aktionen**: Profil anlegen, duplizieren, umbenennen, löschen, aktivieren; die Aktionen
der Worked-Datenbank; der Test-Spot des DX-Clusters; die „Choose…"-Dateiauswahl;
der Export des Stylesheets. Das sind Handlungen, keine Werte.
- **Reparaturen gespeicherter Werte** (`repairStoredSettings`), einmal beim Öffnen des
Fensters, bevor ein Reiter gebaut wird: eine unlesbare Ausweichband-Vorgabe des
DX-Clusters wird zu 144, und die Win-Test-Broadcastadresse wird, solange sie noch der
Platzhalter 255.255.255.255 ist, durch die erkannte Subnetz-Adresse ersetzt. Beides schrieb
schon das JavaFX-Fenster beim Aufbau, beides ist keine Eingabe des Operateurs und hat
nichts zum Übernehmen oder Verwerfen — darum direkt in `ChatPreferences`. Früher geschah
das in den Reiterzuständen, eines davon beim Lesen während der Komposition; Compose liest
wann es will, und ein Schreiben dort ist leicht zu übersehen. Jetzt gibt es dafür einen
bekannten Zeitpunkt, und kein Reiterzustand schreibt beim Lesen oder Bauen.
Unverändert bleiben das Format von `preferences.xml` und die Knöpfe der Verbindungsleiste.
## Mechanismus
`SettingsDraft` ist ein Fensterobjekt, das einmal pro Öffnen entsteht und an alle
Reiterzustände geht. Er hält ausstehende Änderungen, eine pro Einstellung, unter einem
Schlüssel:
```kotlin
draft.stage(key, value, live = { prefs.getX() }, write = { prefs.setX(it) },
effect = ::restartSomething, effectKey = "something")
draft.valueOf(key) { prefs.getX() } // Entwurfswert oder der gespeicherte
draft.commitTypedEntries() // getippter Text der Felder → Entwurf, oder Abweisungen
draft.apply() // Schreiben, dann Wirkungen; liefert die Fehlschläge
draft.discard()
draft.hasPendingChanges // Compose-Zustand
```
- **Lesen geht durch den Entwurf.** Die Getter der Reiterzustände lesen
`draft.valueOf(key) { prefs… }`. Die Ablage ist Compose-Zustand, also zeichnet ein Feld nach
einer Änderung neu — die alten `revision`-Zähler der Reiter werden dafür nicht mehr gebraucht,
schaden aber nicht.
- **Zurück auf den gespeicherten Wert heißt: nichts steht aus.** Ein Wert gleich dem
gespeicherten nimmt den Eintrag heraus. Ein Häkchen hin und zurück lässt keine Frage beim
Schließen zurück und keinen Neustart beim Übernehmen.
- **`Form.committed`** bleibt wie in #128: Fokusverlust oder Enter rufen `commit`, und das
schreibt jetzt in den Entwurf. `current` liest über den Reiterzustand den Entwurfs- oder den
gespeicherten Wert. Damit gelten die drei Zusagen von #128 weiter: kein Festschreiben beim
Öffnen, kein doppeltes, kein verlorenes.
- **Listen** (Kurzbefehle, Textbausteine, beobachtete Rufzeichen) halten ihren Arbeitsstand
schon in `EditableListState`; der Entwurf merkt sich nur, dass die Liste geändert ist, und
`apply` füllt den Roster aus diesem Stand.
## Reihenfolge beim Anwenden
1. **Erst alle Schreibvorgänge**, in der Reihenfolge, in der die Einstellungen zum ersten Mal
geändert wurden.
2. **Dann die Wirkungen**, in derselben Reihenfolge, **jede Wirkung einmal**. Wirkungen tragen
einen Schlüssel; teilen sich zwei Einstellungen einen, läuft er einmal.
3. Dann ist der Entwurf leer.
Schreiben vor Wirken ist nicht Geschmack. Win-Test-Listener ein **und** neuer Port: liefe die
Wirkung zwischen den Schreibvorgängen, bände der Listener noch den alten Port. Erst alle
Werte, dann die Wirkungen heißt: jede Wirkung sieht den fertigen Stand.
Einmal pro Schlüssel ist ebenfalls nicht Geschmack. Zwei kurz nacheinander neu gebundene
Sockets auf demselben Port können am noch nicht freigegebenen ersten scheitern. Die
geteilten Schlüssel:
| Schlüssel | Einstellungen | Wirkung (auf dem fertigen Stand) |
|---|---|---|
| Win-Test-Listener | Listener ein/aus, Port | an → Neustart, aus → Stopp |
| DX-Cluster-Server | Server ein/aus, Port | nur angemeldet: an → Neustart, aus → Stopp |
| Eigene-QRG-Folger | UCXLog-QRG, Win-Test-QRG | Folger an, wenn eine der beiden Quellen an ist |
| Bakentimer | Intervall | Neustart |
| Mitschnitt in Datei | Häkchen | Fehlerprotokoll und Recorder öffnen oder schließen |
| Kurzbefehle / Textbausteine | Listen | Knöpfe bzw. Kontextmenüs neu bauen |
Die geteilten Wirkungen gleichen den laufenden Dienst an den fertigen Stand an, statt zu
fragen, welche der beiden Einstellungen sich geändert hat. Das deckt alle Fälle der sofortigen
Änderung ab: Einschalten startet (ein Neustart eines nicht laufenden Dienstes ist ein Start),
Ausschalten stoppt, ein neuer Port bei laufendem Dienst bindet neu. Ein neuer Port bei
abgeschaltetem Dienst ruft einen Stopp auf einem nicht laufenden Dienst; der tut nichts, wie
zuvor die ausgebliebene Wirkung.
Zwischen verschiedenen Schlüsseln gibt es keine Abhängigkeit; ihre Reihenfolge ist die der
ersten Änderung und damit dieselbe, in der die sofortigen Änderungen sie bisher ausgelöst
hätten.
## Fäden
Alles läuft auf dem AWT-Ereignisfaden, wie bisher die sofortigen Änderungen: Knopfdruck,
Dialogwahl, Menüeintrag. Gewartet wird dort nie. Die Entscheidung — weiter, fragen —
fällt in `SettingsQuestion.settle` sofort auf dem Ereignisfaden, nicht im nächsten Bild des
Einstellungsfensters: ob ein minimiertes Compose-Fenster weiter Bilder erzeugt, ist ohne
laufende Oberfläche nicht prüfbar, und ein Beenden über das Hauptfenster darf davon nicht
abhängen. Ohne offene Änderungen geht es sofort weiter. Gibt es etwas zu fragen, wird das
Fenster nach vorn geholt und, falls minimiert, wiederhergestellt; erst danach braucht es ein
Bild, nämlich für den Dialog. Die Fortsetzung (Wechsel, Beenden) läuft aus dem Rückruf des
Dialogs, über `EventQueue.invokeLater`, nicht im Klick-Handler selbst.
## Prüfung
Pro Einstellung (`DraftedSettingsTableTest`, mit echtem Entwurf): unverändert nach der
Eingabe, geändert nach Übernehmen, unverändert nach Verwerfen. Pro Fenster: Speichern wendet
an und schreibt; X, Profilwechsel und Programmende fragen bei offenen Änderungen; Zurück
bricht ab; ungültiger Text und Fehlschläge halten das Fenster offen; Bearbeiten während einer
Sitzung geht.
+2
View File
@@ -36,6 +36,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
- **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.
- **Einstellungen wirken erst mit Übernehmen:** Änderungen im Einstellungsfenster wirken nicht mehr während der Eingabe, sondern erst mit **Übernehmen/Schließen** oder **Einstellungen speichern**; **Verbinden** übernimmt sie ebenfalls vorher. Das gilt auch während einer laufenden Sitzung. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken wie bisher sofort.
### Behoben
- **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts.
+13 -11
View File
@@ -4,7 +4,9 @@
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
> **Wichtig**: Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken sofort.
>
> Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
---
@@ -139,7 +141,7 @@ Die TRX-Synchronisation übernimmt die aktuelle Frequenz aus dem Logprogramm und
Der allgemeine Listener ist für Logprogramme vorgesehen, die kompatible `RadioInfo`-Pakete senden. Dazu gehören – abhängig von deren jeweiliger Konfiguration – UCXLog, N1MM+, QARTest und DXLog.net. QSO- und `RadioInfo`-Pakete verwenden denselben unter **Log sync** konfigurierten Port. Dort wird jedoch getrennt festgelegt, ob KST4Contest QSO-Informationen, TRX-Informationen oder beide Paketarten verarbeitet. Eine automatische QRG-Quelle muss aktiviert sein und tatsächlich gültige Pakete liefern; die Aktivierung allein aktualisiert `MYQRG` nicht.
Wird der gemeinsame UDP-Port geändert, muss KST4Contest neu gestartet werden. Eine reine Änderung der Checkboxen wird dagegen sofort berücksichtigt.
Wird der gemeinsame UDP-Port geändert, muss KST4Contest neu gestartet werden. Eine reine Änderung der Checkboxen wird dagegen berücksichtigt, sobald sie übernommen ist.
### Welche QRG wird aktualisiert?
@@ -303,7 +305,7 @@ Die Auswahl begrenzt nicht nur den Netzwerkverkehr. Sie verhindert außerdem, da
### Übernahme geänderter Einstellungen
Folgende Änderungen werden nach Verlassen des Eingabefeldes beziehungsweise Betätigen der Checkbox sofort für neue Pakete verwendet:
Folgende Änderungen werden für neue Pakete verwendet, sobald sie übernommen sind:
- Aktivierung oder Deaktivierung der AirScout-Integration,
- Server-Identifier,
@@ -368,7 +370,7 @@ Mehr zur Erkennungslogik und zu absichtlich ignorierten Zahlen: [QRG-Erkennung](
KST4Contest kann erkannte Richtungsgelegenheiten als DX-Cluster-Spots an ein Logprogramm weitergeben. Eine im Chat erkannte Frequenz erscheint dadurch direkt in der Bandmap des Logprogramms und muss nicht erst von Hand übertragen werden.
Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung sofort wirksam.
Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung wirksam, sobald sie übernommen ist.
Folgende Einstellungen und Schaltflächen gehören zur lokalen DX-Cluster-Ausgabe:
@@ -477,7 +479,7 @@ Rufzeichen werden folgendermaßen verwaltet:
Die Eingabe darf ein sichtbares KST-Suffix oder portable Bestandteile enthalten. KST4Contest normalisiert sie vor dem Speichern auf das Basisrufzeichen. Mehrere Varianten desselben Basisrufzeichens gelten deshalb als Duplikat.
Die Änderung der Liste wirkt sofort. Zum dauerhaften Speichern anschließend **Save Settings** verwenden. Die Basisrufzeichen werden in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt.
Die Änderung der Liste wirkt, sobald sie übernommen ist. Zum dauerhaften Speichern anschließend **Save Settings** verwenden. Die Basisrufzeichen werden in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt.
> Die Zusammenführung der KST-Suffixe über das Basisrufzeichen ist ab v1.42 enthalten.
@@ -514,7 +516,7 @@ Die Reihenfolge der Tabelle entspricht der Reihenfolge der Schaltflächen im Hau
3. Wird der Inhalt vollständig gelöscht und anschließend mit `Enter` bestätigt, entfernt KST4Contest den Eintrag.
4. Mit **Move selected up** und **Move selected down** wird der markierte Eintrag innerhalb der Liste verschoben.
Änderungen werden sofort im Hauptfenster sichtbar. Damit sie auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
Übernommene Änderungen werden sofort im Hauptfenster sichtbar. Damit sie auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
---
@@ -563,7 +565,7 @@ Die Snippet-Liste wird genauso bearbeitet wie die Shortcut-Liste:
4. Ein leer bestätigter Eintrag wird entfernt.
5. **Move selected up** und **Move selected down** ändern die Reihenfolge und damit auch die Zuordnung zu `Ctrl+1` bis `Ctrl+0`.
Die Kontextmenüs und Tastenkombinationen werden nach einer Änderung sofort aktualisiert. Für die dauerhafte Speicherung anschließend **Save Settings** verwenden.
Die Kontextmenüs und Tastenkombinationen werden aktualisiert, sobald die Änderung übernommen ist. Für die dauerhafte Speicherung anschließend **Save Settings** verwenden.
Eine vollständige Übersicht der verfügbaren Platzhalter und ihrer Grenzen steht unter [Makros und Variablen](de-Makros-und-Variablen).
@@ -872,7 +874,7 @@ Im Reiter **GUI** lassen sich zwei Zusatzinformationen der Bandspalten ein- oder
- **Show "o" in band columns …** zeigt ein `o`, wenn das vierstellige Großfeld auf dem betreffenden Band bereits gearbeitet wurde. Das Abschalten entfernt keine Daten aus der Datenbank; nur die zusätzliche Anzeige in den Bandspalten wird ausgeblendet. `wkdany` bleibt davon unberührt.
- **Show "a" in band columns …** unterscheidet ein vollständig neues Rufzeichen von einer Bandmöglichkeit mit einem bereits auf einem anderen Band gearbeiteten Rufzeichen. Ist die Option ausgeschaltet, werden beide Fälle als `B+` dargestellt. Die Bandherleitung selbst ändert sich dadurch nicht.
Änderungen werden in der laufenden Benutzeroberfläche unmittelbar sichtbar. Damit sie nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
Übernommene Änderungen werden in der laufenden Benutzeroberfläche unmittelbar sichtbar. Damit sie nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
![GUI-Einstellungen für die Hinweise in den Bandspalten](client_settings_window_gui.png)
@@ -946,7 +948,7 @@ Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art
### Profil im laufenden Betrieb wechseln
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Hält das Einstellungsfenster noch nicht übernommene Änderungen, fragt KST4Contest zuerst, ob sie auf das bisherige Profil übernommen, verworfen oder der Wechsel abgebrochen werden soll. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen.
@@ -958,7 +960,7 @@ Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des je
## Dark Mode (ab v1.26)
Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt sofort und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert.
Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt, sobald sie übernommen ist, und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert.
Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark mode design** und **Windows → Use default mode design**. Diese Umschaltung gilt nur bis zum nächsten Start oder Profilwechsel; danach gilt wieder die Einstellung im Reiter **GUI**.
@@ -966,7 +968,7 @@ Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark m
## Einstellungen speichern
**Save Settings** speichert die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
**Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
- Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen.
+2
View File
@@ -36,6 +36,8 @@ Several operators sharing one computer can now use their own callsigns, locators
- **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.
- **Settings take effect with Apply:** changes in the settings window no longer take effect while they are typed, but with **Apply/Close prefs** or **Save settings**; **Connect** applies them first as well. This also works during a running session. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, language and the login data on the **Station** tab still take effect immediately.
### Fixed
- **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows.
+13 -11
View File
@@ -4,7 +4,9 @@
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
> **Important**: Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language and the login data on the **Station** tab take effect immediately.
>
> Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
---
@@ -158,7 +160,7 @@ TRX synchronisation imports the current frequency from the logging application a
The general listener is intended for logging applications which transmit compatible `RadioInfo` packets. Depending on their individual configuration, this includes UCXLog, N1MM+, QARTest and DXLog.net. QSO and `RadioInfo` packets use the same port configured under **Log sync**, but separate options determine whether KST4Contest processes QSO information, TRX information or both packet types. An automatic QRG source must be enabled and actually supply valid packets; enabling it alone does not update `MYQRG`.
Restart KST4Contest after changing the shared UDP port. Changes to the two QRG-sync checkboxes take effect immediately.
Restart KST4Contest after changing the shared UDP port. Changes to the two QRG-sync checkboxes take effect as soon as they are applied.
### Which QRG Is Updated?
@@ -356,7 +358,7 @@ This selection does more than reduce network traffic. It also prevents AirScout
### Applying Changed Settings
The following changes are used for new packets immediately after leaving the input field or changing the checkbox:
The following changes are used for new packets as soon as they are applied:
- enabling or disabling the AirScout integration,
- server identifier,
@@ -417,7 +419,7 @@ Further details, including numbers which are deliberately ignored, are described
KST4Contest can forward detected directional opportunities to logging software as DX cluster spots. A frequency recognised in the chat can therefore appear directly in the logger's band map without being entered manually.
The **Enable the local DX Cluster server …** checkbox starts or stops the local TCP server. When KST4Contest is connected to the chat, the change takes effect immediately.
The **Enable the local DX Cluster server …** checkbox starts or stops the local TCP server. When KST4Contest is connected to the chat, the change takes effect as soon as it is applied.
The following settings and controls belong to the local DX cluster output:
@@ -514,7 +516,7 @@ The order of the table determines the order of the buttons in the main window. M
3. To remove an entry, delete its complete contents and confirm with `Enter`.
4. Use **Move selected up** and **Move selected down** to change the position of the selected entry.
Changes appear in the main window immediately. Use **Save Settings** afterwards if they should remain available after the next program start.
Applied changes appear in the main window at once. Use **Save Settings** afterwards if they should remain available after the next program start.
---
@@ -563,7 +565,7 @@ The snippet list is edited in the same way as the shortcut list:
4. Confirming an empty entry removes it.
5. **Move selected up** and **Move selected down** change both the displayed order and the assignment to `Ctrl+1` through `Ctrl+0`.
The context menus and keyboard mappings are updated immediately. Use **Save Settings** afterwards to store the modified list permanently.
The context menus and keyboard mappings are updated as soon as the change is applied. Use **Save Settings** afterwards to store the modified list permanently.
The complete list of available placeholders and their limitations is described under [Macros and Variables](en-Macros-and-Variables).
@@ -914,7 +916,7 @@ Manage the list as follows:
The entered value may contain a visible KST suffix or portable components. KST4Contest normalises it to the base callsign before storing it. Different variants of the same base callsign are therefore treated as duplicates.
Changes to the list take effect immediately. Press **Save Settings** afterwards to retain them. The base callsigns are stored in `preferences.xml` and restored at the next program start.
Changes to the list take effect as soon as they are applied. Press **Save Settings** afterwards to retain them. The base callsigns are stored in `preferences.xml` and restored at the next program start.
> Base-callsign monitoring across KST suffixes is included from v1.42 onwards.
@@ -929,7 +931,7 @@ Two optional additions to the band columns can be enabled or disabled in the **G
- **Show "o" in band columns …** displays an `o` if the four-character grid square has already been worked on the relevant band. Disabling the option does not delete any database records; it only hides the additional indicator in the band columns. `wkdany` is unaffected.
- **Show "a" in band columns …** distinguishes a completely new callsign from a band opportunity involving a callsign already worked elsewhere. When disabled, both cases are displayed as `B+`. The band-opportunity calculation itself remains unchanged.
Changes are reflected in the current user interface immediately. Click **Save Settings** afterwards if they should persist after the next start.
Applied changes are reflected in the current user interface at once. Click **Save Settings** afterwards if they should persist after the next start.
![GUI settings for band-column hints](client_settings_window_gui.png)
@@ -1003,7 +1005,7 @@ The **Profiles** tab in the settings window lists all profiles with their name,
### Switching profiles while running
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. If the settings window still holds changes that were not applied, KST4Contest first asks whether to apply them to the current profile, discard them, or cancel the switch. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
Once more than one profile exists, the main window title also shows the profile name.
@@ -1015,7 +1017,7 @@ As before, the ON4KST password is stored in clear text in the `preferences.xml`
## Dark Mode (from v1.26)
Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect immediately and is stored in the profile's `preferences.xml` with **Save Settings**.
Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect when it is applied and is stored in the profile's `preferences.xml` with **Save Settings**.
For a quick change during operation, use **Windows → Use dark mode design** and **Windows → Use default mode design**. This switch only lasts until the next start or profile switch; after that the setting on the **GUI** tab applies again.
@@ -1023,7 +1025,7 @@ For a quick change during operation, use **Windows → Use dark mode design** an
## Saving Settings
**Save Settings** stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
**Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
- The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**.