mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
* 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) and83e23f55(unthemed Swing menu bar), both in main as2a5c89b9. 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 (both21251377), 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 as2a5c89b9): 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 in21251377: 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 switchc8ce0e98restored, 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 beforefe4215bc). 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 thatcc8c9020fixed. 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:
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, band, worked) -> formatBandCellStatus(member, band, worked),
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
|
||||
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|"
|
||||
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
|
||||
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
|
||||
"compose-stations",
|
||||
widths);
|
||||
|
||||
|
||||
@@ -75,8 +75,9 @@ object MonitorWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal rather than private so the window's body can be drawn headless in a test. */
|
||||
@Composable
|
||||
private fun MonitorContent(
|
||||
internal fun MonitorContent(
|
||||
clusterTable: DataTableState<ClusterMessage>,
|
||||
qsoTable: DataTableState<ChatMessage>,
|
||||
) {
|
||||
|
||||
@@ -26,6 +26,19 @@ object StationColumns {
|
||||
/** The heading the ten worked columns sit under. */
|
||||
const val WORKED_GROUP = "worked"
|
||||
|
||||
/**
|
||||
* The identity a station row is drawn and selected under: the callsign and the chat
|
||||
* category. The category is part of it because the same callsign can be in two chats at
|
||||
* once, and AGENTS.md makes the category part of the chat identity; keyed by callsign
|
||||
* alone, the two rows collided (fixed in 21251377).
|
||||
*
|
||||
* Kept here rather than inline at the one call site so the station list can be tested
|
||||
* with the key it really uses. The text is exactly what the inline Java expression
|
||||
* produced, including "null" for a missing callsign.
|
||||
*/
|
||||
fun rowKey(member: ChatMember): String =
|
||||
"${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}"
|
||||
|
||||
fun all(
|
||||
tropoOf: (ChatMember) -> String,
|
||||
priorityScoreOf: (ChatMember) -> Double,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -59,8 +60,6 @@ object StationMapWindow {
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
) { _ ->
|
||||
val strings = LocalStrings.current
|
||||
|
||||
val window = this.window
|
||||
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
|
||||
val tileFetcher = remember { TileFetcher() }
|
||||
@@ -74,6 +73,37 @@ object StationMapWindow {
|
||||
onDispose { tileFetcher.close() }
|
||||
}
|
||||
|
||||
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
|
||||
} // host.show content
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's body, without the window.
|
||||
*
|
||||
* Split out of [show] so it can be drawn headless in a test, with a tile fetcher that
|
||||
* stays off the network and the user's tile cache, a roster that is not the running
|
||||
* application's, and a [MapState] the test can read. The defaults are exactly what
|
||||
* [show] used inline before the split.
|
||||
*
|
||||
* @param chatMembers the roster a marker click looks its station up in; a marker stands
|
||||
* for a raw callsign, and this is where its category variants come from
|
||||
* @param mapState where the map is looking
|
||||
* @param pathAnalysis asked each time the selection changes; null means there is no
|
||||
* running application, and then the map neither pans nor analyses, exactly as
|
||||
* when no chat controller existed before the split
|
||||
*/
|
||||
@Composable
|
||||
internal fun Content(
|
||||
mainWindowState: MainWindowState,
|
||||
tileFetcher: TileFetcher,
|
||||
chatMembers: () -> List<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
|
||||
* 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) }
|
||||
val mapState = remember { MapState() }
|
||||
|
||||
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
||||
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
||||
|
||||
LaunchedEffect(selectedMember) {
|
||||
if (selectedMember == null) {
|
||||
@@ -126,8 +158,8 @@ object StationMapWindow {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
if (chatController != null) {
|
||||
val analysis = currentPathAnalysis()
|
||||
if (analysis != null) {
|
||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
|
||||
@@ -155,13 +187,7 @@ object StationMapWindow {
|
||||
selectedSnapshot?.callSignRaw() ?: ""
|
||||
)
|
||||
|
||||
val overrideBand: kst4contest.model.Band? = null
|
||||
|
||||
chatController.reachabilityService.requestPathAnalysisForMap(
|
||||
selectedMember,
|
||||
selectedSnapshot,
|
||||
overrideBand
|
||||
) { result ->
|
||||
analysis.request(selectedMember, selectedSnapshot) { result ->
|
||||
pathAnalysisResult = result
|
||||
}
|
||||
}
|
||||
@@ -277,10 +303,8 @@ object StationMapWindow {
|
||||
maxQrbKm = maxQrbKm,
|
||||
darkMode = isDarkTheme,
|
||||
onMarkerClicked = { snapshot ->
|
||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
val variants = chatController?.lst_chatMemberList?.snapshot()
|
||||
?.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||
.orEmpty()
|
||||
val variants = chatMembers()
|
||||
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||
/*
|
||||
* A marker stands for a raw callsign, not a category. Keep the
|
||||
* category already selected when it is one of the variants.
|
||||
@@ -298,7 +322,7 @@ object StationMapWindow {
|
||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
|
||||
)
|
||||
|
||||
MapOverlayControls(
|
||||
@@ -361,8 +385,10 @@ object StationMapWindow {
|
||||
} // main layout column
|
||||
} // window column
|
||||
} // Surface
|
||||
} // host.show content
|
||||
}
|
||||
} // Content
|
||||
|
||||
/** Marks the map canvas so a test can click a marker on it. */
|
||||
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) {
|
||||
@@ -397,3 +423,34 @@ object StationMapWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the path analysis of the station selected on the map.
|
||||
*
|
||||
* The seam between the map window and the running application: the window pans to the
|
||||
* station and starts the analysis only when there is one to ask, which a test supplies
|
||||
* without building a chat controller.
|
||||
*/
|
||||
internal fun interface MapPathAnalysis {
|
||||
fun request(
|
||||
member: kst4contest.model.ChatMember,
|
||||
snapshot: kst4contest.view.map.MapCallsignRawSnapshot?,
|
||||
onResult: (PathAnalysisResult) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The running application's reachability service, or null before there is one.
|
||||
*
|
||||
* Looked up on every call, as the window did inline: a profile switch replaces the runtime.
|
||||
* No band override -- the map always analyses on the automatically resolved band.
|
||||
*/
|
||||
private fun runtimePathAnalysis(): MapPathAnalysis? {
|
||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null
|
||||
|
||||
return MapPathAnalysis { member, snapshot, onResult ->
|
||||
chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result ->
|
||||
onResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.view.compose.DataTable
|
||||
import kst4contest.view.compose.Form
|
||||
@@ -134,6 +135,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
|
||||
onValueChange = { entered = it },
|
||||
singleLine = true,
|
||||
label = { Text(strings.notificationCallsign) },
|
||||
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -149,3 +151,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */
|
||||
internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field"
|
||||
@@ -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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
+384
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user