diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index e8e03315..cfa08fc4 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -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 diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 0795425b..18491582 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt index 26015138..443b4b60 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt @@ -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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt index ac6ff9dc..24447c9d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt @@ -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), ) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt index d34b8492..a3542649 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -264,6 +264,7 @@ private fun StationColumn( onChanged = { onFiltersChanged() }, + onQtfDirectionChanged = state::applyExplicitQtfFilterDirection, onReset = state::resetStationFilters, modifier = Modifier.fillMaxWidth(), ) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt index af5a9fdb..b6d27ce4 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -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() } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt new file mode 100644 index 00000000..27532bdb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt @@ -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 +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt index c21c995f..5cc30f96 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -38,6 +38,7 @@ fun StationFilterBar( activeBands: Set, 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() } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt index b1f767cf..385e3e5f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt @@ -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), diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt index 0526d2f6..d2175ba5 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt @@ -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 } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt index a86a2355..053ff051 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt @@ -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) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt new file mode 100644 index 00000000..932f3d00 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt @@ -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")) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt index e49337bb..ad6d2959 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt @@ -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() } 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() diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt index 907bda60..d76e18d3 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt @@ -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() }), diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt index 13526db2..fe2cbda6 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt @@ -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() } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt index 57ea9703..0858d5b9 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt @@ -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" diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt index b24e9a23..09b0058e 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt @@ -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() diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index 0c28c9a2..3cefbd07 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -57,11 +57,13 @@ public class ChatPreferences { * only use is a best-effort warning when a file is newer than the application. Release * 1.50 added the two palette-override elements and deliberately left this at 7. A bump * that no reader acts on only invites the next person to bump it again. + * Version 9 marks the manual-QTF-heading opt-out migration. A missing opt-out marker + * enables the feature, and a subsequently written marker preserves the user's decision. *

* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. */ // private static final int CONFIG_VERSION = 2; - public static final int CONFIG_VERSION = 8; + public static final int CONFIG_VERSION = 9; // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. private static final String TAG_CONFIG_VERSION = "configVersion"; @@ -202,6 +204,7 @@ public class ChatPreferences { int stn_on4kstServersPort = 23001; boolean stn_pstRotatorEnabled = false; + boolean stn_qtfFilterDirectionAsAntennaHeadingOptedOut; String stn_pstRotatorHost = "127.0.0.1"; int stn_pstRotatorPort = 12000; boolean stn_pstRotator2Enabled = false; @@ -259,6 +262,7 @@ public class ChatPreferences { boolean loginToSecondChatEnabled; MutableValue actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime! MutableValue actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown + private boolean actualQtfInitializedFromPreferences; boolean stn_bandActive50; boolean stn_bandActive70; @@ -1052,6 +1056,20 @@ public class ChatPreferences { this.stn_pstRotatorEnabled = stn_pstRotatorEnabled; } + public boolean isStn_useQtfFilterDirectionAsAntennaHeading() { + return !stn_qtfFilterDirectionAsAntennaHeadingOptedOut; + } + + public void setStn_useQtfFilterDirectionAsAntennaHeading( + boolean stn_useQtfFilterDirectionAsAntennaHeading) { + this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut = + !stn_useQtfFilterDirectionAsAntennaHeading; + } + + public boolean isQtfFilterDirectionAsAntennaHeadingEnabled() { + return !stn_pstRotatorEnabled && !stn_qtfFilterDirectionAsAntennaHeadingOptedOut; + } + public String getStn_pstRotatorHost() { return stn_pstRotatorHost; } @@ -1224,7 +1242,9 @@ public class ChatPreferences { } public void setStn_qtfDefault(double stn_qtfDefault) { - this.stn_qtfDefault = stn_qtfDefault; + if (Double.isFinite(stn_qtfDefault) && stn_qtfDefault >= 0.0 && stn_qtfDefault <= 360.0) { + this.stn_qtfDefault = stn_qtfDefault; + } } public boolean isStn_loginAFKState() { @@ -1742,6 +1762,12 @@ public class ChatPreferences { stn_qtfDefault.setTextContent(this.stn_qtfDefault+""); station.appendChild(stn_qtfDefault); + Element stn_qtfFilterDirectionAsAntennaHeadingOptedOut = + doc.createElement("stn_qtfFilterDirectionAsAntennaHeadingOptedOut"); + stn_qtfFilterDirectionAsAntennaHeadingOptedOut.setTextContent( + Boolean.toString(this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut)); + station.appendChild(stn_qtfFilterDirectionAsAntennaHeadingOptedOut); + Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters"); stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + ""); station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters); @@ -2628,7 +2654,7 @@ public class ChatPreferences { stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg"); stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault"); - stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"); + setStn_qtfDefault(getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault")); stn_pathAnalysisOwnAntennaHeightMeters = getDouble( stationEl, @@ -2738,6 +2764,16 @@ public class ChatPreferences { stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G"); stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled"); + /* + * The positive version-8 setting cannot distinguish an explicit false from its old + * default. Absence of the opt-out marker therefore performs the approved migration + * to enabled; once written, the marker preserves every later user decision. + */ + stn_qtfFilterDirectionAsAntennaHeadingOptedOut = getBoolean( + stationEl, + false, + "stn_qtfFilterDirectionAsAntennaHeadingOptedOut" + ); setStn_pstRotatorHost( getText( @@ -3411,10 +3447,18 @@ public class ChatPreferences { System.out.println(e.getMessage()); } + initializeActualQtfFromDefaultIfRequired(); return true; } + private void initializeActualQtfFromDefaultIfRequired() { + if (!actualQtfInitializedFromPreferences) { + actualQTF.set(stn_qtfDefault); + actualQtfInitializedFromPreferences = true; + } + } + /** * * @return double array with separated values for each doublevalue, seperated by ";", empty array if none diff --git a/core/src/main/resources/praktiKSTpreferences.xml b/core/src/main/resources/praktiKSTpreferences.xml index 4656544d..db8d900e 100644 --- a/core/src/main/resources/praktiKSTpreferences.xml +++ b/core/src/main/resources/praktiKSTpreferences.xml @@ -1,6 +1,6 @@ - 8 + 9 DO5AMF changeMe @@ -13,6 +13,7 @@ 60.0 1333.0 135.0 + false true true true diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java new file mode 100644 index 00000000..a6679608 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java @@ -0,0 +1,195 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.nio.file.Files; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatPreferencesQtfHeadingTest { + + @TempDir + Path directory; + + @Test + void loadedQtfDefaultInitializesTheRuntimeHeadingOnce() { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(247.5); + assertTrue(written.writePreferencesToXmlFile()); + + ChatPreferences restored = new ChatPreferences(file); + assertTrue(restored.readPreferencesFromXmlFile()); + assertEquals(247.5, restored.getActualQTF().get(), 0.0001); + + restored.getActualQTF().set(91.0); + assertTrue(restored.readPreferencesFromXmlFile()); + assertEquals(91.0, restored.getActualQTF().get(), 0.0001, + "reloading preferences overwrote a later runtime rotor heading"); + } + + @Test + void eachProfileStartsWithItsOwnQtfDefault() { + Path rootFile = directory.resolve("preferences.xml"); + Path operatorFile = directory.resolve("profiles/OP2/preferences.xml"); + writeQtfDefault(rootFile, 135.0); + writeQtfDefault(operatorFile, 302.0); + + ChatPreferences root = read(rootFile); + ChatPreferences operator = read(operatorFile); + + assertEquals(135.0, root.getActualQTF().get(), 0.0001); + assertEquals(302.0, operator.getActualQTF().get(), 0.0001); + } + + @Test + void qtfFilterHeadingSynchronizationDefaultsToEnabledForRootAndOperatorProfiles() { + ChatPreferences root = new ChatPreferences(directory.resolve("preferences.xml")); + ChatPreferences operator = new ChatPreferences(directory.resolve("profiles/OP2/preferences.xml")); + + assertTrue(root.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(root.isQtfFilterDirectionAsAntennaHeadingEnabled()); + assertTrue(operator.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(operator.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void primaryRotatorOnlySuspendsTheStoredManualHeadingPreference() { + ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml")); + + preferences.setStn_pstRotatorEnabled(true); + + assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled()); + + preferences.setStn_pstRotatorEnabled(false); + + assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void versionEightProfilesMigrateToTheEnabledDefault() throws IOException { + Path file = directory.resolve("preferences.xml"); + Files.writeString(file, """ + + + 8 + + false + false + + + """); + + ChatPreferences migrated = read(file); + + assertTrue(migrated.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(migrated.isQtfFilterDirectionAsAntennaHeadingEnabled()); + + assertTrue(migrated.writePreferencesToXmlFile()); + String migratedXml = Files.readString(file); + assertTrue(migratedXml.contains("9")); + assertTrue(migratedXml.contains( + "false")); + assertTrue(read(file).isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void explicitOptOutSurvivesRestartAndPrimaryRotatorChanges() throws IOException { + Path file = directory.resolve("preferences.xml"); + ChatPreferences preferences = new ChatPreferences(file); + preferences.setStn_useQtfFilterDirectionAsAntennaHeading(false); + assertTrue(preferences.writePreferencesToXmlFile()); + + ChatPreferences restored = read(file); + assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled()); + assertTrue(Files.readString(file).contains( + "true")); + + restored.setStn_pstRotatorEnabled(true); + restored.setStn_pstRotatorEnabled(false); + + assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void profilesKeepIndependentManualHeadingDecisions() { + Path rootFile = directory.resolve("preferences.xml"); + Path operatorFile = directory.resolve("profiles/OP2/preferences.xml"); + ChatPreferences root = new ChatPreferences(rootFile); + root.setStn_useQtfFilterDirectionAsAntennaHeading(false); + assertTrue(root.writePreferencesToXmlFile()); + + ChatPreferences operator = new ChatPreferences(operatorFile); + assertTrue(operator.writePreferencesToXmlFile()); + + assertFalse(read(rootFile).isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(read(operatorFile).isStn_useQtfFilterDirectionAsAntennaHeading()); + } + + @Test + void laterRotorHeadingReplacesTheLoadedStartValue() { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(188.0); + written.setStn_pstRotatorEnabled(true); + assertTrue(written.writePreferencesToXmlFile()); + + ChatPreferences restored = read(file); + restored.getActualQTF().set(271.25); + + assertEquals(271.25, restored.getActualQTF().get(), 0.0001); + assertEquals(188.0, restored.getStn_qtfDefault(), 0.0001, + "a runtime heading changed the persisted start direction"); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-0.1", "360.1"}) + void invalidStoredQtfDefaultsAreIgnored(String invalidValue) throws IOException { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(247.5); + assertTrue(written.writePreferencesToXmlFile()); + Files.writeString(file, Files.readString(file).replace( + "247.5", + "" + invalidValue + "")); + + ChatPreferences restored = read(file); + + assertEquals(135.0, restored.getStn_qtfDefault(), 0.0001); + assertEquals(135.0, restored.getActualQTF().get(), 0.0001); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-Infinity", "-0.1", "360.1"}) + void invalidQtfDefaultsCannotReplaceAValidValue(String invalidValue) { + ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml")); + preferences.setStn_qtfDefault(247.5); + + preferences.setStn_qtfDefault(Double.parseDouble(invalidValue)); + + assertEquals(247.5, preferences.getStn_qtfDefault(), 0.0001); + } + + private void writeQtfDefault(Path file, double qtf) { + ChatPreferences preferences = new ChatPreferences(file); + preferences.setStn_qtfDefault(qtf); + assertTrue(preferences.writePreferencesToXmlFile()); + } + + private ChatPreferences read(Path file) { + ChatPreferences preferences = new ChatPreferences(file); + assertTrue(preferences.readPreferencesFromXmlFile()); + return preferences; + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java index 97c1a190..89575b18 100644 --- a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java +++ b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java @@ -181,6 +181,8 @@ class ChatPreferencesRoundTripTest { assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked")); assertTrue(fromTemplate.isStn_bandActive144()); assertFalse(fromTemplate.isStn_bandActive10G()); + assertTrue(fromTemplate.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(fromTemplate.isQtfFilterDirectionAsAntennaHeadingEnabled()); } @Test @@ -211,6 +213,8 @@ class ChatPreferencesRoundTripTest { assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat()); assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber()); assertEquals(8123, legacy.getNotify_dxclusterServerPort()); + assertTrue(legacy.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(legacy.isQtfFilterDirectionAsAntennaHeadingEnabled()); // A tag that is missing keeps the compiled-in default. assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(), legacy.getStn_on4kstServersPort()); diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java index e3251745..4f3048ec 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java @@ -54,7 +54,7 @@ class ChatPreferencesDebugModeTest { assertTrue(written.writePreferencesToXmlFile()); String writtenXml = Files.readString(preferencesFile); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains( "false" + "")); diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java index 6fa3de55..ca9b3b74 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java @@ -117,7 +117,7 @@ class ChatPreferencesLayoutPersistenceTest { assertFalse(writtenXml.contains("UNSAVED-CALL")); assertTrue(writtenXml.contains("")); assertTrue(writtenXml.contains("untouched")); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains("812.0;1340.0")); assertTrue(writtenXml.contains("false" + "")); diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java index 8b6c0fae..a4595ba5 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java @@ -32,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest { assertTrue(written.writePreferencesToXmlFile()); String writtenXml = Files.readString(preferencesFile); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains("false" + "")); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index c51a357e..f1eadb94 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -208,6 +208,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - The lower selected-station panel contains a vertical two-pane `SplitterPane`: the upper pane owns the independently scrollable message table, while the lower pane scrolls NOT-QRV and Sked above a pinned lookup group. The lower pane reserves 160 dp before stored divider positions are applied, so normal small-window layouts retain direct access to lookup actions; impossible smaller layouts keep the action groups scrollable. `GUIselectedCallSignSplitPane_dividerposition` persists this divider through the existing profile-scoped layout autosave. Station or message updates never reset it. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state. - The selected-station sked picker takes its options exclusively from `MainWindowState.activeBands`, which originates in `BandOpportunityResolver.getEnabledStationBands(...)`. Station QRG and name evidence chooses the initial band but must not empty or narrow the operator's locally enabled choices. The detected-band header value is already localized and labelled by its producer, so the Compose panel renders it unchanged. - Compact desktop buttons, text fields, choices and checkbox rows share `Density.CONTROL_HEIGHT` at 26 dp and `compactControlTextStyle()`. Material touch-target and component defaults must not reintroduce different visible heights. Compass filter buttons keep a common 28 dp width with reduced horizontal content padding; the send-row QRG fields reserve 96 dp and QTF fields 56 dp so normal contest values remain visible. The station filter bar groups related controls on shared theme surfaces while its outer flow rows continue to wrap from the station pane's actual width. +- `actualQTF` is initialised once from the active profile's `stn_qtfDefault` after preferences are read. Later PSTRotator feedback or manual runtime changes replace that value without changing the stored default, and a repeated preferences read must not overwrite a live heading. Manual QTF-heading transfer is enabled by default and persisted as the profile-scoped opt-out marker `stn_qtfFilterDirectionAsAntennaHeadingOptedOut`; absence of that marker migrates root and operator profiles to enabled, while subsequent writes preserve an explicit opt-out. The effective state is `!stn_pstRotatorEnabled && !optedOut`: PSTRotator 1 temporarily suspends transfer without erasing the user's choice, and PSTRotator 2 remains independent. Explicit QTF field entries and compass-button actions route directly to `actualQTF`, even when the QTF predicate is off; filter reset, checkbox changes and restored filter state never take that route. The coupling is one-way and never calls the rotor command path. ## Autoanswer / Beacon diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index 4ff6ca13..1d15ecf5 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -204,6 +204,8 @@ Dezent umrahmte Flächen fassen **QRB**, **QTF** einschließlich Richtungstasten | **Tropo >=0dB** | Zeigt Stationen mit einer berechneten, nicht negativen SSB-Marge | | **AS next 5m** | Zeigt Stationen mit einem aktuellen oder innerhalb der nächsten fünf Minuten erwarteten AirScout-Fenster | +Standardmäßig aktualisiert eine gültige direkte Eingabe in der QTF-Gruppe oder der Klick auf eine Richtungstaste zugleich die aktuelle eigene Antennenrichtung. Diese ausdrückliche Übernahme funktioniert unabhängig davon, ob **Show only QTF** ein- oder ausgeschaltet ist. Das Zurücksetzen oder bloße Ausschalten des Filters verändert die Antennenrichtung nicht. Im Abschnitt **Station → Manuelle Antennenrichtung** kann das Verhalten mit **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** profilbezogen ausgeschaltet werden. PSTRotator 1 hat vorübergehend Vorrang, ohne diese gespeicherte Auswahl zu verändern. + Bei **New bands** werden aktuelle QRGs, Bandangaben im Namensfeld und aktive Rufzeichenvarianten gemeinsam ausgewertet. Manuelle NOT-QRV-Markierungen haben Vorrang. Der Filter **Tropo >=0dB** entfernt nur Stationen, für die eine abgeschlossene Berechnung eine negative Marge ergeben hat. Noch nicht berechnete oder fehlgeschlagene Auswertungen bleiben sichtbar. Andernfalls würde ein fehlender API-Wert wie ein nachgewiesen ungeeigneter Funkweg behandelt. diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index 41ee10c6..778acddb 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -836,6 +836,7 @@ Die Einstellungen befinden sich im Reiter **Station**: | Einstellung | Standardwert | Verwendung | |---|---:|---| | **Enable PSTRotator** | deaktiviert | Startet die UDP-Kommunikation mit PSTRotator | +| **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** | aktiviert, wenn PSTRotator 1 deaktiviert ist | Übernimmt bewusste Richtungseingaben im QTF-Filter als aktuelle eigene Antennenrichtung | | **PSTRotator host** | `127.0.0.1` | Hostname oder IP-Adresse des Rechners, auf dem PSTRotator läuft | | **PSTRotator UDP port** | `12000` | UDP-Port, auf dem PSTRotator die Steuerbefehle empfängt | @@ -862,6 +863,8 @@ Das vollständige UDP-Protokoll ist im [PSTRotatorAz User Manual](https://www.qs ### Übernahme der aktuellen QTF +Beim Programmstart wird die aktuelle eigene QTF mit der im aktiven Root- oder Operatorprofil gespeicherten Vorgabe **QTF** initialisiert. Sie dient nur als Startwert. Änderungen während des Betriebs verändern diese gespeicherte Vorgabe nicht. + KST4Contest fragt PSTRotator alle zwei Sekunden nach der aktuellen Azimutposition und dem Betriebsmodus. Die zurückgemeldete Azimutposition wird als `actualQTF` übernommen. Bei aktivierter PSTRotator-Integration ist das QTF-Feld im Hauptfenster deshalb nicht manuell editierbar. Es zeigt die zuletzt von PSTRotator gemeldete Position. @@ -877,6 +880,10 @@ Diese QTF wird unter anderem verwendet für: Eine empfangene Rotatorposition ist damit nicht nur eine Anzeige. Sie verändert mehrere Funktionen, die auf der aktuellen Antennenrichtung beruhen. +Ohne PSTRotator 1 kann **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** einen direkt in den QTF-Filter eingetragenen Winkel oder eine über dessen Richtungstasten gewählte Richtung in dasselbe `actualQTF` übernehmen. Das funktioniert auch, wenn **Show only QTF** ausgeschaltet ist. **Reset filters**, das Ausschalten des Filters und ein automatisch wiederhergestellter Filterzustand verändern die Antennenrichtung nicht. Die Übernahme erfolgt nur vom Filter zur Antennenrichtung und sendet keinen PSTRotator-Befehl. + +Die Option steht im Abschnitt **Manuelle Antennenrichtung** und ist standardmäßig aktiviert. Das gilt nach dem Update auch für bestehende Root- und Operatorprofile. Wer die Kopplung nicht verwenden möchte, schaltet sie aus; diese Entscheidung wird im jeweiligen Profil gespeichert. Bei aktiviertem PSTRotator 1 ist die Checkbox nicht bedienbar und die Übernahme vorübergehend unwirksam. Die gespeicherte Auswahl bleibt dabei erhalten und gilt wieder, sobald PSTRotator 1 deaktiviert wird. PSTRotator 2 unter SO2R ist von dieser Regel unabhängig. + KST4Contest verwendet derzeit nur den Azimut. Eine Elevationssteuerung oder eine vollständige Azimut-/Elevationsnachführung ist nicht Bestandteil dieser Integration. ### Änderungen übernehmen diff --git a/github_docs/en-Configuration.md b/github_docs/en-Configuration.md index 767b36d4..21f1a0bb 100644 --- a/github_docs/en-Configuration.md +++ b/github_docs/en-Configuration.md @@ -838,6 +838,7 @@ The settings are located in the **Station** tab: | Setting | Default | Purpose | |---|---:|---| | **Enable PSTRotator** | disabled | Starts UDP communication with PSTRotator | +| **Use QTF filter direction as antenna heading** | enabled when PSTRotator 1 is disabled | Copies deliberate QTF-filter direction entries to the current local antenna heading | | **PSTRotator host** | `127.0.0.1` | Hostname or IP address of the computer running PSTRotator | | **PSTRotator UDP port** | `12000` | UDP port on which PSTRotator receives control commands | @@ -864,6 +865,8 @@ The complete UDP protocol is documented in the [PSTRotatorAz User Manual](https: ### Updating the local QTF +At program start, the current local QTF is initialised from the **QTF** default stored in the active root or operator profile. This is a start value only. It does not change the stored default during operation. + KST4Contest asks PSTRotator for the current azimuth and operating mode every two seconds. The reported azimuth becomes `actualQTF`. While PSTRotator integration is enabled, the QTF field in the main window is therefore read-only. It displays the most recent position reported by PSTRotator. @@ -879,6 +882,10 @@ This QTF is used by: A received rotator position is therefore more than a displayed value. It changes several functions which depend on the current antenna direction. +Without PSTRotator 1, **Use QTF filter direction as antenna heading** can apply a direction entered in the QTF filter, or selected with one of its compass buttons, to the same `actualQTF`. This also works while **Show only QTF** is switched off. **Reset filters**, switching the filter off and restored filter state do not alter the antenna heading. The transfer is one-way and does not issue a PSTRotator command. + +The option is grouped under **Manual antenna heading** and is enabled by default, including for existing root and operator profiles after updating. Clear it to store a profile-specific opt-out. While PSTRotator 1 is enabled, the checkbox is unavailable and the transfer is temporarily inactive. Its stored state is retained, so disabling PSTRotator 1 restores the previous choice. PSTRotator 2 under SO2R is independent of this rule. + The current integration uses azimuth only. Elevation control and complete azimuth/elevation tracking are outside its present scope. ### Applying changed settings diff --git a/github_docs/en-User-Interface.md b/github_docs/en-User-Interface.md index 6b93c01f..bab2e902 100644 --- a/github_docs/en-User-Interface.md +++ b/github_docs/en-User-Interface.md @@ -206,6 +206,8 @@ Subtly bordered surfaces group **QRB**, **QTF** including direction buttons, **H | **Tropo >=0dB** | Shows stations with a calculated non-negative SSB margin | | **AS next 5m** | Shows stations with a current AirScout window or one expected within the next five minutes | +By default, a valid direct entry in the QTF group or a compass-button click also updates the current local antenna heading. This explicit transfer works whether **Show only QTF** is on or off. Resetting or merely disabling the filter does not change the antenna heading. The profile-specific option **Use QTF filter direction as antenna heading** under **Station → Manual antenna heading** can disable this behaviour. PSTRotator 1 takes temporary priority without changing that saved choice. + For **New bands**, KST4Contest evaluates current QRGs, band information in the name field and active callsign variants together. Manual NOT-QRV marks take precedence. The **Tropo >=0dB** filter removes only stations for which a completed calculation returned a negative margin. Stations with pending or failed calculations remain visible. Otherwise, a missing API result would incorrectly be treated as proof that the path is unsuitable.