diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 8039929f..d7d961c2 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -3034,8 +3034,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos member -> parseTableDouble(formatPriorityScore(member)), (member, band, worked) -> formatBandCellStatus(member, band, worked), BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())), - RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|" - + (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())), + RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey), "compose-stations", widths); diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt index a5aa51d1..f3400b40 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt @@ -75,8 +75,9 @@ object MonitorWindow { } } +/** Internal rather than private so the window's body can be drawn headless in a test. */ @Composable -private fun MonitorContent( +internal fun MonitorContent( clusterTable: DataTableState, qsoTable: DataTableState, ) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt index b9065c35..8268582a 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt @@ -26,6 +26,19 @@ object StationColumns { /** The heading the ten worked columns sit under. */ const val WORKED_GROUP = "worked" + /** + * The identity a station row is drawn and selected under: the callsign and the chat + * category. The category is part of it because the same callsign can be in two chats at + * once, and AGENTS.md makes the category part of the chat identity; keyed by callsign + * alone, the two rows collided (fixed in 21251377). + * + * Kept here rather than inline at the one call site so the station list can be tested + * with the key it really uses. The text is exactly what the inline Java expression + * produced, including "null" for a missing callsign. + */ + fun rowKey(member: ChatMember): String = + "${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}" + fun all( tropoOf: (ChatMember) -> String, priorityScoreOf: (ChatMember) -> Double, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt index 98710e8b..bbbf704f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -59,8 +60,6 @@ object StationMapWindow { heightDp = heightDp, onResized = onResized, ) { _ -> - val strings = LocalStrings.current - val window = this.window androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) } val tileFetcher = remember { TileFetcher() } @@ -74,6 +73,37 @@ object StationMapWindow { onDispose { tileFetcher.close() } } + Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher) + } // host.show content + } + + /** + * The window's body, without the window. + * + * Split out of [show] so it can be drawn headless in a test, with a tile fetcher that + * stays off the network and the user's tile cache, a roster that is not the running + * application's, and a [MapState] the test can read. The defaults are exactly what + * [show] used inline before the split. + * + * @param chatMembers the roster a marker click looks its station up in; a marker stands + * for a raw callsign, and this is where its category variants come from + * @param mapState where the map is looking + * @param pathAnalysis asked each time the selection changes; null means there is no + * running application, and then the map neither pans nor analyses, exactly as + * when no chat controller existed before the split + */ + @Composable + internal fun Content( + mainWindowState: MainWindowState, + tileFetcher: TileFetcher, + chatMembers: () -> List = { + ApplicationRuntimeLauncher.getCurrent()?.chatController?.lst_chatMemberList?.snapshot().orEmpty() + }, + mapState: MapState = remember { MapState() }, + pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis, + ) { + val strings = LocalStrings.current + /* * The fetcher is plain Java state, so nothing about it recomposes on its * own. Polled rather than wired through a callback: this drives one line of @@ -118,7 +148,9 @@ object StationMapWindow { } var pathAnalysisResult by remember { mutableStateOf(null) } - val mapState = remember { MapState() } + + // Read through updated state so a caller passing a new lambda is never served a stale one. + val currentPathAnalysis by rememberUpdatedState(pathAnalysis) LaunchedEffect(selectedMember) { if (selectedMember == null) { @@ -126,8 +158,8 @@ object StationMapWindow { return@LaunchedEffect } - val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController - if (chatController != null) { + val analysis = currentPathAnalysis() + if (analysis != null) { val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw } if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) { @@ -155,13 +187,7 @@ object StationMapWindow { selectedSnapshot?.callSignRaw() ?: "" ) - val overrideBand: kst4contest.model.Band? = null - - chatController.reachabilityService.requestPathAnalysisForMap( - selectedMember, - selectedSnapshot, - overrideBand - ) { result -> + analysis.request(selectedMember, selectedSnapshot) { result -> pathAnalysisResult = result } } @@ -277,10 +303,8 @@ object StationMapWindow { maxQrbKm = maxQrbKm, darkMode = isDarkTheme, onMarkerClicked = { snapshot -> - val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController - val variants = chatController?.lst_chatMemberList?.snapshot() - ?.filter { it.callSignRaw == snapshot.callSignRaw() } - .orEmpty() + val variants = chatMembers() + .filter { it.callSignRaw == snapshot.callSignRaw() } /* * A marker stands for a raw callsign, not a category. Keep the * category already selected when it is one of the variants. @@ -298,7 +322,7 @@ object StationMapWindow { mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true) } }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG) ) MapOverlayControls( @@ -361,8 +385,10 @@ object StationMapWindow { } // main layout column } // window column } // Surface - } // host.show content - } + } // Content + + /** Marks the map canvas so a test can click a marker on it. */ + internal const val MAP_CANVAS_TAG = "station-map-canvas" @JvmStatic fun applyDarkMode(darkMode: Boolean) { @@ -397,3 +423,34 @@ object StationMapWindow { } } } + +/** + * Asks for the path analysis of the station selected on the map. + * + * The seam between the map window and the running application: the window pans to the + * station and starts the analysis only when there is one to ask, which a test supplies + * without building a chat controller. + */ +internal fun interface MapPathAnalysis { + fun request( + member: kst4contest.model.ChatMember, + snapshot: kst4contest.view.map.MapCallsignRawSnapshot?, + onResult: (PathAnalysisResult) -> Unit, + ) +} + +/** + * The running application's reachability service, or null before there is one. + * + * Looked up on every call, as the window did inline: a profile switch replaces the runtime. + * No band override -- the map always analyses on the automatically resolved band. + */ +private fun runtimePathAnalysis(): MapPathAnalysis? { + val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null + + return MapPathAnalysis { member, snapshot, onResult -> + chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result -> + onResult(result) + } + } +} 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 73c21e7d..cb8ff04e 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 @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.testTag import kst4contest.model.Band import kst4contest.view.compose.DataTable import kst4contest.view.compose.Form @@ -134,6 +135,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { onValueChange = { entered = it }, singleLine = true, label = { Text(strings.notificationCallsign) }, + modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG), ) } }, @@ -149,3 +151,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { ) } } + +/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */ +internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field" diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt new file mode 100644 index 00000000..0863d883 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowScreenTest.kt @@ -0,0 +1,323 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.controller.On4KstConnectionState +import kst4contest.model.PaletteRole +import kst4contest.view.i18n.LanguageStore +import kst4contest.view.i18n.LocalStrings +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * The in-window menu row, drawn and clicked (#107). + * + * [MainMenuModelTest] covers the bar as data. This covers what the operator actually meets: + * a title that opens, an item that runs its own action, a label that follows the state while + * the row is on screen. Two regressions are the reason it exists: + * + * - `fe4215bc` (Remove JavaFX, in `main` as `2a5c89b9`): the sixteen menu actions still hung + * on JavaFX `MenuItem`s that had been deleted, so picking them in the Compose menu did + * nothing. Found by a GUI acceptance run, never by the build. They were moved onto their + * own `menuAction…` methods behind [MainMenuActions]. + * - `83e23f55` (Draw the menu bar in Compose, in `main` as `2a5c89b9`): Compose's `MenuBar` + * is a Swing `JMenuBar` that no theme reaches, so Linux showed the default Metal menu above + * a themed window. The row is now Compose and reads the stylesheet palette. + * + * Headless: only a composition, no window. + */ +@OptIn(ExperimentalTestApi::class) +class MainMenuRowScreenTest { + + private class RecordingActions : MainMenuActions { + val calls = mutableListOf() + override fun connect() { calls += "connect" } + override fun disconnect() { calls += "disconnect" } + override fun switchOperatorProfile() { calls += "switchOperatorProfile" } + override fun exitApplication() { calls += "exitApplication" } + override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" } + override fun toggleAwayState() { calls += "toggleAwayState" } + override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" } + override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" } + override fun toggleStationMap() { calls += "toggleStationMap" } + override fun useDarkDesign() { calls += "useDarkDesign" } + override fun useDefaultDesign() { calls += "useDefaultDesign" } + override fun openDonationPage() { calls += "openDonationPage" } + override fun openHomepage() { calls += "openHomepage" } + override fun openNewsgroup() { calls += "openNewsgroup" } + override fun openChangelog() { calls += "openChangelog" } + override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" } + override fun contactAuthor() { calls += "contactAuthor" } + override fun showAbout() { calls += "showAbout" } + } + + private val rowTag = "menu-row" + + private fun ComposeUiTest.showRow( + state: MainMenuState, + actions: MainMenuActions, + rowState: MainMenuRowState = MainMenuRowState(), + settingsOpen: () -> Boolean = { false }, + monitorOpen: () -> Boolean = { false }, + ) { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Kst4ContestMenuRow( + state = state, + actions = actions, + settingsWindowOpen = settingsOpen(), + monitorWindowOpen = monitorOpen(), + rowState = rowState, + modifier = Modifier.testTag(rowTag), + ) + } + } + } + + private fun onlineState() = MainMenuState().apply { + connectionState = On4KstConnectionState.ONLINE + connectLabel = "Connect to 144/432 MHz" + } + + /** + * Every item, picked through the drawn row, reaches its own action and nobody else's. + * This is the `fe4215bc` fault in the form the operator saw it: the item was there and + * picking it did nothing. + */ + @Test + fun everyItemPickedInTheRowRunsItsOwnAction() = runComposeUiTest { + val actions = RecordingActions() + val state = onlineState() + val rowState = MainMenuRowState() + showRow(state, actions, rowState) + + // Online, so Disconnect is usable and Connect is not; Connect has its own test below. + val expected = listOf( + Triple("File", "Disconnect", "disconnect"), + Triple("File", "Switch operator profile...", "switchOperatorProfile"), + Triple("File", "Exit + disconnect", "exitApplication"), + Triple("Options", "Set QRG as name in Chat (main category)", "setQrgAsNameInChat"), + Triple("Options", "Show me as AWAY FROM chat!", "toggleAwayState"), + Triple("Options", "Show options", "toggleSettingsWindow"), + Triple("Windows", "Show cluster / stranger QSOs", "toggleMonitorWindow"), + Triple("Windows", "Show / hide station map", "toggleStationMap"), + Triple("Windows", "Use dark mode design", "useDarkDesign"), + Triple("Windows", "Use default mode design", "useDefaultDesign"), + Triple("Info", "Donate for kst4Contest development via PayPal", "openDonationPage"), + Triple("Info", "Donate for OV3T´s plane feed service", "openOv3tDonationPage"), + Triple("Info", "Visit DARC X08-Homepage", "openHomepage"), + Triple("Info", "Join kst4Contest newsgroup", "openNewsgroup"), + Triple("Info", "Contact the author using default mail app", "contactAuthor"), + Triple("Info", "About...", "showAbout"), + ) + + expected.forEach { (menu, item, action) -> + onNodeWithText(menu).performClick() + awaitNode(inPopup(item)).performClick() + awaitGone(inPopup(item)) + + assertEquals(action, actions.calls.lastOrNull(), "'$menu > $item' ran the wrong action") + assertEquals(null, rowState.openMenu, "'$menu' stayed down after '$item' was picked") + } + + assertEquals( + expected.size, + actions.calls.size, + "an item ran more than one action, or one ran none", + ) + } + + /** Offline, Connect works; once an attempt runs it is greyed out and Disconnect works. */ + @Test + fun theConnectItemsFollowTheConnectionStateWhileTheMenuIsDown() = runComposeUiTest { + val actions = RecordingActions() + val state = MainMenuState().apply { connectLabel = "Connect to 144/432 MHz" } + showRow(state, actions) + + onNodeWithText("File").performClick() + awaitNode(inPopup("Connect to 144/432 MHz")).assertIsEnabled() + onNode(inPopup("Disconnect")).assertIsNotEnabled() + + onNode(inPopup("Connect to 144/432 MHz")).performClick() + awaitGone(inPopup("Connect to 144/432 MHz")) + assertEquals(listOf("connect"), actions.calls, "Connect did not run while offline") + + onNodeWithText("File").performClick() + awaitNode(inPopup("Connect to 144/432 MHz")) + + // The state changes from elsewhere -- the connection callback -- with the menu down. + runOnIdle { state.connectionState = On4KstConnectionState.CONNECTING } + waitForIdle() + + onNodeWithText("Connect to 144/432 MHz").assertIsNotEnabled() + onNodeWithText("Disconnect").assertIsEnabled() + + onNodeWithText("Connect to 144/432 MHz").performClick() + waitForIdle() + assertEquals(listOf("connect"), actions.calls, "a disabled item ran its action") + } + + /** + * The away item's label is the only place the AFK state is shown at all. JavaFX flipped + * it inside the click handler, which left it wrong whenever the state changed from + * elsewhere; here it must follow the state, drawn. + */ + @Test + fun theAwayLabelFollowsTheStateAndNotTheClick() = runComposeUiTest { + val actions = RecordingActions() + val state = onlineState() + showRow(state, actions) + + onNodeWithText("Options").performClick() + waitForIdle() + onNodeWithText("Show me as AWAY FROM chat!").performClick() + waitForIdle() + + // The click alone changes nothing: the action did not touch the state. + onNodeWithText("Options").performClick() + waitForIdle() + onNodeWithText("Show me as AWAY FROM chat!").assertExists() + + runOnIdle { state.awayFromChat = true } + waitForIdle() + + onNodeWithText("Show me as ACTIVE in chat!").assertExists() + onNodeWithText("Show me as AWAY FROM chat!").assertDoesNotExist() + } + + /** Both toggles name the next action, and follow windows that close on their own. */ + @Test + fun theWindowTogglesFollowWhetherTheWindowIsOpen() = runComposeUiTest { + var monitorOpen by mutableStateOf(false) + showRow(onlineState(), RecordingActions(), monitorOpen = { monitorOpen }) + + onNodeWithText("Windows").performClick() + waitForIdle() + onNodeWithText("Show cluster / stranger QSOs").assertExists() + + runOnIdle { monitorOpen = true } + waitForIdle() + + onNodeWithText("Hide cluster / stranger QSOs").assertExists() + onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist() + } + + /** A second click on an open title closes it again, the way a menu bar behaves. */ + @Test + fun clickingAnOpenTitleClosesIt() = runComposeUiTest { + val rowState = MainMenuRowState() + showRow(onlineState(), RecordingActions(), rowState) + + onNodeWithText("Info").performClick() + waitForIdle() + assertEquals("Info", rowState.openMenu) + onNodeWithText("About...").assertExists() + + onNodeWithText("Info").performClick() + waitForIdle() + assertEquals(null, rowState.openMenu) + onNodeWithText("About...").assertDoesNotExist() + } + + /** + * The `83e23f55` fault: the bar must be drawn in the palette, and a change of the palette + * must reach it while it is on screen. Measured on the rendered row, to the right of the + * last title, where nothing but the row's own background is drawn. + */ + @Test + fun theRowIsDrawnInThePaletteAndFollowsAChange() = runComposeUiTest { + var stored = "" + val store = PaletteStore( + shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { stored }, + storeOverrides = { _, encoded -> stored = encoded }, + ) + + setContent { + CompositionLocalProvider(LocalPaletteStore provides store) { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Kst4ContestMenuRow( + state = onlineState(), + actions = RecordingActions(), + settingsWindowOpen = false, + monitorWindowOpen = false, + rowState = MainMenuRowState(), + modifier = Modifier.testTag(rowTag), + ) + } + } + } + waitForIdle() + + assertEquals(store.shipped(false).base, rightEdgeColour(), "the row is not in the shipped base") + + runOnIdle { store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") } + waitForIdle() + + assertEquals(Color(0xFFFF0000), rightEdgeColour(), "a palette change did not reach the row") + } + + private fun ComposeUiTest.rightEdgeColour(): Color { + val pixels = onNodeWithTag(rowTag).captureToImage().toPixelMap() + // Well inside the row's right end, clear of any edge antialiasing. + return pixels[pixels.width - 12, pixels.height / 2] + } + + /** A language picked after the first draw reaches the titles and the items. */ + @Test + fun aLanguageChosenWhileTheRowIsShownReachesIt() = runComposeUiTest { + var storedLanguage = "en" + val languages = LanguageStore( + storedLanguage = { storedLanguage }, + storeLanguage = { storedLanguage = it }, + systemDefault = Locale.UK, + ) + + setContent { + CompositionLocalProvider(LocalStrings provides languages.strings) { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Kst4ContestMenuRow( + state = onlineState(), + actions = RecordingActions(), + settingsWindowOpen = false, + monitorWindowOpen = false, + rowState = MainMenuRowState(), + ) + } + } + } + + onNodeWithText("File").assertExists() + + try { + runOnIdle { languages.use("de") } + waitForIdle() + + onNodeWithText("Datei").performClick() + waitForIdle() + onNodeWithText("Trennen").assertExists() + onNodeWithText("File").assertDoesNotExist() + } finally { + // use() also sets the JVM-wide CurrentStrings; leave the next test the base language. + runOnIdle { languages.use("en") } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorWindowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorWindowScreenTest.kt new file mode 100644 index 00000000..9be65141 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorWindowScreenTest.kt @@ -0,0 +1,158 @@ +package kst4contest.view.compose + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ClusterMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The cluster and QSO monitor's body, drawn with live tables (#107). + * + * This was the first window whose tables fill while the operator watches, and the defects + * that the Compose port shipped here were all about the coupling between a live list and + * what gets drawn, not about the table itself (`cc8c9020`, Draw the whole interface in + * Compose; in `main` as `2a5c89b9`): + * + * - The monitor sat empty beside a filling JavaFX tab and only redrew when an unrelated + * click forced it: `DataTableState.visibleRows` returned its cache before reading the row + * state, so a recomposition that hit the cache left the table subscribed to nothing. + * - Rows without identity were keyed in a way that could hand out the same key twice, which + * a `LazyColumn` refuses; two messages that read the same are still two messages. + * + * The tables here are built exactly as `Kst4ContestApplication` builds them: the monitor + * columns, keyed by reference. + * + * Headless: [MonitorContent] is drawn on its own, no window is opened. + */ +@OptIn(ExperimentalTestApi::class) +class MonitorWindowScreenTest { + + private fun clusterTable() = + DataTableState(MonitorColumns.dxCluster(), RowKeys.byReference(), "dx-cluster-monitor") + + private fun qsoTable() = + DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference(), "qso-other-monitor") + + private fun member(call: String) = ChatMember().apply { callSign = call } + + private fun spot(from: String, to: String, text: String) = ClusterMessage().apply { + sender = member(from) + receiver = member(to) + messageInhibited = text + } + + private fun qso(from: String, to: String, text: String) = ChatMessage().apply { + sender = member(from) + receiver = member(to) + messageText = text + } + + private fun ComposeUiTest.show( + cluster: DataTableState, + qsos: DataTableState, + ) { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + MonitorContent(cluster, qsos) + } + } + waitForIdle() + } + + @Test + fun bothTablesAreDrawnUnderTheirHeadings() = runComposeUiTest { + show(clusterTable(), qsoTable()) + + onNodeWithText("DX cluster").assertExists() + onNodeWithText("QSO of the other").assertExists() + onNodeWithText("Call tx").assertExists() + onNodeWithText("Call TX").assertExists() + } + + @Test + fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest { + val cluster = clusterTable() + val qsos = qsoTable() + show(cluster, qsos) + + runOnIdle { + cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "144.300 tnx qso"))) + qsos.replaceRows(listOf(qso("DL2CCC", "OK2DDD", "pse sked 432"))) + } + waitForIdle() + + onNodeWithText("144.300 tnx qso").assertExists() + onNodeWithText("pse sked 432").assertExists() + } + + /** + * The empty-monitor fault in its exact shape: the table recomposes once for a reason that + * is not its rows -- here a column moved by the operator -- and the rows that arrive after + * that must still come through. + */ + @Test + fun rowsStillArriveAfterARedrawThatWasNotAboutTheRows() = runComposeUiTest { + val cluster = clusterTable() + show(cluster, qsoTable()) + + runOnIdle { cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "first spot"))) } + waitForIdle() + onNodeWithText("first spot").assertExists() + + // A redraw with the order cache warm. + runOnIdle { cluster.moveColumn("message", 0) } + waitForIdle() + + runOnIdle { + cluster.replaceRows( + listOf(spot("DL3EEE", "OK3FFF", "second spot")) + cluster.rows, + ) + } + waitForIdle() + + onNodeWithText("second spot").assertExists() + onNodeWithText("first spot").assertExists() + } + + /** Two spots that read alike are two rows; neither is dropped and the list does not throw. */ + @Test + fun twoIdenticalLookingMessagesAreTwoRows() = runComposeUiTest { + val cluster = clusterTable() + show(cluster, qsoTable()) + + runOnIdle { + cluster.replaceRows( + listOf( + spot("DL1AAA", "OK1BBB", "repeated spot"), + spot("DL1AAA", "OK1BBB", "repeated spot"), + ), + ) + } + waitForIdle() + + assertEquals(2, onAllNodesWithText("repeated spot").fetchSemanticsNodes().size) + } + + /** A half-filled message off the wire draws as empty cells, not as a crash. */ + @Test + fun aMessageWithoutSenderOrReceiverStillDraws() = runComposeUiTest { + val cluster = clusterTable() + val qsos = qsoTable() + show(cluster, qsos) + + runOnIdle { + cluster.replaceRows(listOf(ClusterMessage().apply { messageInhibited = "orphan spot" })) + qsos.replaceRows(listOf(ChatMessage().apply { messageText = "orphan qso" })) + } + waitForIdle() + + onNodeWithText("orphan spot").assertExists() + onNodeWithText("orphan qso").assertExists() + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OverlayWaits.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OverlayWaits.kt new file mode 100644 index 00000000..618cc333 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OverlayWaits.kt @@ -0,0 +1,45 @@ +package kst4contest.view.compose + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isPopup + +/* + * Waiting for what a click opens on top of the window (#107). + * + * In Compose Multiplatform 1.8.2 a Material3 AlertDialog goes through the skiko Dialog, and a + * DropdownMenu through the skiko Popup; both compose their content into a ComposeSceneLayer of + * the same scene (Dialog_skikoKt calls rememberComposeSceneLayer, checked in the bytecode of + * ui-desktop 1.8.2). The layer's content is set from the composition that opened it, so it can + * take one more frame than the click itself. These helpers wait for the node instead of + * assuming that frame has already happened, and they look only inside the overlay, so a text + * that also stands in the window below can never be picked by accident. + */ + +private const val OVERLAY_TIMEOUT_MILLIS = 5_000L + +/** Waits until a node matching [matcher] is in the tree, then returns it. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher): SemanticsNodeInteraction { + waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } + return onNode(matcher) +} + +/** Waits until no node matches [matcher] any more. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.awaitGone(matcher: SemanticsMatcher) { + waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isEmpty() } +} + +/** A text inside an open dialog. */ +internal fun inDialog(text: String): SemanticsMatcher = + hasText(text, substring = false) and hasAnyAncestor(isDialog()) + +/** A text inside an open popup, which is what a dropdown menu is. */ +internal fun inPopup(text: String): SemanticsMatcher = + hasText(text, substring = false) and hasAnyAncestor(isPopup()) diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt index 4ee027cb..aa135a37 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt @@ -162,4 +162,27 @@ class StationColumnsTest { "reachability is computed by the controller, not carried by the row") assertEquals("250", columns.first { it.id == "score" }.value(member)) } + + /** + * The key Kst4ContestApplication binds the station list to (#107). The category is part + * of the chat identity, so the same callsign in two categories is two keys; keyed by the + * callsign alone the two rows collided (21251377). The exact text is pinned because it + * was moved out of an inline Java lambda and must read the same as that lambda did. + */ + @Test + fun `the row key separates the categories of one callsign`() { + fun member(call: String?, category: Int?) = ChatMember().apply { + callSign = call + chatCategory = category?.let { kst4contest.model.ChatCategory(it) } + } + + assertEquals("DL1ABC|2", StationColumns.rowKey(member("DL1ABC", 2))) + assertEquals("DL1ABC|3", StationColumns.rowKey(member("DL1ABC", 3))) + assertTrue( + StationColumns.rowKey(member("DL1ABC", 2)) != StationColumns.rowKey(member("DL1ABC", 3)), + "two categories of one callsign share a key", + ) + assertEquals("DL1ABC|", StationColumns.rowKey(member("DL1ABC", null))) + assertEquals("null|2", StationColumns.rowKey(member(null, 2))) + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt new file mode 100644 index 00000000..922aee60 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt @@ -0,0 +1,198 @@ +package kst4contest.view.compose + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.AirPlane +import kst4contest.model.AirPlaneReflectionInfo +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import java.util.EnumSet + +/** + * The station list of the main window, drawn: the operator's main working surface (#107). + * + * The same [DataTableView] over [StationColumns] and [StationColumns.rowKey] that + * `Kst4ContestApplication` builds; only the computed cells (tropo, score, band status) are + * stubbed, because they come from the controller. Three regressions are why this list is + * tested on screen and not only as state: + * + * - `21251377` (Codex review findings on PR 89): the row key was the callsign alone, so the + * same callsign in two chat categories collided -- two rows under one key, which a + * `LazyColumn` refuses outright. + * - `21251377` again: sorting worked on all rows instead of the filtered ones, so choosing a + * sort column silently switched the filters off. + * - `ab282162` (#87): the AP column had no comparator and sorted by its text, so + * "13 (75%)" came before "3 (50%)". + * + * Plus the rule the table was built around in `cc8c9020` (Draw the whole interface in + * Compose): rows replaced after the first draw reach the screen, and the selection is kept by + * key across a refresh that hands in new instances. + * + * Headless: only a composition, no window. + */ +@OptIn(ExperimentalTestApi::class) +class StationListScreenTest { + + private fun stationList(): DataTableState = DataTableState( + StationColumns.all( + tropoOf = { "" }, + priorityScoreOf = { 0.0 }, + formatBand = { _, _, _ -> "" }, + activeBands = EnumSet.noneOf(Band::class.java), + ), + RowKeys.byValue(StationColumns::rowKey), + "compose-stations", + ) + + private fun station( + call: String, + category: Int = 2, + qrb: Double? = null, + planes: List>? = null, + ): ChatMember = ChatMember().apply { + callSign = call + name = "op $call" + chatCategory = ChatCategory(category) + this.qrb = qrb + if (planes != null) { + airPlaneReflectInfo = AirPlaneReflectionInfo().apply { + risingAirplanes = planes.map { (minutes, potential) -> + AirPlane().apply { + arrivingDurationMinutes = minutes + this.potential = potential + } + }.toMutableList() + } + } + } + + private fun ComposeUiTest.show( + state: DataTableState, + onRowClick: (ChatMember) -> Unit = {}, + ) { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + DataTableView(state = state, onRowClick = onRowClick) + } + } + waitForIdle() + } + + /** The callsigns as they stand on screen, top to bottom. */ + private fun ComposeUiTest.drawnOrder(vararg calls: String): List = + calls.sortedBy { call -> + onNodeWithText(call).fetchSemanticsNode().positionInRoot.y + } + + @Test + fun theSameCallsignInTwoCategoriesIsTwoRowsAndOnlyTheClickedOneIsSelected() = runComposeUiTest { + val state = stationList() + val inMain = station("DL1ABC", category = 2) + val inSecond = station("DL1ABC", category = 3) + val clicked = mutableListOf() + + show(state) { clicked += it } + runOnIdle { state.replaceRows(listOf(inMain, inSecond)) } + waitForIdle() + + assertEquals( + 2, + onAllNodesWithText("DL1ABC").fetchSemanticsNodes().size, + "the second category's row did not reach the screen", + ) + + // Unsorted, the rows keep their arrival order: the second category is the lower row. + val rows = onAllNodesWithText("DL1ABC") + val lower = rows.fetchSemanticsNodes().withIndex().maxBy { it.value.positionInRoot.y }.index + rows[lower].performClick() + waitForIdle() + + assertSame(inSecond, clicked.single(), "the click reached the other category's row") + assertSame(inSecond, state.selectedRow) + assertEquals(false, state.isSelected(inMain), "both categories read as selected") + } + + @Test + fun aSortChosenWhileFilteredKeepsTheFilter() = runComposeUiTest { + val state = stationList() + show(state) + runOnIdle { + state.replaceRows( + listOf( + station("DL3CCC", qrb = 300.0), + station("DL1AAA", qrb = 100.0), + station("DL2BBB", qrb = 200.0), + ), + ) + state.rowFilter = { it.callSign != "DL2BBB" } + } + waitForIdle() + onNodeWithText("DL2BBB").assertDoesNotExist() + + onNodeWithText("QRB").performClick() + waitForIdle() + + onNodeWithText("DL2BBB").assertDoesNotExist() + assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA")) + } + + @Test + fun theApColumnSortsByMinutesAndNotByItsText() = runComposeUiTest { + val state = stationList() + show(state) + runOnIdle { + state.replaceRows( + listOf( + station("DL1THIRTEEN", planes = listOf(13 to 75)), + station("DL1NONE", planes = emptyList()), + station("DL1THREE", planes = listOf(3 to 50)), + ), + ) + } + waitForIdle() + + onNodeWithText("AP [minutes / pot%]").performClick() + waitForIdle() + + assertEquals( + listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"), + drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"), + "the text order puts \"13 (75%)\" above \"3 (50%)\"", + ) + onNodeWithText("AP [minutes / pot%] ▲").assertExists() + } + + @Test + fun rowsArrivingAfterTheFirstDrawReachTheScreenAndKeepTheSelection() = runComposeUiTest { + val state = stationList() + show(state) + + runOnIdle { state.replaceRows(listOf(station("DL1AAA"), station("DL2BBB"))) } + waitForIdle() + onNodeWithText("DL2BBB").performClick() + waitForIdle() + + // A refresh from the roster hands in new instances, with one more station on top. + val refreshed = station("DL2BBB") + runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"), refreshed)) } + waitForIdle() + + onNodeWithText("DL9NEW").assertExists() + assertSame(refreshed, state.selectedRow, "the selection did not follow the station by key") + + // A station that left takes the selection with it rather than handing it on. + runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"))) } + waitForIdle() + + onNodeWithText("DL2BBB").assertDoesNotExist() + assertEquals(null, state.selectedRow) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt new file mode 100644 index 00000000..78c451e6 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt @@ -0,0 +1,384 @@ +package kst4contest.view.compose.map + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.input.TextFieldValue +import kst4contest.locatorUtils.Location +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ChatPreferences +import kst4contest.model.ClusterMessage +import kst4contest.view.compose.BlinkingNotice +import kst4contest.view.compose.ChatInputActions +import kst4contest.view.compose.ChatInputState +import kst4contest.view.compose.DataTableState +import kst4contest.view.compose.Kst4ContestTheme +import kst4contest.view.compose.MainMenuState +import kst4contest.view.compose.MainWindowState +import kst4contest.view.compose.MainWindowSurroundings +import kst4contest.view.compose.RowKeys +import kst4contest.view.compose.SelectedStationState +import kst4contest.view.compose.SplitterState +import kst4contest.view.compose.StationColumns +import kst4contest.view.compose.StationFilterState +import kst4contest.view.compose.ThreadStatusButtons +import kst4contest.view.compose.TimelineState +import kst4contest.view.compose.TopPriorityState +import kst4contest.view.map.StationMapClusterer +import kst4contest.view.map.MapCallsignRawSnapshot +import kst4contest.view.map.PathAnalysisResult +import kst4contest.view.map.TileFetcher +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.io.IOException +import java.util.EnumSet + +/** + * The station map window's body, drawn and clicked (#107). + * + * The map has the longest regression history of the Compose windows, and the two that + * reached an operator are both about what a click on the map does to the rest of the + * application: + * + * - `21251377` (Codex review findings on PR 89): a marker click and "Reset view" set the + * map's own copy of the selection instead of going through the central station selector, + * so the station list, the score service and the send line did not follow the map. + * - `cc8c9020` (Draw the whole interface in Compose, in `main` as `2a5c89b9`): an earlier + * Compose version recomputed the zoom on every selection and threw an operator working at + * zoom 15 out to about 7. AGENTS.md: reset clears the selection without changing the zoom. + * + * Tiles never arrive: an empty temporary cache and a source that always fails, the + * arrangement `3dd2cf10` gave the drawing test after the default fetcher read the user's + * disk cache and the real tile server. No running application exists: the path analysis is + * either absent, as it is before a runtime exists, or a recording stand-in passed through the + * window's [MapPathAnalysis] seam, which is what lets the zoom rule on selection run here. + * + * Headless: [StationMapWindow.Content] is drawn on its own, no window is opened. + */ +@OptIn(ExperimentalTestApi::class) +class StationMapWindowScreenTest { + + /* + * Never the no-argument ChatPreferences: it resolves the operator's real profile and + * seeds preferences.xml there when it is missing. A file in a temporary directory is + * never read or written by these tests, it only gives the preferences a home. + */ + @TempDir + lateinit var directory: Path + + private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml")) + + private lateinit var offlineTileFetcher: TileFetcher + + @BeforeEach + fun openTileFetcher() { + offlineTileFetcher = TileFetcher( + directory.resolve("tile-cache"), + { throw IOException("no tile source in this test") }, + ) + } + + /** The fetcher owns a thread pool; the window closes it on dispose, and so does this. */ + @AfterEach + fun closeTileFetcher() { + offlineTileFetcher.close() + } + + /** Records what the window asks the path analysis for, instead of a running application. */ + private class RecordingPathAnalysis : MapPathAnalysis { + val requested = mutableListOf() + override fun request( + member: ChatMember, + snapshot: MapCallsignRawSnapshot?, + onResult: (PathAnalysisResult) -> Unit, + ) { + requested += member + } + } + + private val ownLocator = "JO40HA" + + private fun station(call: String, category: Int, locator: String = "JO50JP") = ChatMember().apply { + callSign = call + qra = locator + chatCategory = ChatCategory(category) + } + + private class NoChatActions : ChatInputActions { + override fun panelSelection(): ChatMember? = null + override fun tableSelection(): ChatMember? = null + override fun scoreSelection(): ChatMember? = null + override fun resolveVariables(template: String, member: ChatMember?): String? = template + override fun isAddressedToOwnCallsign(text: String): Boolean = false + override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null + override fun mainCategory(): ChatCategory? = null + override fun recordOutboundCq(text: String) {} + override fun requestRecompute(reason: String) {} + override fun queue(message: ChatMessage) {} + override fun ownQrgMain(): String = "" + override fun ownQrgSecond(): String = "" + } + + private fun windowState(prefs: ChatPreferences): MainWindowState { + val stations = DataTableState( + StationColumns.all( + tropoOf = { "" }, + priorityScoreOf = { 0.0 }, + formatBand = { _, _, _ -> "" }, + ), + RowKeys.byValue(StationColumns::rowKey), + ) + val menu = MainMenuState() + return MainWindowState( + activeBands = EnumSet.of(Band.B_144, Band.B_432), + prefs = prefs, + stations = stations, + stationFilter = StationFilterState(), + directedMessages = DataTableState(emptyList(), RowKeys.byReference()), + publicMessages = DataTableState(emptyList(), RowKeys.byReference()), + clusterMessages = DataTableState(emptyList(), RowKeys.byReference()), + qsoOfTheOther = DataTableState(emptyList(), RowKeys.byReference()), + selectedStationMessages = DataTableState(emptyList(), RowKeys.byReference()), + selectedStation = SelectedStationState( + propagateNotQrv = {}, + createSked = { _, _, _, _ -> false }, + ), + topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}), + timeline = TimelineState(), + chatInput = ChatInputState(NoChatActions()), + menu = menu, + surroundings = MainWindowSurroundings(menu), + skedNotice = BlinkingNotice(), + bandUpgradeNotice = BlinkingNotice(), + threadButtons = ThreadStatusButtons(), + outerSplitter = SplitterState(2, doubleArrayOf(0.5)) {}, + messageSplitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {}, + rightSplitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, doubleArrayOf(0.4, 0.6)) {}, + ) + } + + private fun prefs(pathAnalysisVisible: Boolean = false) = preferences().apply { + stn_loginLocatorMainCat = ownLocator + isGUIstationMapPathAnalysisVisible = pathAnalysisVisible + isGUIstationMapClusteringEnabled = false + } + + private fun ComposeUiTest.show( + state: MainWindowState, + members: List, + mapState: MapState, + pathAnalysis: MapPathAnalysis? = null, + onPathAnalysisAsked: () -> Unit = {}, + ) { + state.stations.replaceRows(members) + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + StationMapWindow.Content( + mainWindowState = state, + tileFetcher = offlineTileFetcher, + chatMembers = { members }, + mapState = mapState, + pathAnalysis = { onPathAnalysisAsked(); pathAnalysis }, + ) + } + } + waitForIdle() + } + + /** A map looking straight at a locator, so its marker sits in the middle of the canvas. */ + private fun mapLookingAt(locator: String, zoom: Float): MapState { + val location = Location(locator) + return MapState( + zoom = zoom, + centerLon = location.longitude.toDegrees(), + centerLat = location.latitude.toDegrees(), + ) + } + + @Test + fun resetViewClearsTheSelectionThroughTheSelectorAndKeepsTheZoom() = runComposeUiTest { + val state = windowState(prefs()) + val dl1abc = station("DL1ABC", 2) + state.selectedStation.select(dl1abc) + val selectorCalls = mutableListOf() + state.stationSelector = { selectorCalls += it } + val mapState = mapLookingAt("JO50JP", zoom = 15f) + + show(state, listOf(dl1abc), mapState) + + onNodeWithText("Reset view").performClick() + waitForIdle() + + assertEquals(listOf(null), selectorCalls, "reset did not go through the station selector") + assertSame( + dl1abc, + state.selectedStation.selected, + "reset cleared the panel's selection behind the selector's back", + ) + assertEquals(15f, mapState.zoom, "reset changed the zoom") + + val home = Location(ownLocator) + assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9) + assertEquals(home.latitude.toDegrees(), mapState.centerLat, 1e-9) + } + + /** Without a selector -- before the host set one -- reset still clears the selection. */ + @Test + fun resetViewWithoutASelectorClearsThePanelSelection() = runComposeUiTest { + val state = windowState(prefs()) + val dl1abc = station("DL1ABC", 2) + state.selectedStation.select(dl1abc) + + show(state, listOf(dl1abc), mapLookingAt("JO50JP", zoom = 9f)) + + onNodeWithText("Reset view").performClick() + waitForIdle() + + assertEquals(null, state.selectedStation.selected) + } + + /** + * A marker stands for a raw callsign. With one of its category variants already selected + * the click keeps that one, goes through the selector, and prepares "/cq CALL" over + * whatever was typed -- the JavaFX handleMapCallsignSelection behaviour. + */ + @Test + fun aMarkerClickSelectsThroughTheSelectorAndKeepsTheSelectedCategory() = runComposeUiTest { + val state = windowState(prefs()) + val inMain = station("DL1ABC", 2) + val inSecond = station("DL1ABC", 3) + state.selectedStation.select(inSecond) + state.chatInput.value = TextFieldValue("half typed") + val selectorCalls = mutableListOf() + state.stationSelector = { selectorCalls += it } + val mapState = mapLookingAt("JO50JP", zoom = 12f) + + show(state, listOf(inMain, inSecond), mapState) + + onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick() + waitForIdle() + + assertEquals(1, selectorCalls.size, "the marker click did not reach the station selector") + assertSame(inSecond, selectorCalls.single(), "the click swapped the selected category") + assertEquals("/cq DL1ABC ", state.chatInput.value.text) + assertEquals(12f, mapState.zoom, "selecting from the map changed the zoom") + } + + @Test + fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest { + val state = windowState(prefs()) + val inMain = station("DL1ABC", 2) + val inSecond = station("DL1ABC", 3) + val selectorCalls = mutableListOf() + state.stationSelector = { selectorCalls += it } + + show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f)) + + onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick() + waitForIdle() + + assertSame(inMain, selectorCalls.single()) + } + + /** + * The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone. + * The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7. + */ + @Test + fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest { + val state = windowState(prefs()) + val dl1abc = station("DL1ABC", 2, locator = "JO50JP") + val analysis = RecordingPathAnalysis() + val mapState = mapLookingAt(ownLocator, zoom = 15f) + + show(state, listOf(dl1abc), mapState, analysis) + + runOnIdle { state.selectedStation.select(dl1abc) } + waitForIdle() + + val target = Location("JO50JP") + assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan") + assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan") + assertEquals(15f, mapState.zoom, "selecting a station changed the zoom") + assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked") + } + + /** + * With grouping on, a selection zooms in only as far as it takes to break the station out + * of its bubble, and never out: StationMapClusterer.zoomToRevealStations. + */ + @Test + fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest { + val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true } + val state = windowState(prefs) + val dl1abc = station("DL1ABC", 2, locator = "JO50JP") + val far = mapLookingAt(ownLocator, zoom = 5f) + + show(state, listOf(dl1abc), far, RecordingPathAnalysis()) + + runOnIdle { state.selectedStation.select(dl1abc) } + waitForIdle() + + assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom) + } + + /** Without a running application there is nothing to ask, and the map stays put. */ + @Test + fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest { + val state = windowState(prefs()) + val dl1abc = station("DL1ABC", 2, locator = "JO50JP") + val mapState = mapLookingAt(ownLocator, zoom = 15f) + val home = Location(ownLocator) + var asked = 0 + + show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ }) + + runOnIdle { state.selectedStation.select(dl1abc) } + waitForIdle() + + // Proves the selection effect ran, so the unchanged map is not a vacuous pass. + assertEquals(1, asked, "the selection effect did not ask for a path analysis") + assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9) + assertEquals(15f, mapState.zoom) + } + + /** The two header switches write the profile, so the map opens the same way next time. */ + @Test + fun theHeaderSwitchesWriteThePreferences() = runComposeUiTest { + val prefs = prefs(pathAnalysisVisible = true) + val state = windowState(prefs) + + show(state, emptyList(), MapState()) + + onNodeWithText("Hide path analysis").performClick() + waitForIdle() + assertEquals(false, prefs.isGUIstationMapPathAnalysisVisible) + onNodeWithText("Path analysis is hidden.").assertExists() + onNodeWithText("Show path analysis").assertExists() + + onNodeWithText("Show path analysis").performClick() + waitForIdle() + assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible) + + // The grouping checkbox sits beside its label; the label itself is not clickable. + onNodeWithText("Group nearby stations").assertExists() + onNode(isToggleable()).assertIsOff().performClick() + waitForIdle() + onNode(isToggleable()).assertIsOn() + assertEquals(true, prefs.isGUIstationMapClusteringEnabled) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ListTabsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ListTabsScreenTest.kt new file mode 100644 index 00000000..a8459bfe --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ListTabsScreenTest.kt @@ -0,0 +1,188 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.ChatMember +import kst4contest.model.ChatPreferences +import kst4contest.observe.SimpleRoster +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import kst4contest.view.compose.awaitNode +import kst4contest.view.compose.awaitGone +import kst4contest.view.compose.inDialog +import kst4contest.view.compose.inPopup +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The two tabs built around a list, drawn: Shortcuts and the worked-station database (#107). + * + * The shortcut lists are what the button row above the send line and the snippet menus are + * built from. Their write-through has a history: the refresh hook was called into thin air + * after the JavaFX buttons were unplugged, so an edited shortcut reached the operator only + * after the window was rebuilt (`fe4215bc`, Remove JavaFX). The hook is what these tests see. + */ +@OptIn(ExperimentalTestApi::class) +class ListTabsScreenTest { + + /* + * Never the no-argument ChatPreferences: it resolves the operator's real profile and + * seeds preferences.xml there when it is missing. A file in a temporary directory is + * never read or written by these tests, it only gives the preferences a home. + */ + @TempDir + lateinit var directory: Path + + private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml")) + + // ------------------------------------------------------------------ Shortcuts + + private class Shortcuts(isolated: ChatPreferences) { + val prefs = isolated.apply { + lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked")) + lst_txtSnipList.setAll(listOf("tnx qso 73")) + } + var shortcutRefreshes = 0 + var snippetRefreshes = 0 + val state = ShortcutsTabState(prefs, { shortcutRefreshes++ }, { snippetRefreshes++ }) + } + + @Test + fun shortcutsShowsBothListsWithWhatTheProfileHolds() = runComposeUiTest { + val s = Shortcuts(preferences()) + showTab { ShortcutsTab(s.state) { } } + + onNodeWithText("Shortcut buttons above the message field").assertExists() + onNodeWithText("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)").assertExists() + onNode(hasSetTextAction() and hasText("pse sked")).assertExists() + onNode(hasSetTextAction() and hasText("tnx qso 73")).assertExists() + onNodeWithText("Add shortcut").assertExists() + onNodeWithText("Add new snippet").assertExists() + } + + @Test + fun anEditedShortcutIsWrittenAndTheButtonsAreRefreshed() = runComposeUiTest { + val s = Shortcuts(preferences()) + showTab { ShortcutsTab(s.state) { } } + + val row = onNode(hasSetTextAction() and hasText("pse sked")) + row.performScrollTo().performClick() + row.performTextReplacement("pse sked 432") + onNode(hasSetTextAction() and hasText("pse sked 432")).performKeyInput { pressKey(Key.Enter) } + waitForIdle() + + assertEquals(listOf("MYQRG", "pse sked 432"), s.prefs.lst_txtShortCutBtnList.snapshot()) + assertTrue(s.shortcutRefreshes > 0, "the shortcut buttons were not told") + } + + @Test + fun addingASnippetPutsTheTemplateOnTopAndWritesIt() = runComposeUiTest { + val s = Shortcuts(preferences()) + showTab { ShortcutsTab(s.state) { } } + + onNodeWithText("Add new snippet").performScrollTo().performClick() + waitForIdle() + + assertEquals( + listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "tnx qso 73"), + s.prefs.lst_txtSnipList.snapshot(), + ) + assertTrue(s.snippetRefreshes > 0, "the snippet menus were not told") + assertEquals(listOf("MYQRG", "pse sked"), s.prefs.lst_txtShortCutBtnList.snapshot()) + } + + // ------------------------------------------------------------------ Worked database + + private class Worked(affected: Int = 12) { + val roster = SimpleRoster().apply { + setAll(listOf(ChatMember().apply { callSign = "DL1ABC"; isWorked = true; isWorked144 = true })) + } + var resets = 0 + var guiRefreshes = 0 + var viewRefreshes = 0 + val state = WorkedDatabaseTabState( + resetInDatabase = { resets++; affected }, + refreshGuiLists = { guiRefreshes++ }, + refreshView = { viewRefreshes++ }, + workedRoster = roster, + ) + } + + @Test + fun theWorkedDatabaseShowsItsRows() = runComposeUiTest { + val w = Worked() + showTab { WorkedDatabaseTab(w.state) } + + onNodeWithText("Worked station database").assertExists() + onNodeWithText("DL1ABC").assertExists() + onNodeWithText("wkd").assertExists() + } + + @Test + fun refreshReReadsTheRoster() = runComposeUiTest { + val w = Worked() + showTab { WorkedDatabaseTab(w.state) } + + runOnIdle { w.roster.setAll(listOf(ChatMember().apply { callSign = "OK1XYZ" })) } + onNodeWithText("Refresh worked database").performScrollTo().performClick() + waitForIdle() + + assertEquals(1, w.viewRefreshes) + onNodeWithText("OK1XYZ").assertExists() + onNodeWithText("DL1ABC").assertDoesNotExist() + } + + /** Nothing is touched until the dialog is confirmed; then the database goes first. */ + @Test + fun aResetAsksFirstAndReportsWhatItDid() = runComposeUiTest { + val w = Worked(affected = 12) + showTab { WorkedDatabaseTab(w.state) } + + onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick() + waitForIdle() + awaitNode(inDialog("Reset contest data")) + assertEquals(0, w.resets, "the reset ran before it was confirmed") + + onNode(inDialog("Cancel")).performClick() + awaitGone(inDialog("Reset contest data")) + assertEquals(0, w.resets) + + onNodeWithText("Reset worked, NOT-QRV and grid data...").performClick() + waitForIdle() + awaitNode(inDialog("Reset data")).performClick() + awaitGone(inDialog("Reset data")) + + assertEquals(1, w.resets) + assertEquals(1, w.guiRefreshes) + onNodeWithText("Last reset affected 12 rows.").assertExists() + } + + @Test + fun aFailedResetIsSaidAndLeavesTheListsAlone() = runComposeUiTest { + val w = Worked(affected = -1) + showTab { WorkedDatabaseTab(w.state) } + + onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick() + waitForIdle() + awaitNode(inDialog("Reset data")).performClick() + awaitGone(inDialog("Reset data")) + + awaitNode( + inDialog( + "The contest data could not be reset. The internal database may be unavailable or " + + "inconsistent. No successful reset was confirmed.", + ), + ) + assertEquals(0, w.guiRefreshes, "the lists were cleared although the database was not") + } +} 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 new file mode 100644 index 00000000..3e2f967a --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessagingTabsScreenTest.kt @@ -0,0 +1,251 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.Band +import kst4contest.model.ChatMember +import kst4contest.model.ChatPreferences +import kst4contest.observe.SimpleRoster +import kst4contest.view.i18n.LanguageStore +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import kst4contest.view.compose.awaitNode +import kst4contest.view.compose.awaitGone +import kst4contest.view.compose.inDialog +import kst4contest.view.compose.inPopup +import androidx.compose.ui.test.onNodeWithTag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.Locale + +/** + * The tabs that decide what this station says and hears, drawn: Message handling, GUI, + * Beacon and Notification (#107). + * + * Each is composed headless, shows its sections and key controls, and an edit made through + * the drawn control reaches the preferences. The message-handling tab carries the + * debug-to-file switch that the Compose redraw had dropped and `c8ce0e98` gave back. + */ +@OptIn(ExperimentalTestApi::class) +class MessagingTabsScreenTest { + + /* + * Never the no-argument ChatPreferences: it resolves the operator's real profile and + * seeds preferences.xml there when it is missing. A file in a temporary directory is + * never read or written by these tests, it only gives the preferences a home. + */ + @TempDir + lateinit var directory: Path + + private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml")) + + // ------------------------------------------------------------------ Message handling + + @Test + fun messageHandlingWritesBothCategoriesAndAppliesTheFileRecording() = runComposeUiTest { + val prefs = preferences().apply { + isMessageHandling_autoAnswerEnabled = false + isMessageHandling_debugModeToFileEnabled = false + } + val recording = mutableListOf() + val state = MessageHandlingTabState(prefs) { recording += it } + showTab { MessageHandlingTab(state) } + + onNodeWithText("Automatic answer to private messages").assertExists() + onNodeWithText("Diagnostics").assertExists() + + toggle("Answer private messages automatically") + assertTrue(prefs.isMessageHandling_autoAnswerEnabled) + assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat, "the second category was left behind") + + type("Answer text", "qrv 432.200") + assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextMainCat) + assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextSecondCat) + + // The c8ce0e98 control: it writes the preference and asks the application to act. + toggle("Record the whole session to file") + assertTrue(prefs.isMessageHandling_debugModeToFileEnabled) + assertEquals(listOf(true), recording) + } + + // ------------------------------------------------------------------ GUI + + @Test + fun guiOptionsWritesTheStartupChoicesAndTheLanguage() = runComposeUiTest { + val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false } + var storedLanguage = "en" + val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK) + val state = GuiOptionsTabState(prefs, languages) + showTab { GuiOptionsTab(state) } + + onNodeWithText("Startup design").assertExists() + onNodeWithText("Message filters active at startup").assertExists() + checkBeside("Start in the evening design").assertIsOff() + + toggle("Start in the evening design") + assertTrue(prefs.isGUI_darkModeActiveByDefault) + + val before = prefs.isGuiOptions_defaultFilterPublicMsgs + toggle("Public messages") + assertEquals(!before, prefs.isGuiOptions_defaultFilterPublicMsgs) + + try { + onNodeWithText("EN").performScrollTo().performClick() + waitForIdle() + awaitNode(inPopup("DE")).performClick() + waitForIdle() + + assertEquals("de", storedLanguage, "the picked language was not stored") + assertEquals("de", languages.language) + } finally { + // use() also sets the JVM-wide CurrentStrings; leave the next test the base language. + runOnIdle { languages.use("en") } + } + } + + // ------------------------------------------------------------------ Beacon + + private class Beacon(val prefs: ChatPreferences, val refuse: Boolean = false) { + var restarts = 0 + val refusals = mutableListOf() + val state = BeaconTabState( + prefs, + validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") }, + restartBeaconTimer = { restarts++ }, + mainCategoryName = "Microwave", + secondCategoryName = "144/432 MHz", + ) + } + + @Test + fun beaconShowsOneSectionPerCategoryAndCommitsOnEnter() = runComposeUiTest { + val s = Beacon(preferences()) + showTab { BeaconTab(s.state) { s.refusals += it } } + + onNodeWithText("CQ beacon for Microwave").assertExists() + onNodeWithText("CQ beacon for 144/432 MHz").assertExists() + onNodeWithText("Shared interval").assertExists() + + typeAndCommit("Beacon message", "cq de dn9apw %MYQRG%", occurrence = 1) + assertEquals("cq de dn9apw %MYQRG%", s.prefs.bcn_beaconTextSecondCat) + + typeAndCommit("Interval in whole minutes", "7") + assertEquals(7, s.prefs.bcn_beaconIntervalInMinutesMainCat) + assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}") + } + + /** A refused template is reported, not stored, and the field goes back to what is stored. */ + @Test + fun beaconRefusesAnInvalidTemplateAndPutsTheFieldBack() = runComposeUiTest { + val s = Beacon(preferences(), refuse = true) + s.prefs.bcn_beaconTextMainCat = "cq cq" + showTab { BeaconTab(s.state) { s.refusals += it } } + /* + * 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%") + + assertEquals("cq cq", s.prefs.bcn_beaconTextMainCat) + assertEquals(1, s.refusals.size) + fieldBeside("Beacon message").assertTextEquals("cq cq") + } + + // ------------------------------------------------------------------ Notification + + private class Notification(val prefs: ChatPreferences) { + init { + prefs.isNotify_dxClusterServerEnabled = false + } + val monitored = SimpleRoster() + val serverEnabled = mutableListOf() + val ports = mutableListOf() + val spots = mutableListOf() + val refusals = mutableListOf() + val state = NotificationTabState( + prefs, + monitored, + applyDxClusterServerEnabled = { serverEnabled += it }, + applyDxClusterServerPort = { ports += it }, + broadcastTestSpot = { spots += it; "no logger connected" }, + ) + } + + @Test + fun notificationWritesThroughAndStartsTheClusterServer() = runComposeUiTest { + val s = Notification(preferences()) + showTab { NotificationTab(s.state) { s.refusals += it } } + + listOf("Sounds", "Band upgrade hints", "Local DX Cluster server", "QSO monitoring") + .forEach { onNodeWithText(it).assertExists() } + + val before = s.prefs.isNotify_playSimpleSounds + toggle("Play simple sounds") + assertEquals(!before, s.prefs.isNotify_playSimpleSounds) + + toggle("Run the DX Cluster server") + assertTrue(s.prefs.isNotify_dxClusterServerEnabled) + assertEquals(listOf(true), s.serverEnabled) + + typeAndCommit("Spotter callsign", "dn9apw") + assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get()) + + val storedPort = s.prefs.notify_dxclusterServerPort + typeAndCommit("TCP port", "0") + assertEquals(1, s.refusals.size, "an impossible port was not refused") + assertEquals(storedPort, s.prefs.notify_dxclusterServerPort) + assertTrue(s.ports.isEmpty(), "the server was moved to a refused port") + } + + @Test + fun theFallbackBandAndTheTestSpotReachTheirTargets() = runComposeUiTest { + val s = Notification(preferences()) + showTab { NotificationTab(s.state) { s.refusals += it } } + + val current = s.state.notify_optionalFrequencyPrefix + onNodeWithText(current.prefix + " MHz").performScrollTo().performClick() + waitForIdle() + val other = Band.entries.first { it != current } + awaitNode(inPopup(other.prefix + " MHz")).performClick() + waitForIdle() + assertEquals(other.prefix, s.prefs.notify_optionalFrequencyPrefix.get()) + + onNodeWithText("Send a test spot").performScrollTo().performClick() + waitForIdle() + assertEquals("DO5AMF", s.spots.single().callSign) + assertEquals(listOf("no logger connected"), s.refusals, "the outcome was not reported") + } + + /** The add button asks for the callsign first; what is confirmed reaches the roster. */ + @Test + fun aMonitoredCallsignAddedThroughTheDialogReachesTheRoster() = runComposeUiTest { + val s = Notification(preferences()) + showTab { NotificationTab(s.state) { s.refusals += it } } + + onNodeWithText("Add monitored callsign").performScrollTo().performClick() + waitForIdle() + awaitNode(inDialog("Add a callsign to the monitoring list")) + + onNodeWithTag(MONITORED_CALLSIGN_FIELD_TAG).performTextReplacement("DL1ABC") + waitForIdle() + awaitNode(inDialog("OK")).performClick() + awaitGone(inDialog("OK")) + + assertEquals(listOf("DL1ABC"), s.monitored.snapshot()) + assertFalse(s.refusals.isNotEmpty(), "the callsign was refused: ${s.refusals}") + } +} 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 new file mode 100644 index 00000000..2f211672 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfileTabsScreenTest.kt @@ -0,0 +1,238 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.controller.OperatorProfileManagementService +import kst4contest.controller.OperatorProfileStore +import kst4contest.model.OperatorProfile +import kst4contest.model.PaletteRole +import kst4contest.view.compose.JavaFxStylesheet +import kst4contest.view.compose.OperatorProfilePaletteFiles +import kst4contest.view.compose.PaletteSource +import kst4contest.view.compose.PaletteStore +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import kst4contest.view.compose.awaitNode +import kst4contest.view.compose.awaitGone +import kst4contest.view.compose.inDialog +import kst4contest.view.compose.inPopup +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path + +/** + * The two tabs that belong to the operator profile, drawn: Profiles and Colours (#107). + * + * Profiles: only what refuses before the service writes anything, or what writes into a + * temporary registry, is exercised. Creating, renaming and deleting a real profile reach + * into the application directory under the operator's home ([ProfilesTabStateTest] says why + * that is not done here either). + * + * Colours: a store made of lambdas and stylesheet files in a temporary directory, so nothing + * reaches the operator's profile. The theme reading the store is `ThemeReadsPaletteStoreTest`; + * this is the tab that writes into it. + */ +@OptIn(ExperimentalTestApi::class) +class ProfileTabsScreenTest { + + @TempDir + lateinit var directory: Path + + // ------------------------------------------------------------------ Profiles + + private fun profilesState(activated: MutableList = mutableListOf()) = + ProfilesTabState( + OperatorProfileManagementService( + OperatorProfileStore(directory.resolve("profiles.xml").toString()) + ), + { null }, + { activated += it }, + ) + + @Test + fun profilesShowsTheDefaultProfileAndItsActions() = runComposeUiTest { + val state = profilesState() + showTab { ProfilesTab(state) } + + onNodeWithText("Active profile:").assertExists() + onNodeWithText("Profile").assertExists() + onNodeWithText(state.profiles.single().displayName).assertExists() + listOf("New profile...", "Duplicate...", "Rename...", "Delete...", + "Change worked stations...", "Switch to selected profile...") + .forEach { onNodeWithText(it).assertExists() } + } + + /** The default profile is the installation's own; deleting it is refused, and said so. */ + @Test + fun deletingTheDefaultProfileIsRefusedInADialog() = runComposeUiTest { + val state = profilesState() + showTab { ProfilesTab(state) } + + onNodeWithText("Delete...").performScrollTo().performClick() + waitForIdle() + + // A selected profile is always asked about first; the refusal comes on confirmation. + awaitNode(inDialog("Delete operator profile")) + onNode(inDialog("Delete profile")).performClick() + + awaitNode( + inDialog("The default profile uses the files of the installation itself and cannot be deleted."), + ) + onNode(inDialog("OK")).performClick() + awaitGone(inDialog("Operator profiles")) + assertEquals(1, state.profiles.size) + } + + @Test + fun switchingAsksTheApplicationToActivateTheSelectedProfile() = runComposeUiTest { + val activated = mutableListOf() + val state = profilesState(activated) + showTab { ProfilesTab(state) } + + onNodeWithText("Switch to selected profile...").performScrollTo().performClick() + waitForIdle() + + assertEquals(listOf(state.profiles.single()), activated) + } + + /** The New dialog opens and, cancelled, creates nothing. */ + @Test + fun aCancelledNewProfileDialogCreatesNothing() = runComposeUiTest { + val state = profilesState() + showTab { ProfilesTab(state) } + + onNodeWithText("New profile...").performScrollTo().performClick() + waitForIdle() + awaitNode(inDialog("New operator profile")) + + onNode(inDialog("Cancel")).performClick() + awaitGone(inDialog("New operator profile")) + assertEquals(1, state.profiles.size) + } + + // ------------------------------------------------------------------ Colours + + private class Colours(directory: Path) { + val stored = mutableMapOf(false to "", true to "") + val store = PaletteStore( + shippedOf = { dark -> + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { dark -> stored.getValue(dark) }, + storeOverrides = { dark, encoded -> stored[dark] = encoded }, + ) + val files = OperatorProfilePaletteFiles( + dayFile = directory.resolve("day.css").toFile(), + eveningFile = directory.resolve("evening.css").toFile(), + ) + val state = ColoursTabState(store, { false }, files) + val refusals = mutableListOf() + val outcomes = mutableListOf() + } + + private fun ComposeUiTest.show(c: Colours) { + showTab { ColoursTab(c.state, { c.refusals += it }, { c.outcomes += it }) } + } + + /** The label a role row carries right now: its name and where its colour came from. */ + private fun Colours.labelOf(role: PaletteRole, name: String): String { + val provenance = when (state.roles().single { it.role == role }.source) { + PaletteSource.SHIPPED -> "shipped" + PaletteSource.FILE -> "from file" + PaletteSource.CHANGED -> "changed" + } + return "$name ($provenance)" + } + + @Test + fun coloursShowsTheSixRolesAndTheWaysBack() = runComposeUiTest { + val c = Colours(directory) + show(c) + + onNodeWithText("Colours of this design").assertExists() + onNodeWithText("Ways back").assertExists() + listOf( + PaletteRole.SURFACE to "Surface (menu strip, selected rows)", + PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)", + PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)", + PaletteRole.TEXT to "Text", + PaletteRole.ACCENT to "Accent", + PaletteRole.SEPARATOR to "Separator line", + ).forEach { (role, name) -> + fieldBeside(c.labelOf(role, name)).assertTextEquals(c.state.roles().single { it.role == role }.hex) + } + onNodeWithText( + "Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})", + ).assertExists() + } + + @Test + fun aTypedColourIsCommittedIntoTheStore() = runComposeUiTest { + val c = Colours(directory) + show(c) + + typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B") + + assertEquals("#3C7A4B", c.state.roles().single { it.role == PaletteRole.ACCENT }.hex) + assertEquals(Color(0xFF3C7A4B), c.store.resolved(false).palette.accent) + assertTrue(c.refusals.isEmpty(), "a valid colour was refused: ${c.refusals}") + } + + @Test + fun aMistypedColourIsRefusedAndChangesNothing() = runComposeUiTest { + val c = Colours(directory) + show(c) + val before = c.state.roles().single { it.role == PaletteRole.TEXT }.hex + + typeAndCommit(c.labelOf(PaletteRole.TEXT, "Text"), "#12") + + assertEquals(1, c.refusals.size) + assertEquals(before, c.state.roles().single { it.role == PaletteRole.TEXT }.hex) + fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).assertTextEquals(before) + } + + @Test + fun resetToShippedPutsEveryRoleBack() = runComposeUiTest { + val c = Colours(directory) + show(c) + typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B") + + onNodeWithText( + "Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})", + ).performScrollTo().performClick() + waitForIdle() + + assertEquals(c.store.shipped(false).accent, c.store.resolved(false).palette.accent) + } + + /** The export writes the shipped sheet into the profile, and never over an existing file. */ + @Test + fun theExportWritesTheStylesheetOnceAndThenLeavesItAlone() = runComposeUiTest { + val c = Colours(directory) + show(c) + val day: File = c.files.dayFile + + onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick() + waitForIdle() + + assertTrue(day.isFile, "nothing was written") + assertEquals(listOf("Written to ${day.path}. Edit it and restart to see the result."), c.outcomes) + + day.writeText("/* the operator's own */") + onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick() + waitForIdle() + + assertEquals("/* the operator's own */", day.readText(), "an existing stylesheet was replaced") + assertEquals("${day.path} is already there and was left untouched.", c.outcomes.last()) + } +} 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 new file mode 100644 index 00000000..0b4dc3fc --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt @@ -0,0 +1,115 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.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 +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import kst4contest.view.compose.Kst4ContestTheme + +/* + * Shared plumbing for the settings-tab screen tests (#107). + * + * The form controls draw a label beside a field, not a labelled field: there is no semantic + * link between the two. These helpers find a control by the label the operator reads, on the + * same row, which is exactly how the operator finds it -- and which breaks, as it should, + * when a label moves away from its field. + */ + +/** + * Draws one tab the way the settings window does: themed, in a column that scrolls, so a + * control further down can be scrolled to and clicked. + */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + content() + } + } + } + waitForIdle() +} + +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.labelNode(label: String, occurrence: Int): SemanticsNode { + val labels = onAllNodes(hasText(label, substring = false)).fetchSemanticsNodes() + check(labels.size > occurrence) { "no label '$label' (#$occurrence) on screen; found ${labels.size}" } + return labels.sortedBy { it.boundsInRoot.top }[occurrence] +} + +/** The control matching [matcher] on the row of [label], picked by [choose] among candidates. */ +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.besideLabel( + label: String, + occurrence: Int, + matcher: SemanticsMatcher, + choose: (label: SemanticsNode, candidates: List) -> SemanticsNode?, +): SemanticsNodeInteraction { + val anchor = labelNode(label, occurrence) + val all = onAllNodes(matcher).fetchSemanticsNodes() + val sameRow = all.filter { node -> + node.boundsInRoot.center.y in anchor.boundsInRoot.top..anchor.boundsInRoot.bottom + } + val chosen = choose(anchor, sameRow) ?: error("no control beside '$label' (#$occurrence)") + return onAllNodes(matcher)[all.indexOf(chosen)] +} + +/** The text field to the right of [label]. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.fieldBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction = + besideLabel(label, occurrence, hasSetTextAction()) { anchor, candidates -> + candidates.filter { it.boundsInRoot.left >= anchor.boundsInRoot.right - 1f } + .minByOrNull { it.boundsInRoot.left } + } + +/** The checkbox directly to the left of [label], as [kst4contest.view.compose.Form.check] draws it. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.checkBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction = + besideLabel(label, occurrence, isToggleable()) { anchor, candidates -> + candidates.filter { it.boundsInRoot.right <= anchor.boundsInRoot.left + 1f } + .maxByOrNull { it.boundsInRoot.right } + } + +/** Ticks or unticks the checkbox beside [label], scrolling to it first. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.toggle(label: String, occurrence: Int = 0) { + checkBeside(label, occurrence).performScrollTo().performClick() + waitForIdle() +} + +/** Replaces the text of the field beside [label], as typing a whole new value would. */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.type(label: String, text: String, occurrence: Int = 0) { + fieldBeside(label, occurrence).performTextReplacement(text) + waitForIdle() +} + +/** + * Types into a field that commits on Enter or on leaving it, the way + * [kst4contest.view.compose.Form.committed] does, and presses Enter. + */ +@OptIn(ExperimentalTestApi::class) +internal fun ComposeUiTest.typeAndCommit(label: String, text: String, occurrence: Int = 0) { + val field = fieldBeside(label, occurrence) + field.performScrollTo().performClick() + field.performTextReplacement(text) + field.performKeyInput { pressKey(Key.Enter) } + waitForIdle() +} 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 new file mode 100644 index 00000000..e094c434 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt @@ -0,0 +1,133 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.ChatCategory +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import kst4contest.view.compose.awaitNode +import kst4contest.view.compose.awaitGone +import kst4contest.view.compose.inDialog +import kst4contest.view.compose.inPopup +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The Station tab, drawn (#107). + * + * [StationTabStateTest] pins the facade; this pins that the drawn controls show what the + * profile holds and that editing one writes through, which is the class of fault the + * settings window shipped repeatedly with a green build (`83e23f55`, Etappe 7). + */ +@OptIn(ExperimentalTestApi::class) +class StationTabScreenTest { + + /* + * Never the no-argument ChatPreferences: it resolves the operator's real profile and + * seeds preferences.xml there when it is missing. A file in a temporary directory is + * never read or written by these tests, it only gives the preferences a home. + */ + @TempDir + lateinit var directory: Path + + private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml")) + + private class Session(isolated: ChatPreferences) { + val prefs = isolated.apply { + stn_loginCallSign = "DN9APW" + stn_on4kstServersPort = 23001 + isStn_bandActive144 = true + isStn_bandActive10G = false + } + val mainApplied = mutableListOf() + val state = StationTabState(prefs, { mainApplied += it }, { }) + } + + @Test + fun theTabShowsItsSectionsAndWhatTheProfileHolds() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults", + "PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() } + + fieldBeside("Callsign").assertTextEquals("DN9APW") + fieldBeside("Server port").assertTextEquals("23001") + checkBeside("144 MHz").assertIsOn() + checkBeside("10 GHz").assertIsOff() + } + + @Test + fun editingTheTextFieldsWritesThroughToThePreferences() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + type("Callsign", "DO5AMF") + type("Locator", "JO51IJ") + type("Server host name", "www.on4kst.info") + + assertEquals("DO5AMF", s.prefs.stn_loginCallSign) + assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat) + assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns) + fieldBeside("Callsign").assertTextEquals("DO5AMF") + } + + /** A number field writes only what parses; a half-typed entry leaves the stored value. */ + @Test + fun theNumberFieldsWriteOnlyWhatParses() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + type("Server port", "23000") + assertEquals(23000, s.prefs.stn_on4kstServersPort) + + type("Server port", "23x") + assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written") + + type("QTF", "135,5") + assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9) + } + + @Test + fun aBandCheckboxWritesThrough() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + toggle("10 GHz") + assertTrue(s.prefs.isStn_bandActive10G) + checkBeside("10 GHz").assertIsOn() + + toggle("144 MHz") + assertFalse(s.prefs.isStn_bandActive144) + } + + @Test + fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest { + val s = Session(preferences()) + s.prefs.loginChatCategoryMain = ChatCategory(2) + showTab { StationTab(s.state) } + + val target = s.state.availableCategories.first { it.categoryNumber == 3 } + val current = s.state.describeCategory(s.prefs.loginChatCategoryMain) + // The main category is the upper of the two pickers; the second may show the same name. + onAllNodesWithText(current).onFirst().performScrollTo().performClick() + waitForIdle() + awaitNode(inPopup(s.state.describeCategory(target))).performClick() + waitForIdle() + + assertEquals(3, s.prefs.loginChatCategoryMain?.categoryNumber) + assertEquals(3, s.mainApplied.single()?.categoryNumber, "the running session was not told") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SynchTabsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SynchTabsScreenTest.kt new file mode 100644 index 00000000..be1b55ca --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SynchTabsScreenTest.kt @@ -0,0 +1,146 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The three integration tabs, drawn: Log synch, TRX synch and Airscout (#107). + * + * All three draw from a facade with no snapshot state in it and count a revision on every + * write so the control redraws. That device is what these tests watch: a write that + * reached the preferences and not the screen is the class-1 fault of `83e23f55`. + * + * The "Choose..." button of the log file is never pressed here: it opens a modal AWT file + * dialog, which has no place in a headless test. + */ +@OptIn(ExperimentalTestApi::class) +class SynchTabsScreenTest { + + /* + * Never the no-argument ChatPreferences: it resolves the operator's real profile and + * seeds preferences.xml there when it is missing. A file in a temporary directory is + * never read or written by these tests, it only gives the preferences a home. + */ + @TempDir + lateinit var directory: Path + + private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml")) + + // ------------------------------------------------------------------ Log synch + + private class LogSynch(isolated: ChatPreferences, detected: String? = "192.168.1.255") { + val prefs = isolated.apply { + // Both listeners default to on; start from off so a tick is a visible change. + isLogsynch_ucxUDPWkdCallListenerEnabled = false + isLogsynch_wintestNetworkListenerEnabled = false + } + var restarts = 0 + var stops = 0 + val state = LogSynchTabState(prefs, { restarts++ }, { stops++ }, { detected }) + } + + @Test + fun logSynchShowsItsSectionsAndTheDetectedBroadcastAddress() = runComposeUiTest { + val s = LogSynch(preferences()) + showTab { LogSynchTab(s.state) } + + onNodeWithText("File polling for worked callsigns").assertExists() + onNodeWithText("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)").assertExists() + onNodeWithText("Win-Test Network-Listener").assertExists() + onNodeWithText("Choose...").assertExists() + fieldBeside("UDP broadcast address for Win-Test (default = internet interface broadcast)") + .assertTextEquals("192.168.1.255") + } + + @Test + fun logSynchWritesThroughAndStartsTheWinTestListener() = runComposeUiTest { + val s = LogSynch(preferences()) + showTab { LogSynchTab(s.state) } + + toggle("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net") + assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled) + checkBeside("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net").assertIsOn() + + type("Shared UDP port for QSO and TRX messages [default 12060]:", "12061") + assertEquals(12061, s.prefs.logsynch_ucxUDPWkdCallListenerPort) + + val winTestPort = "UDP-Port for Win-Test listener (default is 9871)" + // The port field is greyed out until the listener is switched on; greyed out, it + // offers no text input, so it is found by the port it shows. + onNodeWithText(s.prefs.logsynch_wintestNetworkPort.toString()).assertIsNotEnabled() + toggle("Receive Win-Test network based UDP log messages") + assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled) + assertEquals(1, s.restarts, "switching the listener on did not start it") + + type(winTestPort, "9872") + assertEquals(9872, s.prefs.logsynch_wintestNetworkPort) + + // A refused entry is kept on screen as typed, and nothing is written. + type(winTestPort, "98x") + fieldBeside(winTestPort).assertTextEquals("98x") + assertEquals(9872, s.prefs.logsynch_wintestNetworkPort) + + type("KST station name in Win-Test network (src of SKED packets)", " KST2 ") + assertEquals("KST2", s.prefs.logsynch_wintestNetworkStationNameOfKST) + } + + // ------------------------------------------------------------------ TRX synch + + @Test + fun trxSynchWritesThroughAndSwitchesTheQrgFollower() = runComposeUiTest { + val prefs = preferences() + val follower = mutableListOf() + val state = TrxSynchTabState(prefs) { follower += it } + showTab { TrxSynchTab(state) } + + onNodeWithText("Receive UCXLog TRX info").assertExists() + onNodeWithText("What an enabled source does").assertExists() + + toggle("Update MYQRG from RadioInfo messages received on the shared log-sync port") + assertTrue(prefs.isTrxSynch_ucxLogUDPListenerEnabled) + assertEquals(listOf(true), follower) + + toggle("Use pass frequency from Win-Test STATUS (instead of own QRG)") + assertTrue(prefs.isLogsynch_wintestUsePassQrg) + + type("Win-Test station name filter (e.g. STN1, empty = accept all)", "STN1 ") + assertEquals("STN1", prefs.logsynch_wintestNetworkStationNameOfWintestClient1) + } + + // ------------------------------------------------------------------ Airscout + + @Test + fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest { + // The link defaults to on; start from off so a tick is a visible change. + val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false } + val state = AirscoutTabState(prefs) + showTab { AirscoutTab(state) } + + onNodeWithText("AirScout UDP communication").assertExists() + onNodeWithText("Queried frequency").assertExists() + + toggle("Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)") + assertTrue(prefs.isAirScout_asUDPListenerEnabled) + + val port = "AirScout UDP port [9872] — reconnect after changing:" + type(port, "9873") + assertEquals(9873, prefs.airScout_asCommunicationPort) + + type(port, "70000") + fieldBeside(port).assertTextEquals("70000") + assertEquals(9873, prefs.airScout_asCommunicationPort, "an out-of-range port was written") + + type("AirScout server identifier [AS]:", "AS2") + assertEquals("AS2", prefs.airScout_asServerNameString) + } +}