Compose screen tests with runComposeUiTest (#107) (#125)

* test: draw and click the in-window menu row (#107)

Pins what MainMenuModelTest cannot see: every item picked through the
drawn row runs its own action, the enabled states and the away and
window-toggle labels follow their state while the menu is down, and a
palette or language change reaches the row on screen.

Named regressions: fe4215bc (menu actions hung on deleted JavaFX
MenuItems) and 83e23f55 (unthemed Swing menu bar), both in main as
2a5c89b9.

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

* test: draw the station list with its real row key (#107)

The station row key moves from an inline Java lambda into
StationColumns.rowKey, unchanged in its text, so the list can be tested
with the key the application really uses.

The screen test pins three regressions on the drawn table: the same
callsign in two categories collided under one key and sorting dropped
the filters (both 21251377), and the AP column sorted by its text
(ab282162, #87). It also pins that rows replaced after the first draw
reach the screen and that the selection follows a station by key.

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

* test: draw the monitor window's live tables (#107)

MonitorContent becomes internal so its body can be drawn headless.

The screen test pins the defects the port shipped in this window
(cc8c9020, in main as 2a5c89b9): rows arriving while the window is
open, also after a redraw that was not about the rows, must reach both
tables, and two messages that read alike stay two rows.

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

* test: draw and click the station map window's body (#107)

StationMapWindow.show now hands its body to an internal Content
composable that takes the tile fetcher, the member roster and the map
state as parameters, with the old inline values as defaults, and the
map canvas carries a test tag.

The screen test pins the map selection fixed in 21251377: a marker
click and Reset view go through the central station selector, a click
keeps the category variant already selected and prepares "/cq CALL",
and neither changes the zoom. The header switches write the profile.
Tiles never arrive: an empty temporary cache and a failing source.

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

* test: draw the Station, Log synch, TRX synch and Airscout tabs (#107)

Each tab is composed headless and checked for its sections and key
controls, and editing a control through the drawn form must write
through to ChatPreferences and redraw. Refused entries stay on screen
as typed and leave the stored value alone. A small helper finds a
control by the label beside it, the way the operator finds it.

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

* test: draw the Message handling, GUI, Beacon and Notification tabs (#107)

Each tab is composed headless with its sections and key controls, and
edits through the drawn controls reach ChatPreferences and the
application callbacks: the auto answer for both categories, the
debug-to-file switch c8ce0e98 restored, the startup design and the
language, the beacon templates and interval, the cluster server, the
fallback band, the test spot and the monitored callsign dialog.

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

* test: draw the Shortcuts and Worked database tabs (#107)

The shortcut and snippet lists show what the profile holds, and an edit
or an added entry made in the drawn list reaches the roster and the
refresh hook the button row depends on (the hook that was called into
thin air before fe4215bc). The worked database lists its rows, re-reads
them on refresh, asks before a reset and leaves the lists alone when
the database reset fails.

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

* test: draw the Profiles and Colours tabs (#107)

Profiles: the default profile and every action are on screen, deleting
it is refused after the confirmation, a cancelled New dialog creates
nothing, and switching hands the selected profile to the application.
Only a temporary registry is touched.

Colours: the six roles show the resolved colour, a typed colour is
committed into the store, a mistyped one is refused and put back, the
shipped reset undoes a change, and the stylesheet export writes once
into a temporary profile directory and never over an existing file.

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

* refactor: give the map's path analysis a seam and tag the monitoring field (#107)

StationMapWindow.Content takes a MapPathAnalysis supplier. The default
looks the running application's reachability service up on every
selection, exactly as the inline chat-controller lookup did, and null
still means: no pan, no analysis. This lets a test run the zoom rule
on selection that cc8c9020 fixed.

The callsign field of the add-to-monitoring dialog in NotificationTab
carries a test tag, so a test types into it by name rather than by
position.

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

* test: isolate and harden the screen tests after review (#107)

- No screen test builds ChatPreferences() any more: that constructor
  resolves the real user profile and seeds preferences.xml there. Each
  class uses ChatPreferences(Path) under a @TempDir.
- Compose state changed from the test thread (roster refresh, language
  reset) goes through runOnIdle.
- Dialogs and dropdowns are waited for with waitUntil and found inside
  their overlay (isDialog / isPopup) instead of by tree order; the
  monitored-callsign field is found by its test tag.
- The menu test now asserts Disconnect when online and Connect when
  offline instead of skipping one.
- The map test runs the zoom-on-selection rule through the new seam,
  closes its tile fetcher after each test and keeps its cache in the
  @TempDir.
- StationColumns.rowKey is pinned directly: two categories, two keys.
- The menu colour is sampled further inside the row, and the station
  list click picks its row by position.

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

* test: prove the map's selection effect ran when no analysis is available (#107)

Review follow-up. The no-analysis test only asserted that the map stayed
put, which would also hold if the selection effect never ran; it now
counts the analysis lookup. The effect reads the lookup through
rememberUpdatedState, so a caller passing a new lambda is not served a
stale one.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 10:21:14 +02:00
1 parent 2db71ee864
commit 4b10d96091
17 files changed
+2299 -22

No files matched your search

@@ -3034,8 +3034,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
member -> parseTableDouble(formatPriorityScore(member)), member -> parseTableDouble(formatPriorityScore(member)),
(member, band, worked) -> formatBandCellStatus(member, band, worked), (member, band, worked) -> formatBandCellStatus(member, band, worked),
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())), BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|" RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
"compose-stations", "compose-stations",
widths); widths);
@@ -75,8 +75,9 @@ object MonitorWindow {
} }
} }
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable @Composable
private fun MonitorContent( internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
) { ) {
@@ -26,6 +26,19 @@ object StationColumns {
/** The heading the ten worked columns sit under. */ /** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked" 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( fun all(
tropoOf: (ChatMember) -> String, tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double, priorityScoreOf: (ChatMember) -> Double,
@@ -10,6 +10,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -59,8 +60,6 @@ object StationMapWindow {
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
) { _ -> ) { _ ->
val strings = LocalStrings.current
val window = this.window val window = this.window
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) } androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
val tileFetcher = remember { TileFetcher() } val tileFetcher = remember { TileFetcher() }
@@ -74,6 +73,37 @@ object StationMapWindow {
onDispose { tileFetcher.close() } 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<kst4contest.model.ChatMember> = {
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 * 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 * own. Polled rather than wired through a callback: this drives one line of
@@ -118,7 +148,9 @@ object StationMapWindow {
} }
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) } var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(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) { LaunchedEffect(selectedMember) {
if (selectedMember == null) { if (selectedMember == null) {
@@ -126,8 +158,8 @@ object StationMapWindow {
return@LaunchedEffect return@LaunchedEffect
} }
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController val analysis = currentPathAnalysis()
if (chatController != null) { if (analysis != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw } val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) { if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
@@ -155,13 +187,7 @@ object StationMapWindow {
selectedSnapshot?.callSignRaw() ?: "" selectedSnapshot?.callSignRaw() ?: ""
) )
val overrideBand: kst4contest.model.Band? = null analysis.request(selectedMember, selectedSnapshot) { result ->
chatController.reachabilityService.requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
overrideBand
) { result ->
pathAnalysisResult = result pathAnalysisResult = result
} }
} }
@@ -277,10 +303,8 @@ object StationMapWindow {
maxQrbKm = maxQrbKm, maxQrbKm = maxQrbKm,
darkMode = isDarkTheme, darkMode = isDarkTheme,
onMarkerClicked = { snapshot -> onMarkerClicked = { snapshot ->
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController val variants = chatMembers()
val variants = chatController?.lst_chatMemberList?.snapshot() .filter { it.callSignRaw == snapshot.callSignRaw() }
?.filter { it.callSignRaw == snapshot.callSignRaw() }
.orEmpty()
/* /*
* A marker stands for a raw callsign, not a category. Keep the * A marker stands for a raw callsign, not a category. Keep the
* category already selected when it is one of the variants. * category already selected when it is one of the variants.
@@ -298,7 +322,7 @@ object StationMapWindow {
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true) mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
) )
MapOverlayControls( MapOverlayControls(
@@ -361,8 +385,10 @@ object StationMapWindow {
} // main layout column } // main layout column
} // window column } // window column
} // Surface } // 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 @JvmStatic
fun applyDarkMode(darkMode: Boolean) { 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)
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band import kst4contest.model.Band
import kst4contest.view.compose.DataTable import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form import kst4contest.view.compose.Form
@@ -134,6 +135,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
onValueChange = { entered = it }, onValueChange = { entered = it },
singleLine = true, singleLine = true,
label = { Text(strings.notificationCallsign) }, 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"
@@ -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<String>()
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") }
}
}
}
@@ -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<ClusterMessage>(), "dx-cluster-monitor")
private fun qsoTable() =
DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference<ChatMessage>(), "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<ClusterMessage>,
qsos: DataTableState<ChatMessage>,
) {
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()
}
}
@@ -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())
@@ -162,4 +162,27 @@ class StationColumnsTest {
"reachability is computed by the controller, not carried by the row") "reachability is computed by the controller, not carried by the row")
assertEquals("250", columns.first { it.id == "score" }.value(member)) 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)))
}
} }
@@ -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<ChatMember> = 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<Pair<Int, Int>>? = 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<ChatMember>,
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<String> =
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<ChatMember>()
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)
}
}
@@ -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<ChatMember>()
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<ChatMessage>()),
publicMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
clusterMessages = DataTableState(emptyList(), RowKeys.byReference<ClusterMessage>()),
qsoOfTheOther = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStationMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
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<ChatMember>,
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<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(listOf<ChatMember?>(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<ChatMember?>()
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<ChatMember?>()
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)
}
}
@@ -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<ChatMember>().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")
}
}
@@ -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<Boolean>()
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<String>()
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<String>()
val serverEnabled = mutableListOf<Boolean>()
val ports = mutableListOf<Int>()
val spots = mutableListOf<ChatMember>()
val refusals = mutableListOf<String>()
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}")
}
}
@@ -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<OperatorProfile> = 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<OperatorProfile>()
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<String>()
val outcomes = mutableListOf<String>()
}
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())
}
}
@@ -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>) -> 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()
}
@@ -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<ChatCategory?>()
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")
}
}
@@ -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<Boolean>()
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)
}
}