feat: improve manual QTF heading control

This commit is contained in:
Marc Froehlich committed 2026-10-24 21:39:17 +02:00
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) }
@@ -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()