mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 15:45:58 +01:00
feat: improve manual QTF heading control
This commit is contained in:
1 parent
4be6c178f8
commit
1cda9b4ad9
29 files changed
+663
-19
No files matched your search
@@ -122,6 +122,9 @@ station.password=Passwort
|
||||
station.pathSection=Vorgaben für die Streckenanalyse
|
||||
station.qtf=QTF
|
||||
station.rotatorEnabled=Rotorabgleich aktiv
|
||||
station.manualAntennaHeadingSection=Manuelle Antennenrichtung
|
||||
station.manualAntennaHeadingRotatorHint=Nicht verfügbar, solange PSTRotator 1 aktiviert ist
|
||||
station.useQtfFilterDirectionAsAntennaHeading=QTF-Filterrichtung als eigene Antennenrichtung übernehmen
|
||||
station.rotatorHost=Rotor-Host
|
||||
station.rotatorPort=Rotor-Port
|
||||
station.rotatorSection=PSTRotator
|
||||
|
||||
@@ -131,6 +131,9 @@ station.password=Password
|
||||
station.pathSection=Path analysis defaults
|
||||
station.qtf=QTF
|
||||
station.rotatorEnabled=Rotator sync enabled
|
||||
station.manualAntennaHeadingSection=Manual antenna heading
|
||||
station.manualAntennaHeadingRotatorHint=Unavailable while PSTRotator 1 is enabled
|
||||
station.useQtfFilterDirectionAsAntennaHeading=Use QTF filter direction as antenna heading
|
||||
station.rotatorHost=Rotator host
|
||||
station.rotatorPort=Rotator port
|
||||
station.rotatorSection=PSTRotator
|
||||
|
||||
@@ -64,6 +64,9 @@ internal object Density {
|
||||
/** Between buttons in a row. */
|
||||
val BUTTON_GAP = 6.dp
|
||||
|
||||
/** Compact inset for a visually grouped settings section. */
|
||||
val GROUP_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
||||
|
||||
/** Around a selected row, and the hairline between rows. */
|
||||
val SELECTION_BORDER = 1.5.dp
|
||||
|
||||
|
||||
@@ -346,19 +346,29 @@ object Form {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
fun check(
|
||||
label: String,
|
||||
value: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val draft = rememberDraft(value)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
CompactCheckbox(draft.value) { ticked ->
|
||||
CompactCheckbox(draft.value, enabled = enabled) { ticked ->
|
||||
draft.value = ticked
|
||||
onChange(ticked)
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
style = compactControlTextStyle(),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
@@ -388,8 +398,17 @@ object Form {
|
||||
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||
|
||||
@Composable
|
||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
|
||||
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
|
||||
fun decimal(
|
||||
label: String,
|
||||
value: Double,
|
||||
isValid: (Double) -> Boolean = { true },
|
||||
onChange: (Double) -> Unit,
|
||||
) = number(
|
||||
label,
|
||||
value,
|
||||
{ text -> text.trim().replace(',', '.').toDoubleOrNull()?.takeIf(isValid) },
|
||||
onChange,
|
||||
)
|
||||
|
||||
/**
|
||||
* The number field behind [int] and [decimal].
|
||||
@@ -461,6 +480,30 @@ object Form {
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact settings group that needs stronger separation than a labelled divider. */
|
||||
@Composable
|
||||
fun panelSection(title: String, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
)
|
||||
.border(
|
||||
width = Density.HAIRLINE,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
)
|
||||
.padding(Density.GROUP_CONTENT_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A section whose controls flow across the width instead of stacking.
|
||||
*
|
||||
@@ -643,6 +686,7 @@ internal fun CompactToggleButton(
|
||||
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
val contentColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
@@ -859,11 +903,13 @@ internal fun CompactTextField(
|
||||
internal fun CompactCheckbox(
|
||||
checked: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = modifier.size(Density.CONTROL_HEIGHT),
|
||||
)
|
||||
|
||||
@@ -264,6 +264,7 @@ private fun StationColumn(
|
||||
onChanged = {
|
||||
onFiltersChanged()
|
||||
},
|
||||
onQtfDirectionChanged = state::applyExplicitQtfFilterDirection,
|
||||
onReset = state::resetStationFilters,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -109,6 +109,11 @@ class MainWindowState(
|
||||
stationFiltersChanged?.invoke()
|
||||
}
|
||||
|
||||
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
|
||||
fun applyExplicitQtfFilterDirection(qtf: Double) {
|
||||
applyExplicitQtfHeading(prefs, qtf)
|
||||
}
|
||||
|
||||
fun requestLayoutSave() {
|
||||
layoutSaveRequested?.invoke()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
private const val MIN_QTF = 0.0
|
||||
private const val MAX_QTF = 360.0
|
||||
|
||||
/**
|
||||
* Applies a deliberate filter direction to the runtime antenna heading without sending a rotor command.
|
||||
*
|
||||
* @return true when the runtime heading was updated
|
||||
*/
|
||||
internal fun applyExplicitQtfHeading(prefs: ChatPreferences, qtf: Double): Boolean {
|
||||
if (!qtf.isFinite() || qtf !in MIN_QTF..MAX_QTF) return false
|
||||
if (!prefs.isQtfFilterDirectionAsAntennaHeadingEnabled) return false
|
||||
|
||||
prefs.actualQTF.set(qtf)
|
||||
return true
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fun StationFilterBar(
|
||||
activeBands: Set<kst4contest.model.Band>,
|
||||
reachability: ReachabilityState,
|
||||
onChanged: () -> Unit,
|
||||
onQtfDirectionChanged: (Double) -> Unit = {},
|
||||
onReset: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -170,10 +171,11 @@ fun StationFilterBar(
|
||||
filters.qtfDegrees.toString()
|
||||
},
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
val parsed = parseQtfDegrees(it)
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
onQtfDirectionChanged(parsed)
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
@@ -198,6 +200,9 @@ fun StationFilterBar(
|
||||
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
||||
) {
|
||||
filters.toggleBearing(bearing)
|
||||
if (filters.bearing == bearing) {
|
||||
onQtfDirectionChanged(bearing.degrees)
|
||||
}
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ enum class StationFilter {
|
||||
internal fun parseQrbKm(text: String): Double? =
|
||||
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
|
||||
|
||||
/** A deliberate antenna/filter heading in the convention used by the QTF display. */
|
||||
internal fun parseQtfDegrees(text: String): Double? =
|
||||
text.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..360.0 }
|
||||
|
||||
/** The bands the "hide worked" row offers, under the labels the bar shows. */
|
||||
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
|
||||
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
import kst4contest.view.compose.TooltipCard
|
||||
|
||||
/**
|
||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
||||
@@ -16,6 +19,7 @@ import kst4contest.view.compose.Form
|
||||
*
|
||||
* @param onRefused shows why a committed entry was refused (the own ground height)
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||
val strings = LocalStrings.current
|
||||
@@ -86,10 +90,32 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||
|
||||
Form.section(strings.stationDefaultsSection) {
|
||||
Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
|
||||
Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it }
|
||||
Form.decimal(
|
||||
label = strings.stationQtf,
|
||||
value = state.stn_qtfDefault,
|
||||
isValid = { it.isFinite() && it in 0.0..360.0 },
|
||||
) { state.stn_qtfDefault = it }
|
||||
Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
|
||||
}
|
||||
|
||||
Form.panelSection(strings.stationManualAntennaHeadingSection) {
|
||||
val headingOption: @Composable () -> Unit = {
|
||||
Form.check(
|
||||
strings.stationUseQtfFilterDirectionAsAntennaHeading,
|
||||
state.stn_useQtfFilterDirectionAsAntennaHeading,
|
||||
enabled = !state.stn_pstRotatorEnabled,
|
||||
) { state.stn_useQtfFilterDirectionAsAntennaHeading = it }
|
||||
}
|
||||
if (state.stn_pstRotatorEnabled) {
|
||||
TooltipArea(
|
||||
tooltip = { TooltipCard(strings.stationManualAntennaHeadingRotatorHint) },
|
||||
content = headingOption,
|
||||
)
|
||||
} else {
|
||||
headingOption()
|
||||
}
|
||||
}
|
||||
|
||||
Form.section(strings.stationRotatorSection) {
|
||||
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
||||
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
||||
|
||||
@@ -165,7 +165,13 @@ class StationTabState(
|
||||
return null
|
||||
}
|
||||
|
||||
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
|
||||
var stn_pstRotatorEnabled: Boolean by draft.setting(
|
||||
{ prefs.isStn_pstRotatorEnabled() },
|
||||
) { prefs.setStn_pstRotatorEnabled(it) }
|
||||
|
||||
var stn_useQtfFilterDirectionAsAntennaHeading: Boolean by draft.setting(
|
||||
{ prefs.isStn_useQtfFilterDirectionAsAntennaHeading() },
|
||||
) { prefs.setStn_useQtfFilterDirectionAsAntennaHeading(it) }
|
||||
|
||||
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
|
||||
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
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 org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class ManualQtfHeadingSynchronizerTest(@param:TempDir private val directory: Path) {
|
||||
|
||||
@Test
|
||||
fun `valid explicit QTF updates actual heading when synchronization is enabled`() {
|
||||
val prefs = preferences().apply {
|
||||
actualQTF.set(20.0)
|
||||
}
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 315.5))
|
||||
assertEquals(315.5, prefs.actualQTF.get(), 0.0001)
|
||||
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primary rotator prevents manual QTF synchronization`() {
|
||||
val prefs = preferences().apply {
|
||||
setStn_pstRotatorEnabled(true)
|
||||
actualQTF.set(82.0)
|
||||
}
|
||||
assertFalse(applyExplicitQtfHeading(prefs, 180.0))
|
||||
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled option and invalid angles leave actual heading unchanged`() {
|
||||
val prefs = preferences().apply {
|
||||
setStn_useQtfFilterDirectionAsAntennaHeading(false)
|
||||
actualQTF.set(82.0)
|
||||
}
|
||||
listOf(90.0, Double.NaN, Double.POSITIVE_INFINITY, -0.1, 360.1).forEach { qtf ->
|
||||
assertFalse(applyExplicitQtfHeading(prefs, qtf), "unexpectedly accepted $qtf")
|
||||
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero and 360 follow the existing antenna heading convention`() {
|
||||
val prefs = preferences()
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 0.0))
|
||||
assertEquals(0.0, prefs.actualQTF.get(), 0.0001)
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 360.0))
|
||||
assertEquals(360.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
|
||||
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ObservedValue
|
||||
import kst4contest.view.compose.tabs.checkBeside
|
||||
import kst4contest.view.compose.tabs.fieldBeside
|
||||
import kst4contest.view.compose.tabs.showTab
|
||||
@@ -24,6 +26,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
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
|
||||
import java.util.EnumSet
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -37,7 +41,7 @@ import kotlin.math.abs
|
||||
* Headless: only a composition, no window.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StationFilterBarScreenTest {
|
||||
class StationFilterBarScreenTest(@param:TempDir private val directory: Path) {
|
||||
|
||||
private val maxLabel = "Show only QRB [km] <="
|
||||
private val minLabel = "Show only QRB [km] >"
|
||||
@@ -45,6 +49,7 @@ class StationFilterBarScreenTest {
|
||||
private class Bar {
|
||||
val filters = StationFilterState()
|
||||
var changes = 0
|
||||
val explicitQtfDirections = mutableListOf<Double>()
|
||||
}
|
||||
|
||||
private fun ComposeUiTest.showBar(): Bar {
|
||||
@@ -55,11 +60,91 @@ class StationFilterBarScreenTest {
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = { bar.changes++ },
|
||||
onQtfDirectionChanged = { bar.explicitQtfDirections += it },
|
||||
)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aValidManualQtfEntryIsReportedEvenWhileTheFilterIsOff() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
type("Show only QTF:", "127.5")
|
||||
|
||||
assertFalse(bar.filters.qtfEnabled)
|
||||
assertEquals(listOf(127.5), bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyBearingButtonReportsItsExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
Bearing.entries.forEach { bearing ->
|
||||
onNodeWithText(bearing.name).performClick()
|
||||
}
|
||||
|
||||
assertEquals(Bearing.entries.map { it.degrees }, bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deselectingABearingDoesNotReportAnotherExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
onNodeWithText("NE").performClick()
|
||||
onNodeWithText("NE").performClick()
|
||||
|
||||
assertFalse(bar.filters.qtfEnabled)
|
||||
assertEquals(listOf(45.0), bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultManualHeadingMakesNorthEastVisibleToTheMapObserverWhileFilterIsOff() = runComposeUiTest {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
val filters = StationFilterState()
|
||||
val mapHeading = ObservedValue(prefs.actualQTF)
|
||||
try {
|
||||
showTab {
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
onQtfDirectionChanged = { applyExplicitQtfHeading(prefs, it) },
|
||||
)
|
||||
}
|
||||
|
||||
assertFalse(filters.qtfEnabled)
|
||||
onNodeWithText("NE").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(45.0, prefs.actualQTF.get(), 0.0001)
|
||||
assertEquals(45.0, requireNotNull(mapHeading.value), 0.0001)
|
||||
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||
} finally {
|
||||
mapHeading.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetAndQtfCheckboxChangesDoNotReportAnExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
toggle("Show only QTF:")
|
||||
onNodeWithText("Reset filters").performClick()
|
||||
|
||||
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidManualQtfEntriesAreNotReported() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
listOf("NaN", "Infinity", "-1", "360.1").forEach { type("Show only QTF:", it) }
|
||||
|
||||
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||
assertEquals(0.0, bar.filters.qtfDegrees, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
||||
val filters = StationFilterState()
|
||||
|
||||
@@ -82,6 +82,9 @@ class DraftedSettingsTableTest {
|
||||
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
||||
},
|
||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
|
||||
flag("station QTF filter heading sync", { it.station.stn_useQtfFilterDirectionAsAntennaHeading }) { f, v ->
|
||||
f.station.stn_useQtfFilterDirectionAsAntennaHeading = v
|
||||
},
|
||||
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
|
||||
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
|
||||
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
|
||||
|
||||
@@ -36,9 +36,12 @@ import kst4contest.view.compose.Kst4ContestTheme
|
||||
* control further down can be scrolled to and clicked.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
|
||||
internal fun ComposeUiTest.showTab(
|
||||
darkMode: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -77,7 +77,9 @@ class StationTabScreenTest {
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
|
||||
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() }
|
||||
"Manual antenna heading", "PSTRotator", "Path analysis defaults").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
|
||||
fieldBeside("Callsign").assertTextEquals("DN9APW")
|
||||
fieldBeside("Server port").assertTextEquals("23001")
|
||||
@@ -85,6 +87,36 @@ class StationTabScreenTest {
|
||||
checkBeside("10 GHz").assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qtfFilterHeadingOptionFollowsTheDraftedPrimaryRotatorState() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
val label = "Use QTF filter direction as antenna heading"
|
||||
checkBeside(label).assertIsEnabled().assertIsOn()
|
||||
|
||||
toggle("Rotator sync enabled")
|
||||
|
||||
checkBeside(label).assertIsNotEnabled().assertIsOn()
|
||||
toggle("Rotator sync enabled")
|
||||
checkBeside(label).assertIsEnabled().assertIsOn()
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertFalse(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualAntennaHeadingPanelRendersInEveningTheme() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
|
||||
showTab(darkMode = true) { StationTab(s.state) }
|
||||
|
||||
onNodeWithText("Manual antenna heading").assertExists()
|
||||
checkBeside("Use QTF filter direction as antenna heading")
|
||||
.assertIsEnabled()
|
||||
.assertIsOn()
|
||||
}
|
||||
|
||||
/* The credentials write through; every other field waits for Apply. */
|
||||
@Test
|
||||
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
||||
@@ -125,6 +157,18 @@ class StationTabScreenTest {
|
||||
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidQtfDefaultsAreNotStaged() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
listOf("360,1", "-0,1", "NaN", "Infinity").forEach { invalid ->
|
||||
type("QTF", invalid)
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(135.0, s.prefs.stn_qtfDefault, 1e-9, "accepted $invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The draft is Compose state, so a staged number comes straight back to its field. The
|
||||
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
@@ -149,6 +149,76 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QTF filter heading synchronization waits in the draft until apply`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
|
||||
|
||||
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertFalse(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling primary rotator preserves QTF filter heading preference in the draft`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
|
||||
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading(), "an edit took effect before Apply")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling primary rotator restores QTF filter heading synchronization`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_pstRotatorEnabled = false
|
||||
|
||||
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit QTF heading opt out survives primary rotator changes`() {
|
||||
val s = Session()
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_pstRotatorEnabled = false
|
||||
|
||||
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 2 does not disable QTF filter heading synchronization`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = true
|
||||
s.state.stn_pstRotator2Enabled = true
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded station setting leaves the preferences alone`() {
|
||||
val s = Session()
|
||||
|
||||
Reference in new issue
Block a user