mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-27 16:31:48 +01:00
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:
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()
|
||||
|
||||
|
||||
+221
@@ -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")
|
||||
}
|
||||
+23
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -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
|
||||
|
||||
|
||||
+34
-17
@@ -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())
|
||||
|
||||
+93
-16
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user