Apply live beacon settings and improve station map (#164)

Apply beacon checkbox changes at runtime and keep the aggregate status chip synchronized.

Improve explicit station-map navigation, spatial filter overlays, overlay legend, selected-path contrast, and the documented map screenshot.
This commit is contained in:
Praktimarc authored and GitHub committed 2026-11-05 00:52:17 +01:00
1 parent 4d96822c0c
commit d925c83412
61 files changed
+2686 -299

No files matched your search

@@ -35,7 +35,9 @@ class SettingsFieldCoverageTest {
""
}
val missing = expected.filterNot { composeSources.contains("$it(") }
val missing = expected.filterNot {
composeSources.contains("$it(") || composeSources.contains("::$it")
}
assertTrue(missing.isEmpty(),
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
@@ -70,12 +70,27 @@ class StationSelectionStateTest {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
val revision = state.scrollRequestRevision
val explicitRevision = state.explicitSelectionRevision
val refreshed = station("DL1ABC", 2)
state.reconcile(refreshed)
assertSame(refreshed, state.selected)
assertEquals(revision, state.scrollRequestRevision)
assertEquals(explicitRevision, state.explicitSelectionRevision)
}
@Test
fun `roster reconciliation does not publish explicit navigation`() {
val state = StationSelectionState()
val requests = mutableListOf<Pair<ChatMember?, StationSelectionSource>>()
state.explicitSelectionListener = { member, source -> requests += member to source }
state.select(station("DL1ABC", 2), StationSelectionSource.PUBLIC_MESSAGE)
state.reconcile(station("DL1ABC", 2))
assertEquals(1, requests.size)
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, requests.single().second)
}
@Test
@@ -0,0 +1,48 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ThreadStateMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class StatusBarScreenTest {
@Test
fun onlyAThreadWithARegisteredActionIsClickable() = runComposeUiTest {
val buttons = ThreadStatusButtons()
var beaconClicks = 0
buttons.registerAction("MyBeacon") { beaconClicks++ }
buttons.update("MyBeacon", status("off"))
buttons.update("Cluster", status("on"))
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StatusBar(
connectionState = null,
connectionDetail = null,
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = buttons,
showConnectionBadge = false,
)
}
}
waitForIdle()
onNodeWithText("MyBeacon: off").assertHasClickAction().performClick()
waitForIdle()
assertEquals(1, beaconClicks)
onNodeWithText("Cluster: on").assertHasNoClickAction()
}
private fun status(description: String): ThreadStateMessage =
ThreadStateMessage("worker", true, description, false).apply {
runningInformationTextDescription = description
}
}
@@ -407,6 +407,52 @@ class StatusIndicatorStateTest {
assertEquals("Cluster: b", button.label)
}
@Test
fun `an action registered before the first report reaches the created button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.registerAction("MyBeacon") { clicks++ }
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `an action registered after a report reaches the existing button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.registerAction("MyBeacon") { clicks++ }
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `a thread without an action cannot be activated`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "cluster", description = "on", info = "running"))
assertFalse(buttons.buttons.single().activate())
}
@Test
fun `a status provider replaces a stale worker snapshot when the UI applies it`() {
val buttons = ThreadStatusButtons()
var current = threadState(nick = "beacon", description = "on", info = "enabled")
buttons.registerStatusProvider("MyBeacon") { current }
val queuedSnapshot = current
current = threadState(nick = "beacon", description = "off", info = "disabled")
buttons.update("MyBeacon", queuedSnapshot)
assertEquals("MyBeacon: off", buttons.buttons.single().label)
assertEquals("disabled", buttons.buttons.single().tooltip)
}
private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
ThreadStateMessage(nick, true, info, false).apply {
setRunningInformationTextDescription(description)
@@ -49,6 +49,8 @@ class ComposeStationMapDrawingTest {
private val workedGridBorder = 0x000000
private val selectedDotBorder = 0xFF9900
/*
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
* construction. The default fetcher read the user's disk cache and the real tile
@@ -71,6 +73,10 @@ class ComposeStationMapDrawingTest {
ownLocator6: String = "",
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
selectedTarget: MapCallsignRawSnapshot? = null,
spatialFilterOverlay: SpatialFilterOverlayState? = null,
antennaBeamWidthDeg: Double = 0.0,
maxQrbKm: Double = 0.0,
): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
@@ -82,8 +88,10 @@ class ComposeStationMapDrawingTest {
selectedMarkerCallsign = null,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = 90.0,
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm,
selectedTarget = selectedTarget,
spatialFilterOverlay = spatialFilterOverlay,
onMarkerClicked = null,
workedGrossFields = workedGrossFields,
darkMode = darkMode,
@@ -108,6 +116,16 @@ class ComposeStationMapDrawingTest {
return count
}
private fun changedPixels(before: BufferedImage, after: BufferedImage): Int {
var changed = 0
for (x in 0 until before.width) {
for (y in 0 until before.height) {
if (before.getRGB(x, y) != after.getRGB(x, y)) changed++
}
}
return changed
}
/** The widest horizontal run of a colour, which for a ring is its outer diameter. */
private fun widestRun(image: BufferedImage, rgb: Int): Int {
var widest = 0
@@ -282,6 +300,54 @@ class ComposeStationMapDrawingTest {
}
}
@Test
fun `active spatial filters add a distinct overlay in both themes`() {
val overlay = SpatialFilterOverlayState(
qtfEnabled = true,
qtfDegrees = 90.0,
beamWidthDeg = 60.0,
minQrbEnabled = true,
minQrbKm = 50.0,
maxQrbEnabled = true,
maxQrbKm = 400.0,
)
for (darkMode in listOf(false, true)) {
val without = render(6f, markers = emptyList(), ownLocator6 = "JO50JP", darkMode = darkMode)
val with = render(
6f,
markers = emptyList(),
ownLocator6 = "JO50JP",
darkMode = darkMode,
spatialFilterOverlay = overlay,
)
assertTrue(changedPixels(without, with) > 1_000) {
"the spatial filter overlay was not visible in darkMode=$darkMode"
}
}
}
@Test
fun `a filtered selected target is drawn without joining the normal marker list`() {
val targetLocation = kst4contest.locatorUtils.Location("JO51AA")
val target = station(
"DL1HIDE",
"JO51AA",
targetLocation.latitude.toDegrees(),
targetLocation.longitude.toDegrees(),
)
val image = render(
6f,
markers = emptyList(),
ownLocator6 = "JO50JP",
selectedTarget = target,
)
assertTrue(countColour(image, selectedDotBorder) > 0, "the separate selected marker is missing")
}
/**
* Every grid line has to be drawn once.
*
@@ -0,0 +1,29 @@
package kst4contest.view.compose.map
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class MapDisplaySettingsStateTest {
@TempDir
lateinit var directory: Path
@Test
fun `refresh publishes applied beam geometry from preferences`() {
val preferences = ChatPreferences(directory.resolve("preferences.xml")).apply {
stn_antennaBeamWidthDeg = 50.0
stn_maxQRBDefault = 750.0
}
val state = MapDisplaySettingsState(preferences)
preferences.stn_antennaBeamWidthDeg = 72.0
preferences.stn_maxQRBDefault = 1_100.0
state.refreshFromPreferences()
assertEquals(72.0, state.antennaBeamWidthDeg)
assertEquals(1_100.0, state.maxQrbKm)
}
}
@@ -0,0 +1,42 @@
package kst4contest.view.compose.map
import kst4contest.view.compose.StationIdentity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MapNavigationStateTest {
@Test
fun `repeated station choices remain observable`() {
val state = MapNavigationState()
val identity = StationIdentity("DL1ABC", 2)
state.fitSelectedPath(identity)
val firstRevision = state.requestRevision
state.fitSelectedPath(identity)
assertTrue(state.requestRevision > firstRevision)
assertEquals(MapNavigationRequest.FitSelectedPath(identity), state.request)
}
@Test
fun `latest explicit action replaces an older one`() {
val state = MapNavigationState()
state.fitSelectedPath(StationIdentity("DL1ABC", 2))
state.centerOnQtf(45.0)
assertEquals(MapNavigationRequest.CenterOnQtf(45.0), state.request)
}
@Test
fun `invalid QTF values do not create requests`() {
val state = MapNavigationState()
listOf(Double.NaN, Double.POSITIVE_INFINITY, -1.0, 360.1).forEach(state::centerOnQtf)
assertEquals(0L, state.requestRevision)
assertEquals(null, state.request)
}
}
@@ -0,0 +1,60 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
@OptIn(ExperimentalTestApi::class)
class MapOverlayLegendScreenTest {
@Test
fun germanEveningLegendWrapsBothItemsInsideANarrowHeader() = runComposeUiTest {
var storedLanguage = "de"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.GERMANY,
)
try {
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Box(Modifier.width(110.dp)) {
MapOverlayLegend(
showAntenna = true,
showSpatialFilters = true,
darkMode = true,
)
}
}
}
}
onNodeWithText("Antenne").assertIsDisplayed()
onNodeWithText("Aktive Filter").assertIsDisplayed()
val antenna = onNodeWithTag(MAP_ANTENNA_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
val filters = onNodeWithTag(MAP_FILTER_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
val widthLimit = with(density) { 110.dp.toPx() }
assertTrue(antenna.right <= widthLimit + 0.5f)
assertTrue(filters.right <= widthLimit + 0.5f)
assertTrue(filters.top >= antenna.bottom, "the legend items did not wrap at narrow width")
} finally {
runOnIdle { languages.use("en") }
}
}
}
@@ -0,0 +1,91 @@
package kst4contest.view.compose.map
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SpatialFilterOverlayStateTest {
@Test
fun `maximum QRB is inclusive and minimum QRB is exclusive`() {
val state = overlay(minEnabled = true, minKm = 200.0, maxEnabled = true, maxKm = 500.0)
assertFalse(state.accepts(200.0, 90.0))
assertTrue(state.accepts(200.1, 90.0))
assertTrue(state.accepts(500.0, 90.0))
assertFalse(state.accepts(500.1, 90.0))
}
@Test
fun `QTF uses the same wrapped beamwidth semantics as the station filter`() {
val state = overlay(qtf = true, direction = 0.0, beamWidth = 60.0)
assertTrue(state.accepts(300.0, 350.0))
assertTrue(state.accepts(300.0, 30.0))
assertFalse(state.accepts(300.0, 31.0))
}
@Test
fun `unusual full-circle beamwidth retains the existing station filter semantics`() {
val state = overlay(qtf = true, direction = 0.0, beamWidth = 360.0)
assertTrue(state.accepts(300.0, 180.0))
assertFalse(state.accepts(300.0, 0.0))
}
@Test
fun `minimum above maximum is an empty distance range`() {
val state = overlay(minEnabled = true, minKm = 500.0, maxEnabled = true, maxKm = 200.0)
assertTrue(state.hasEmptyDistanceRange)
assertFalse(state.accepts(100.0, 0.0))
assertFalse(state.accepts(300.0, 0.0))
assertFalse(state.accepts(600.0, 0.0))
}
@Test
fun `non-finite active values cannot be rendered as a misleading area`() {
assertFalse(overlay(qtf = true, direction = Double.NaN).hasRenderableValues)
assertFalse(overlay(minEnabled = true, minKm = Double.POSITIVE_INFINITY).hasRenderableValues)
assertFalse(overlay(maxEnabled = true, maxKm = Double.NaN).hasRenderableValues)
}
@Test
fun `zero distance boundaries report only actually drawable geometry`() {
assertFalse(overlay(maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertFalse(overlay(qtf = true, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertFalse(
overlay(minEnabled = true, minKm = 0.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry
)
assertTrue(overlay(minEnabled = true, minKm = 0.0).hasDrawableGeometry)
assertTrue(overlay(qtf = true).hasDrawableGeometry)
assertTrue(overlay(minEnabled = true, minKm = 100.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertTrue(overlay(maxEnabled = true, maxKm = 500.0).hasDrawableGeometry)
}
@Test
fun `no spatial filter accepts finite positions`() {
val state = overlay()
assertFalse(state.isActive)
assertTrue(state.accepts(1000.0, 275.0))
}
private fun overlay(
qtf: Boolean = false,
direction: Double = 0.0,
beamWidth: Double = 60.0,
minEnabled: Boolean = false,
minKm: Double = 0.0,
maxEnabled: Boolean = false,
maxKm: Double = 0.0,
) = SpatialFilterOverlayState(
qtfEnabled = qtf,
qtfDegrees = direction,
beamWidthDeg = beamWidth,
minQrbEnabled = minEnabled,
minQrbKm = minKm,
maxQrbEnabled = maxEnabled,
maxQrbKm = maxKm,
)
}
@@ -4,7 +4,6 @@ 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.assertCountEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText
@@ -42,7 +41,6 @@ import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
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
@@ -231,7 +229,10 @@ class StationMapWindowScreenTest {
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { member, _ -> selectorCalls += member }
state.stationSelector = { member, source ->
selectorCalls += member
state.selectedStation.select(member, source)
}
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
@@ -240,11 +241,7 @@ class StationMapWindowScreenTest {
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(null, state.selectedStation.selected)
assertEquals(15f, mapState.zoom, "reset changed the zoom")
val home = Location(ownLocator)
@@ -369,12 +366,9 @@ class StationMapWindowScreenTest {
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
}
/**
* 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.
*/
/** An explicit selection fits both ends and still starts the existing path analysis. */
@Test
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
fun selectingAStationFitsTheCompletePath() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val analysis = RecordingPathAnalysis()
@@ -382,53 +376,49 @@ class StationMapWindowScreenTest {
show(state, listOf(dl1abc), mapState, analysis)
runOnIdle { state.selectedStation.select(dl1abc) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
waitForIdle()
val home = Location(ownLocator)
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")
assertTrue(mapState.centerLon in home.longitude.toDegrees()..target.longitude.toDegrees())
assertTrue(mapState.centerLat in home.latitude.toDegrees()..target.latitude.toDegrees())
assertTrue(mapState.zoom < 15f, "a path too long for zoom 15 was not fitted")
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.
*/
/** Grouping never forces a path which already fits to zoom in. */
@Test
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
fun groupingDoesNotZoomInWhileFittingASelectedPath() = runComposeUiTest {
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
val state = windowState(prefs)
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val far = mapLookingAt(ownLocator, zoom = 5f)
val dl1abc = station("DL1ABC", 2, locator = "JO40HB")
val mapState = mapLookingAt(ownLocator, zoom = 5f)
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
show(state, listOf(dl1abc), mapState, RecordingPathAnalysis())
runOnIdle { state.selectedStation.select(dl1abc) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
waitForIdle()
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
assertEquals(5f, mapState.zoom)
}
/** Without a running application there is nothing to ask, and the map stays put. */
/** Camera navigation is independent of the optional path-analysis service. */
@Test
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
fun withoutAPathAnalysisASelectionStillFitsThePath() = 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) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
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)
assertTrue(mapState.zoom < 15f)
}
/** The two header switches write the profile, so the map opens the same way next time. */
@@ -477,6 +467,54 @@ class StationMapWindowScreenTest {
assertEquals(listOf(true, false), applied)
}
@Test
fun headerLegendFollowsDrawableAntennaAndSpatialFilterOverlays() = runComposeUiTest {
val prefs = prefs()
val state = windowState(prefs)
show(state, emptyList(), MapState())
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertDoesNotExist()
runOnIdle {
state.stationFilter.maxQrbKm = 666.0
state.stationFilter.maxQrbEnabled = true
}
waitForIdle()
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertExists()
runOnIdle {
prefs.stn_antennaBeamWidthDeg = 0.0
prefs.stn_maxQRBDefault = 0.0
state.refreshMapDisplaySettings()
state.stationFilter.maxQrbEnabled = false
}
waitForIdle()
onNodeWithText("Antenna").assertDoesNotExist()
onNodeWithText("Active filters").assertDoesNotExist()
}
@Test
fun invalidSpatialFilterDoesNotClaimAVisibleOverlay() = runComposeUiTest {
val state = windowState(prefs())
state.stationFilter.maxQrbKm = 0.0
state.stationFilter.maxQrbEnabled = true
show(state, emptyList(), MapState())
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertDoesNotExist()
runOnIdle { state.stationFilter.maxQrbKm = Double.NaN }
waitForIdle()
onNodeWithText("Active filters").assertDoesNotExist()
}
@Test
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
val state = windowState(prefs())
@@ -553,13 +591,9 @@ class StationMapWindowScreenTest {
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
}
/**
* A selected station the filters hide has no marker, and so nothing to analyse: no
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
* The visible station beside it is the control: selecting it does ask and does load.
*/
/** A filtered-out selection remains a separate target without entering the marker count. */
@Test
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
fun aSelectedStationTheFiltersHideKeepsItsTargetAndPathAnalysis() = runComposeUiTest {
val state = windowState(prefs(pathAnalysisVisible = true))
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
@@ -568,21 +602,128 @@ class StationMapWindowScreenTest {
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 9f)
val home = Location(ownLocator)
show(state, listOf(hidden, shown), mapState, analysis)
runOnIdle { state.selectedStation.select(hidden) }
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
waitForIdle()
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
assertEquals(listOf(hidden), analysis.requested, "the selected hidden target was not analysed")
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
onNodeWithText("Showing 1 visible stations | filtered view active", substring = true).assertExists()
runOnIdle { state.selectedStation.select(shown) }
runOnIdle { state.selectStation(shown, StationSelectionSource.PRIORITY) }
waitForIdle()
assertEquals(listOf(shown), analysis.requested)
assertEquals(listOf(hidden, shown), analysis.requested)
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
}
@Test
fun filteredSelectedMarkerWinsHitTestingWhenAnotherMarkerOverlapsIt() = runComposeUiTest {
val state = windowState(prefs())
val hidden = station("DL1HIDE", 2, locator = ownLocator).apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = ownLocator).apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val selections = mutableListOf<ChatMember?>()
state.stationSelector = { member, source ->
selections += member
state.selectedStation.select(member, source)
}
show(state, listOf(hidden, shown), mapLookingAt(ownLocator, zoom = 12f))
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
waitForIdle()
selections.clear()
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { click() }
waitForIdle()
assertEquals(listOf(hidden), selections)
}
@Test
fun rosterReconciliationDoesNotOverwriteManualCameraMovement() = runComposeUiTest {
val state = windowState(prefs())
val selected = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 10f)
show(state, listOf(selected), mapState, RecordingPathAnalysis())
runOnIdle { state.selectStation(selected, StationSelectionSource.PUBLIC_MESSAGE) }
waitForIdle()
runOnIdle {
mapState.centerLon = 3.0
mapState.centerLat = 48.0
mapState.zoom = 11f
state.stations.replaceRows(listOf(station("DL1ABC", 2, locator = "JO50JP")))
}
waitForIdle()
assertEquals(3.0, mapState.centerLon, 1e-9)
assertEquals(48.0, mapState.centerLat, 1e-9)
assertEquals(11f, mapState.zoom)
}
@Test
fun explicitQtfNavigationPreservesZoomAndFilterResetDoesNotNavigate() = runComposeUiTest {
val state = windowState(prefs())
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
show(state, emptyList(), mapState)
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
waitForIdle()
val qtfLon = mapState.centerLon
val qtfLat = mapState.centerLat
assertEquals(9.5f, mapState.zoom)
assertTrue(qtfLon > Location(ownLocator).longitude.toDegrees())
assertTrue(qtfLat > Location(ownLocator).latitude.toDegrees())
runOnIdle { state.resetStationFilters() }
waitForIdle()
assertEquals(qtfLon, mapState.centerLon, 1e-9)
assertEquals(qtfLat, mapState.centerLat, 1e-9)
assertEquals(9.5f, mapState.zoom)
}
@Test
fun QtfNavigationDoesNotRequireOrChangeManualAntennaHeading() = runComposeUiTest {
val prefs = prefs().apply {
setStn_useQtfFilterDirectionAsAntennaHeading(false)
actualQTF.set(123.0)
}
val state = windowState(prefs)
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
show(state, emptyList(), mapState)
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
waitForIdle()
assertTrue(mapState.centerLon > Location(ownLocator).longitude.toDegrees())
assertTrue(mapState.centerLat > Location(ownLocator).latitude.toDegrees())
assertEquals(9.5f, mapState.zoom)
assertEquals(123.0, prefs.actualQTF.get(), 1e-9)
}
@Test
fun selectingTheSameStationAgainRepeatsPathNavigation() = runComposeUiTest {
val state = windowState(prefs())
val selected = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 10f)
show(state, listOf(selected), mapState, RecordingPathAnalysis())
runOnIdle { state.selectStation(selected, StationSelectionSource.TIMELINE) }
waitForIdle()
val firstCentre = mapState.centerLon to mapState.centerLat
runOnIdle {
mapState.centerLon = 2.0
mapState.centerLat = 45.0
state.selectStation(selected, StationSelectionSource.TIMELINE)
}
waitForIdle()
assertEquals(firstCentre.first, mapState.centerLon, 1e-9)
assertEquals(firstCentre.second, mapState.centerLat, 1e-9)
}
}
@@ -41,7 +41,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `beacon edits wait for apply, and the timer restarts only then`() {
fun `enable changes immediately while template and interval wait for apply`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
@@ -51,7 +51,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat(), "the runtime switch did not take effect immediately")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
@@ -65,6 +65,18 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(1, s.timerRestarts)
}
@Test
fun `enable changes do not become pending draft edits`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
@@ -162,8 +162,6 @@ class DraftedSettingsTableTest {
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
@@ -3,6 +3,7 @@ package kst4contest.view.compose.tabs
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
@@ -190,6 +191,19 @@ class MessagingTabsScreenTest {
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
}
@Test
fun anExternalBeaconToggleUpdatesTheOpenSettingsTab() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.isBcn_beaconsEnabledMainCat = false
showTab { BeaconTab(s.state) { s.refusals += it } }
checkBeside("Send the beacon in this category").assertIsOff()
runOnIdle { s.prefs.isBcn_beaconsEnabledMainCat = true }
waitForIdle()
checkBeside("Send the beacon in this category").assertIsOn()
}
/*
* The tab reads the interval from a plain preference getter, so nothing recomposes it
* after Enter commits. Compared with that stale value, leaving the field afterwards
@@ -107,14 +107,14 @@ class SettingsApplyScreenTest {
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
fun beaconEnableTakesEffectImmediatelyWhileTheIntervalWaitsForApply() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the runtime switch did not take effect immediately")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
@@ -178,19 +178,19 @@ class SettingsApplyScreenTest {
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
/*
@@ -264,7 +264,7 @@ class SettingsApplyScreenTest {
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
@@ -273,7 +273,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(w.closed)
}
@@ -288,7 +288,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the immediate runtime switch was unexpectedly reverted")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
@@ -299,7 +299,7 @@ class SettingsApplyScreenTest {
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -307,7 +307,7 @@ class SettingsApplyScreenTest {
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
@@ -377,13 +377,13 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@@ -393,7 +393,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -408,14 +408,14 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
@Test
@@ -423,7 +423,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -432,7 +432,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "applied despite the refusal")
}
@Test
@@ -457,7 +457,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -477,7 +477,7 @@ class SettingsApplyScreenTest {
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
runOnIdle { w.x() }
@@ -489,7 +489,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@@ -20,6 +20,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val secondRotatorCalls = mutableListOf<Unit>()
var beaconStatusRefreshes = 0
var mapDisplayRefreshes = 0
val draft = SettingsDraft()
val state = StationTabState(
prefs,
@@ -27,6 +29,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
applySecondRotator = { secondRotatorCalls.add(Unit) },
refreshBeaconStatus = { beaconStatusRefreshes++ },
applyMapDisplaySettings = { mapDisplayRefreshes++ },
)
}
@@ -305,6 +309,34 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
assertEquals(true, state.loginToSecondChatEnabled)
}
@Test
fun `applying the second chat switch refreshes aggregate beacon status`() {
val session = Session()
session.state.loginToSecondChatEnabled = true
assertEquals(0, session.beaconStatusRefreshes)
session.draft.apply()
assertEquals(1, session.beaconStatusRefreshes)
}
@Test
fun `beam width and maximum QRB refresh the map once after both are applied`() {
val session = Session()
session.state.stn_antennaBeamWidthDeg = 72.0
session.state.stn_maxQRBDefault = 1_250.0
assertEquals(0, session.mapDisplayRefreshes)
val outcome = session.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(72.0, session.prefs.stn_antennaBeamWidthDeg, 0.0001)
assertEquals(1_250.0, session.prefs.stn_maxQRBDefault, 0.0001)
assertEquals(1, session.mapDisplayRefreshes)
}
@Test
fun `the own height above sea level reads empty while the elevation model decides`() {
val s = Session()