diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 1a978850..6dee3c9e 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -3730,6 +3730,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } } + /** + * The session state the settings window shows, read from the main window's Compose state + * so the Station tab's login fields follow it while the window is open. Before that state + * exists the controller's value is the best there is. + */ + private On4KstConnectionState settingsConnectionState() { + if (composeMainWindowState != null) { + return composeMainWindowState.getSurroundings().getConnectionState(); + } + On4KstConnectionState current = chatcontroller.getOn4KstConnectionState(); + return current == null ? On4KstConnectionState.DISCONNECTED : current; + } + /** * Opens the settings window, or does nothing when it is already open. * @@ -3739,7 +3752,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private void openSettingsWindow() { SettingsWindow.show( - SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore), + SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore, + this::settingsConnectionState), settingsNotices, chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt index 11f78377..f68acbc9 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt @@ -111,10 +111,25 @@ object Form { * every prefix of a valid entry, so the draft lives here until the operator leaves * the field or presses Enter. * - * @param stored the value currently stored; the draft falls back to it when an - * entry is refused, which is what those fields did with setText + * Only an edited draft is committed: drawing the field, or entering and leaving it + * without typing, commits nothing, and neither does Enter on an unchanged value. + * A field removed while it holds the focus -- its tab switched away, its window + * closed -- commits a typed entry once: Compose reports the cleared focus to the + * field as a focus loss. + * + * @param stored the value stored when the caller composed; the draft starts from it * @param commit returns null when the value was accepted, otherwise the reason * @param onRefused shown to the operator; the JavaFX fields opened an alert + * @param isUnchanged whether the draft names the stored value. Textual by default, + * looser where two spellings mean one value, as with colours. It must agree + * with what [commit] stores: a draft that commit would store as `c` has to count + * as unchanged against `c`, or leaving the field after Enter commits it again. + * @param current the value stored right now, read when the field commits. The draft + * is judged against it, and falls back to it on a refusal (what the JavaFX + * fields did with setText). Required, because [stored] alone goes stale when it + * comes from a plain getter: nothing recomposes the caller after a commit, so + * after Enter it still names the old value, and once the preference is reset + * behind the field it names the new one by coincidence only. */ @Composable fun committed( @@ -122,14 +137,32 @@ object Form { stored: String, commit: (String) -> String?, onRefused: (String) -> Unit, + isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now }, + current: () -> String, ) { var draft by remember(stored) { mutableStateOf(stored) } + /* + * Whether the field has held the focus since it was last committed. onFocusChanged + * also fires when the modifier is first attached, with "not focused"; reading that + * as a focus loss committed every such field the moment its tab was opened. Only a + * change from focused to unfocused is the operator leaving the field. A plain holder, + * not state: nothing is drawn from it. + */ + val hadFocus = remember { booleanArrayOf(false) } + fun commitDraft() { + /* + * An untouched draft is no edit. Committing it anyway would turn a shipped colour + * into an override of itself, restart the beacon timer, or report a stored value + * the rule refuses although the operator only passed through the field. + */ + if (isUnchanged(draft, current())) return + val refusal = commit(draft) if (refusal != null) { onRefused(refusal) - draft = stored + draft = current() } } @@ -139,7 +172,14 @@ object Form { onValueChange = { draft = it }, modifier = Modifier .fillMaxWidth() - .onFocusChanged { focus -> if (!focus.isFocused) commitDraft() } + .onFocusChanged { focus -> + if (focus.isFocused) { + hadFocus[0] = true + } else if (hadFocus[0]) { + hadFocus[0] = false + commitDraft() + } + } .onKeyEvent { event -> if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) { commitDraft() diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt index e4cb6a10..46cc2733 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import kst4contest.controller.ChatController import kst4contest.controller.ActiveOperatorProfile import kst4contest.controller.OperatorProfileManagementService +import kst4contest.controller.On4KstConnectionState import kst4contest.controller.OperatorProfilePaths import kst4contest.model.ChatMember import kst4contest.model.OperatorProfile @@ -113,6 +114,10 @@ interface SettingsHost { * place, and the foreign-write coverage test can see it. A tab that only wrote a * preference and skipped its session call would look correct in the file and do * nothing until the next restart. + * + * @param connectionState the session state as Compose state reads it; the Station tab + * locks the login fields from it. The controller's own getter is not observable, + * so a tab reading it would stay on whatever the link was when the window opened. */ fun buildSettingsTabs( controller: ChatController, @@ -120,6 +125,7 @@ fun buildSettingsTabs( notices: SettingsNotices, paletteStore: PaletteStore, languageStore: kst4contest.view.i18n.LanguageStore, + connectionState: () -> On4KstConnectionState, ): List { val prefs = controller.chatPreferences @@ -128,6 +134,7 @@ fun buildSettingsTabs( prefs, applyMainCategory = { category -> controller.setChatCategoryMain(category) }, applySecondCategory = { category -> controller.setChatCategorySecondChat(category) }, + connectionState = connectionState, ) val notificationState = NotificationTabState( diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt index c9c549ac..ecbc6111 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt @@ -31,6 +31,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) { stored = state.beaconTextMainCat, commit = state::commitBeaconTextMainCat, onRefused = onRefused, + current = { state.beaconTextMainCat }, ) } @@ -43,6 +44,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) { stored = state.beaconTextSecondCat, commit = state::commitBeaconTextSecondCat, onRefused = onRefused, + current = { state.beaconTextSecondCat }, ) } @@ -52,6 +54,12 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) { stored = state.beaconIntervalMinutes.toString(), commit = state::commitBeaconInterval, onRefused = onRefused, + /* + * Compared as the number commitBeaconInterval stores, so " 9" or "09" after + * Enter does not count as a second edit and restart the timer again. + */ + isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now }, + current = { state.beaconIntervalMinutes.toString() }, ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt index 1b893d11..9bef8076 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt @@ -119,6 +119,13 @@ fun ColoursTab( stored = row.hex, commit = { typed -> state.set(row.role, typed) }, onRefused = reportRefusal, + /* + * A colour is compared the way PaletteOverrides normalises it -- trimmed, + * case ignored -- so retyping the shown colour in lower case is no edit + * and does not turn it into an override of itself. + */ + isUnchanged = { typed, now -> typed.trim().equals(now.trim(), ignoreCase = true) }, + current = { state.roles().single { it.role == row.role }.hex }, ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt index cb8ff04e..ba66d4b1 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt @@ -77,12 +77,18 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { stored = state.notify_dxclusterServerPort.toString(), commit = state::commitDxClusterServerPort, onRefused = onRefused, + // Compared as the number commitDxClusterServerPort stores. + isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now }, + current = { state.notify_dxclusterServerPort.toString() }, ) Form.committed( label = strings.notificationSpotterCallsign, stored = state.notify_DXCSrv_SpottersCallSign, commit = state::commitSpotterCallSign, onRefused = onRefused, + // commitSpotterCallSign stores the callsign trimmed and in upper case. + isUnchanged = { typed, now -> typed.trim().uppercase(java.util.Locale.ROOT) == now }, + current = { state.notify_DXCSrv_SpottersCallSign }, ) /* * Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt index eedec553..043fae06 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.controller.On4KstConnectionState import kst4contest.model.ChatCategory import kst4contest.model.ChatPreferences @@ -26,6 +27,11 @@ class StationTabState( */ private val applyMainCategory: (ChatCategory?) -> Unit, private val applySecondCategory: (ChatCategory?) -> Unit, + /** + * The state of the ON4KST session. A lambda over Compose state, so the login fields + * follow it while the tab is open; the default suits a state built without a session. + */ + private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED }, ) { var loginChatCategoryMain: ChatCategory? @@ -173,8 +179,14 @@ class StationTabState( * password, name, locator and both category pickers once the session was logged * in: changing them there would not reach the running connection, so an edit * would look applied and be ignored until the next login. + * + * Locked for every state but DISCONNECTED: a reconnect wait or a session being + * stopped still belongs to the credentials it was started with. Derived on each read + * rather than held, so a composition reading it follows the connection state the + * moment that changes, provided [connectionState] reads Compose state. */ - var loginFieldsEnabled: Boolean = true + val loginFieldsEnabled: Boolean + get() = connectionState() == On4KstConnectionState.DISCONNECTED /** * The categories the operator can log into, built the way the JavaFX choice box diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessagingTabsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessagingTabsScreenTest.kt index 3e2f967a..38798870 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessagingTabsScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessagingTabsScreenTest.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertTextEquals @@ -151,12 +152,6 @@ class MessagingTabsScreenTest { val s = Beacon(preferences(), refuse = true) s.prefs.bcn_beaconTextMainCat = "cq cq" showTab { BeaconTab(s.state) { s.refusals += it } } - /* - * Drawing the tab already commits each committed field once, because the field's - * focus callback fires with "not focused" when it is first attached -- reported - * under #107 as a defect, not pinned here. Only what this test types is counted. - */ - s.refusals.clear() typeAndCommit("Beacon message", "cq %foo%") @@ -165,6 +160,125 @@ class MessagingTabsScreenTest { fieldBeside("Beacon message").assertTextEquals("cq cq") } + /* + * Opening the tab used to commit all three fields once, because a committed field's + * focus callback fires with "not focused" when it is first attached: the beacon timer + * was restarted and a stored template the validator refuses was reported, although + * the operator had done nothing but look. + */ + @Test + fun openingTheBeaconTabNeitherRestartsTheTimerNorValidates() = runComposeUiTest { + val s = Beacon(preferences(), refuse = true) + s.prefs.bcn_beaconTextMainCat = "cq %foo%" + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + showTab { BeaconTab(s.state) { s.refusals += it } } + + assertEquals(0, s.restarts, "opening the tab restarted the beacon timer") + assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}") + assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat) + } + + /* + * The tab reads the interval from a plain preference getter, so nothing recomposes it + * after Enter commits. Compared with that stale value, leaving the field afterwards + * looked like a second edit and restarted the timer again. + */ + @Test + fun enterAndThenLeavingTheFieldRestartsTheTimerOnce() = runComposeUiTest { + val s = Beacon(preferences()) + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + showTab { BeaconTab(s.state) { s.refusals += it } } + + typeAndCommit("Interval in whole minutes", "9") + fieldBeside("Beacon message").performScrollTo().performClick() + waitForIdle() + + assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat) + assertEquals(1, s.restarts, "one edit restarted the beacon timer more than once") + } + + /* + * Pins what happens to a typed but uncommitted entry when the field leaves the screen + * while it has the focus -- the settings window switching to another tab, or closing. + * Compose clears the focus of a removed focused node and reports that to the field as a + * focus loss, so the entry is committed, exactly once. That was so before the field + * learned to tell a real focus loss from its first attachment, and must stay so: an + * operator who typed a value and switched tabs expects it to count. + */ + @Test + fun aFieldRemovedWhileTypingCommitsTheEntryOnce() = runComposeUiTest { + val s = Beacon(preferences()) + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + val visible = mutableStateOf(true) + showTab { if (visible.value) BeaconTab(s.state) { s.refusals += it } } + + val field = fieldBeside("Interval in whole minutes") + field.performScrollTo().performClick() + waitForIdle() + field.performTextReplacement("9") + waitForIdle() + runOnIdle { visible.value = false } + waitForIdle() + + assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost") + assertEquals(1, s.restarts) + assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}") + } + + /* + * The stored value can change under a field without the field's own key changing: the + * field commits 9 over 5, nothing recomposes the tab, then a profile switch puts 5 back + * and the tab recomposes with 5 -- the same `stored` it was first drawn with. The field + * still shows 9, so leaving it must commit 9 again; judged against what the field itself + * last committed, it looked unchanged and the shown value silently differed from the + * stored one. + */ + @Test + fun aValueResetBehindTheFieldIsCommittedAgainWhenTheFieldIsLeft() = runComposeUiTest { + val s = Beacon(preferences()) + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + val generation = mutableStateOf(0) + val reportA: (String) -> Unit = { s.refusals += it } + val reportB: (String) -> Unit = { s.refusals += it } + // A different callback per generation, so bumping it recomposes BeaconTab itself. + showTab { BeaconTab(s.state, if (generation.value % 2 == 0) reportA else reportB) } + + typeAndCommit("Interval in whole minutes", "9") + assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat) + + runOnIdle { + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + generation.value++ + } + waitForIdle() + fieldBeside("Interval in whole minutes").assertTextEquals("9") + + fieldBeside("Beacon message").performScrollTo().performClick() + waitForIdle() + + assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the shown 9 was not committed again") + assertEquals(2, s.restarts) + } + + /** Leaving the field commits what was typed, without Enter, and restarts the timer once. */ + @Test + fun aTypedIntervalIsCommittedWhenTheFieldIsLeft() = runComposeUiTest { + val s = Beacon(preferences()) + s.prefs.bcn_beaconIntervalInMinutesMainCat = 5 + showTab { BeaconTab(s.state) { s.refusals += it } } + + typeAndLeave("Interval in whole minutes", "9", leaveTo = "Beacon message") + + assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat) + assertEquals(1, s.restarts) + + typeAndLeave("Beacon message", "cq de dn9apw", leaveTo = "Beacon message", leaveToOccurrence = 1) + + assertEquals("cq de dn9apw", s.prefs.bcn_beaconTextMainCat) + assertEquals(1, s.restarts, "leaving a template field restarted the timer") + assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}") + } + // ------------------------------------------------------------------ Notification private class Notification(val prefs: ChatPreferences) { @@ -211,6 +325,38 @@ class MessagingTabsScreenTest { assertTrue(s.ports.isEmpty(), "the server was moved to a refused port") } + /* + * Opening the tab used to commit the port and the spotter callsign once, because a + * committed field's focus callback fires with "not focused" when it is first attached. + * A stored value the rule refuses was then reported the moment the tab was looked at. + */ + @Test + fun openingTheNotificationTabCommitsNothing() = runComposeUiTest { + val s = Notification(preferences()) + s.prefs.setNotify_DXCSrv_SpottersCallSign("x") + showTab { NotificationTab(s.state) { s.refusals += it } } + + assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}") + assertTrue(s.ports.isEmpty(), "opening the tab moved the cluster server") + assertEquals("x", s.prefs.notify_DXCSrv_SpottersCallSign.get()) + } + + /** Leaving the field commits what was typed, without Enter. */ + @Test + fun aTypedSpotterCallsignAndPortAreCommittedWhenTheFieldIsLeft() = runComposeUiTest { + val s = Notification(preferences()) + s.prefs.notify_dxclusterServerPort = 7300 + showTab { NotificationTab(s.state) { s.refusals += it } } + + typeAndLeave("Spotter callsign", "do5amf", leaveTo = "TCP port") + assertEquals("DO5AMF", s.prefs.notify_DXCSrv_SpottersCallSign.get()) + + typeAndLeave("TCP port", "7301", leaveTo = "Spotter callsign") + assertEquals(7301, s.prefs.notify_dxclusterServerPort) + assertEquals(listOf(7301), s.ports) + assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}") + } + @Test fun theFallbackBandAndTheTestSpotReachTheirTargets() = runComposeUiTest { val s = Notification(preferences()) diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfileTabsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfileTabsScreenTest.kt index 2f211672..ee4978a4 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfileTabsScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfileTabsScreenTest.kt @@ -188,6 +188,84 @@ class ProfileTabsScreenTest { assertTrue(c.refusals.isEmpty(), "a valid colour was refused: ${c.refusals}") } + /* + * Opening the tab used to commit every role field once, because a committed field's + * focus callback fires with "not focused" when it is first attached. Each role then + * became an override of itself: the labels said "changed" instead of "shipped", and + * after "Save settings" the overrides hid the profile's stylesheet file for good. + */ + @Test + fun openingTheTabWritesNoColour() = runComposeUiTest { + val c = Colours(directory) + show(c) + + assertEquals(mapOf(false to "", true to ""), c.stored, "opening the tab wrote overrides") + c.state.roles().forEach { row -> + assertEquals(PaletteSource.SHIPPED, row.source, "${row.role} is no longer the shipped colour") + } + assertTrue(c.refusals.isEmpty(), "opening the tab reported: ${c.refusals}") + } + + /** Passing through a field without typing is not an edit either. */ + @Test + fun enteringAndLeavingAFieldWithoutTypingWritesNothing() = runComposeUiTest { + val c = Colours(directory) + show(c) + val accent = c.labelOf(PaletteRole.ACCENT, "Accent") + val text = c.labelOf(PaletteRole.TEXT, "Text") + + fieldBeside(accent).performScrollTo().performClick() + waitForIdle() + fieldBeside(text).performScrollTo().performClick() + waitForIdle() + fieldBeside(accent).performScrollTo().performClick() + waitForIdle() + + assertEquals(mapOf(false to "", true to ""), c.stored, "passing through a field wrote an override") + } + + /* + * The field shows the colour in upper case. Typing the same colour in lower case names + * no other colour, so it is no edit and must not become an override of itself. + */ + @Test + fun theSameColourInOtherCaseIsNoChange() = runComposeUiTest { + val c = Colours(directory) + show(c) + val accent = c.labelOf(PaletteRole.ACCENT, "Accent") + val shown = c.state.roles().single { it.role == PaletteRole.ACCENT }.hex + + typeAndCommit(accent, shown.lowercase()) + fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).performScrollTo().performClick() + waitForIdle() + + assertEquals(mapOf(false to "", true to ""), c.stored, "an unchanged colour was written as an override") + assertEquals(PaletteSource.SHIPPED, c.state.roles().single { it.role == PaletteRole.ACCENT }.source) + assertTrue(c.refusals.isEmpty(), "the colour was refused: ${c.refusals}") + } + + /** Leaving the field still commits what was typed, without Enter. */ + @Test + fun aTypedColourIsCommittedWhenTheFieldIsLeft() = runComposeUiTest { + val c = Colours(directory) + show(c) + + typeAndLeave( + c.labelOf(PaletteRole.ACCENT, "Accent"), + "#3C7A4B", + leaveTo = c.labelOf(PaletteRole.TEXT, "Text"), + ) + + val accent = c.state.roles().single { it.role == PaletteRole.ACCENT } + assertEquals("#3C7A4B", accent.hex) + assertEquals(PaletteSource.CHANGED, accent.source) + assertEquals( + PaletteSource.SHIPPED, + c.state.roles().single { it.role == PaletteRole.TEXT }.source, + "the field that was only entered became an override", + ) + } + @Test fun aMistypedColourIsRefusedAndChangesNothing() = runComposeUiTest { val c = Colours(directory) diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt index 0b4dc3fc..13526db2 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt @@ -8,11 +8,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.performClick @@ -71,10 +71,13 @@ private fun ComposeUiTest.besideLabel( return onAllNodes(matcher)[all.indexOf(chosen)] } -/** The text field to the right of [label]. */ +/** + * The text field to the right of [label]. Matched by its editable text rather than by its + * set-text action, which a disabled field does not offer, so a locked field is found too. + */ @OptIn(ExperimentalTestApi::class) internal fun ComposeUiTest.fieldBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction = - besideLabel(label, occurrence, hasSetTextAction()) { anchor, candidates -> + besideLabel(label, occurrence, SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) { anchor, candidates -> candidates.filter { it.boundsInRoot.left >= anchor.boundsInRoot.right - 1f } .minByOrNull { it.boundsInRoot.left } } @@ -113,3 +116,25 @@ internal fun ComposeUiTest.typeAndCommit(label: String, text: String, occurrence field.performKeyInput { pressKey(Key.Enter) } waitForIdle() } + +/** + * Types into a field that commits on leaving it, then leaves it the way the operator does: + * by clicking into the field beside [leaveTo]. No Enter is pressed, so only the focus loss + * can commit the entry. + */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.typeAndLeave( + label: String, + text: String, + leaveTo: String, + occurrence: Int = 0, + leaveToOccurrence: Int = 0, +) { + val field = fieldBeside(label, occurrence) + field.performScrollTo().performClick() + waitForIdle() + field.performTextReplacement(text) + waitForIdle() + fieldBeside(leaveTo, leaveToOccurrence).performScrollTo().performClick() + waitForIdle() +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt index e094c434..849420e9 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt @@ -2,6 +2,8 @@ package kst4contest.view.compose.tabs import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onAllNodesWithText @@ -11,8 +13,13 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.runtime.mutableStateOf +import kst4contest.controller.On4KstConnectionState import kst4contest.model.ChatCategory import kst4contest.model.ChatPreferences +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.MainWindowSurroundings +import kst4contest.view.feed.ConnectionStateFeed import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -52,7 +59,8 @@ class StationTabScreenTest { isStn_bandActive10G = false } val mainApplied = mutableListOf() - val state = StationTabState(prefs, { mainApplied += it }, { }) + val link = mutableStateOf(On4KstConnectionState.DISCONNECTED) + val state = StationTabState(prefs, { mainApplied += it }, { }, { link.value }) } @Test @@ -130,4 +138,61 @@ class StationTabScreenTest { assertEquals(3, s.prefs.loginChatCategoryMain?.categoryNumber) assertEquals(3, s.mainApplied.single()?.categoryNumber, "the running session was not told") } + + /* + * The credentials are locked while a session exists or is being made: the running + * connection would not pick up a change, so an edit would look applied and be ignored + * until the next login. The lock used to be a plain field nothing ever wrote. + */ + @Test + fun theLoginFieldsAreLockedWhileConnectedOrConnecting() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + val credentials = listOf("Callsign", "Name, main category", "Name, second category", "Locator", "Password") + + credentials.forEach { fieldBeside(it).assertIsEnabled() } + + runOnIdle { s.link.value = On4KstConnectionState.CONNECTING } + waitForIdle() + credentials.forEach { fieldBeside(it).assertIsNotEnabled() } + + runOnIdle { s.link.value = On4KstConnectionState.ONLINE } + waitForIdle() + credentials.forEach { fieldBeside(it).assertIsNotEnabled() } + fieldBeside("Server host name").assertIsEnabled() + + runOnIdle { s.link.value = On4KstConnectionState.DISCONNECTED } + waitForIdle() + credentials.forEach { fieldBeside(it).assertIsEnabled() } + } + + /* + * The application hands the tab `surroundings.connectionState`, which ConnectionStateFeed + * fills from the controller's callback. The application class itself cannot be built in + * a test -- it needs a controller with its database, the windows and the update service -- + * so this drives the same chain from the feed on: a pushed state reaches the drawn tab + * without anything rebuilding it. + */ + @Test + fun theLoginFieldsFollowWhatTheConnectionFeedPushes() = runComposeUiTest { + val surroundings = MainWindowSurroundings() + val immediately = object : UiDispatcher { + override fun runOnUi(task: Runnable) = task.run() + override fun isUiThread(): Boolean = true + } + val feed = ConnectionStateFeed(surroundings, immediately) + val state = StationTabState(preferences(), { }, { }, { surroundings.connectionState }) + showTab { StationTab(state) } + + fieldBeside("Callsign").assertIsEnabled() + + runOnIdle { feed.push(On4KstConnectionState.AUTHENTICATING, null) } + waitForIdle() + fieldBeside("Callsign").assertIsNotEnabled() + + // An absent state reads as DISCONNECTED in the feed, and unlocks the fields. + runOnIdle { feed.push(null, null) } + waitForIdle() + fieldBeside("Callsign").assertIsEnabled() + } }