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

@@ -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",