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.pathSection=Vorgaben für die Streckenanalyse
|
||||||
station.qtf=QTF
|
station.qtf=QTF
|
||||||
station.rotatorEnabled=Rotorabgleich aktiv
|
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.rotatorHost=Rotor-Host
|
||||||
station.rotatorPort=Rotor-Port
|
station.rotatorPort=Rotor-Port
|
||||||
station.rotatorSection=PSTRotator
|
station.rotatorSection=PSTRotator
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ station.password=Password
|
|||||||
station.pathSection=Path analysis defaults
|
station.pathSection=Path analysis defaults
|
||||||
station.qtf=QTF
|
station.qtf=QTF
|
||||||
station.rotatorEnabled=Rotator sync enabled
|
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.rotatorHost=Rotator host
|
||||||
station.rotatorPort=Rotator port
|
station.rotatorPort=Rotator port
|
||||||
station.rotatorSection=PSTRotator
|
station.rotatorSection=PSTRotator
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ internal object Density {
|
|||||||
/** Between buttons in a row. */
|
/** Between buttons in a row. */
|
||||||
val BUTTON_GAP = 6.dp
|
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. */
|
/** Around a selected row, and the hairline between rows. */
|
||||||
val SELECTION_BORDER = 1.5.dp
|
val SELECTION_BORDER = 1.5.dp
|
||||||
|
|
||||||
|
|||||||
@@ -346,19 +346,29 @@ object Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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)
|
val draft = rememberDraft(value)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
) {
|
) {
|
||||||
CompactCheckbox(draft.value) { ticked ->
|
CompactCheckbox(draft.value, enabled = enabled) { ticked ->
|
||||||
draft.value = ticked
|
draft.value = ticked
|
||||||
onChange(ticked)
|
onChange(ticked)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
color = if (enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
},
|
||||||
style = compactControlTextStyle(),
|
style = compactControlTextStyle(),
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -388,8 +398,17 @@ object Form {
|
|||||||
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
|
fun decimal(
|
||||||
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
|
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].
|
* 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.
|
* 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)
|
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentColor = when {
|
val contentColor = when {
|
||||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||||
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
|
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
@@ -859,11 +903,13 @@ internal fun CompactTextField(
|
|||||||
internal fun CompactCheckbox(
|
internal fun CompactCheckbox(
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
modifier = modifier.size(Density.CONTROL_HEIGHT),
|
modifier = modifier.size(Density.CONTROL_HEIGHT),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ private fun StationColumn(
|
|||||||
onChanged = {
|
onChanged = {
|
||||||
onFiltersChanged()
|
onFiltersChanged()
|
||||||
},
|
},
|
||||||
|
onQtfDirectionChanged = state::applyExplicitQtfFilterDirection,
|
||||||
onReset = state::resetStationFilters,
|
onReset = state::resetStationFilters,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ class MainWindowState(
|
|||||||
stationFiltersChanged?.invoke()
|
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() {
|
fun requestLayoutSave() {
|
||||||
layoutSaveRequested?.invoke()
|
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>,
|
activeBands: Set<kst4contest.model.Band>,
|
||||||
reachability: ReachabilityState,
|
reachability: ReachabilityState,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
|
onQtfDirectionChanged: (Double) -> Unit = {},
|
||||||
onReset: (() -> Unit)? = null,
|
onReset: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -170,10 +171,11 @@ fun StationFilterBar(
|
|||||||
filters.qtfDegrees.toString()
|
filters.qtfDegrees.toString()
|
||||||
},
|
},
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
val parsed = it.toDoubleOrNull()
|
val parsed = parseQtfDegrees(it)
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
filters.qtfDegrees = parsed
|
filters.qtfDegrees = parsed
|
||||||
filters.bearing = null
|
filters.bearing = null
|
||||||
|
onQtfDirectionChanged(parsed)
|
||||||
if (filters.qtfEnabled) onChanged()
|
if (filters.qtfEnabled) onChanged()
|
||||||
} else if (it.isEmpty()) {
|
} else if (it.isEmpty()) {
|
||||||
filters.qtfDegrees = 0.0
|
filters.qtfDegrees = 0.0
|
||||||
@@ -198,6 +200,9 @@ fun StationFilterBar(
|
|||||||
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
||||||
) {
|
) {
|
||||||
filters.toggleBearing(bearing)
|
filters.toggleBearing(bearing)
|
||||||
|
if (filters.bearing == bearing) {
|
||||||
|
onQtfDirectionChanged(bearing.degrees)
|
||||||
|
}
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ enum class StationFilter {
|
|||||||
internal fun parseQrbKm(text: String): Double? =
|
internal fun parseQrbKm(text: String): Double? =
|
||||||
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
|
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. */
|
/** 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) {
|
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),
|
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.view.i18n.LocalStrings
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kst4contest.view.compose.Form
|
import kst4contest.view.compose.Form
|
||||||
|
import kst4contest.view.compose.TooltipCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
* 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)
|
* @param onRefused shows why a committed entry was refused (the own ground height)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
@@ -86,10 +90,32 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
|||||||
|
|
||||||
Form.section(strings.stationDefaultsSection) {
|
Form.section(strings.stationDefaultsSection) {
|
||||||
Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
|
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.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.section(strings.stationRotatorSection) {
|
||||||
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
||||||
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
||||||
|
|||||||
@@ -165,7 +165,13 @@ class StationTabState(
|
|||||||
return null
|
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) }
|
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.test.runComposeUiTest
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kst4contest.model.Band
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ObservedValue
|
||||||
import kst4contest.view.compose.tabs.checkBeside
|
import kst4contest.view.compose.tabs.checkBeside
|
||||||
import kst4contest.view.compose.tabs.fieldBeside
|
import kst4contest.view.compose.tabs.fieldBeside
|
||||||
import kst4contest.view.compose.tabs.showTab
|
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.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ import kotlin.math.abs
|
|||||||
* Headless: only a composition, no window.
|
* Headless: only a composition, no window.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
class StationFilterBarScreenTest {
|
class StationFilterBarScreenTest(@param:TempDir private val directory: Path) {
|
||||||
|
|
||||||
private val maxLabel = "Show only QRB [km] <="
|
private val maxLabel = "Show only QRB [km] <="
|
||||||
private val minLabel = "Show only QRB [km] >"
|
private val minLabel = "Show only QRB [km] >"
|
||||||
@@ -45,6 +49,7 @@ class StationFilterBarScreenTest {
|
|||||||
private class Bar {
|
private class Bar {
|
||||||
val filters = StationFilterState()
|
val filters = StationFilterState()
|
||||||
var changes = 0
|
var changes = 0
|
||||||
|
val explicitQtfDirections = mutableListOf<Double>()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ComposeUiTest.showBar(): Bar {
|
private fun ComposeUiTest.showBar(): Bar {
|
||||||
@@ -55,11 +60,91 @@ class StationFilterBarScreenTest {
|
|||||||
activeBands = EnumSet.of(Band.B_144),
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||||
onChanged = { bar.changes++ },
|
onChanged = { bar.changes++ },
|
||||||
|
onQtfDirectionChanged = { bar.explicitQtfDirections += it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return bar
|
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
|
@Test
|
||||||
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
||||||
val filters = StationFilterState()
|
val filters = StationFilterState()
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ class DraftedSettingsTableTest {
|
|||||||
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
||||||
},
|
},
|
||||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = 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 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 rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
|
||||||
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
|
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.
|
* control further down can be scrolled to and clicked.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
|
internal fun ComposeUiTest.showTab(
|
||||||
|
darkMode: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ class StationTabScreenTest {
|
|||||||
showTab { StationTab(s.state) }
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
|
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("Callsign").assertTextEquals("DN9APW")
|
||||||
fieldBeside("Server port").assertTextEquals("23001")
|
fieldBeside("Server port").assertTextEquals("23001")
|
||||||
@@ -85,6 +87,36 @@ class StationTabScreenTest {
|
|||||||
checkBeside("10 GHz").assertIsOff()
|
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. */
|
/* The credentials write through; every other field waits for Apply. */
|
||||||
@Test
|
@Test
|
||||||
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
||||||
@@ -125,6 +157,18 @@ class StationTabScreenTest {
|
|||||||
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
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
|
* 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"
|
* 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 org.junit.jupiter.api.io.TempDir
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class StationTabStateTest(@TempDir private val tempDir: Path) {
|
class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private inner class Session {
|
private inner class Session {
|
||||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
@@ -149,6 +149,76 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
|||||||
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
|
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
|
@Test
|
||||||
fun `a discarded station setting leaves the preferences alone`() {
|
fun `a discarded station setting leaves the preferences alone`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ public class ChatPreferences {
|
|||||||
* only use is a best-effort warning when a file is newer than the application. Release
|
* 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
|
* 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.
|
* 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.
|
||||||
* <p>
|
* <p>
|
||||||
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
|
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
|
||||||
*/
|
*/
|
||||||
// private static final int CONFIG_VERSION = 2;
|
// 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.
|
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
|
||||||
private static final String TAG_CONFIG_VERSION = "configVersion";
|
private static final String TAG_CONFIG_VERSION = "configVersion";
|
||||||
@@ -202,6 +204,7 @@ public class ChatPreferences {
|
|||||||
int stn_on4kstServersPort = 23001;
|
int stn_on4kstServersPort = 23001;
|
||||||
|
|
||||||
boolean stn_pstRotatorEnabled = false;
|
boolean stn_pstRotatorEnabled = false;
|
||||||
|
boolean stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
|
||||||
String stn_pstRotatorHost = "127.0.0.1";
|
String stn_pstRotatorHost = "127.0.0.1";
|
||||||
int stn_pstRotatorPort = 12000;
|
int stn_pstRotatorPort = 12000;
|
||||||
boolean stn_pstRotator2Enabled = false;
|
boolean stn_pstRotator2Enabled = false;
|
||||||
@@ -259,6 +262,7 @@ public class ChatPreferences {
|
|||||||
boolean loginToSecondChatEnabled;
|
boolean loginToSecondChatEnabled;
|
||||||
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
|
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
|
||||||
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
|
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
|
||||||
|
private boolean actualQtfInitializedFromPreferences;
|
||||||
|
|
||||||
boolean stn_bandActive50;
|
boolean stn_bandActive50;
|
||||||
boolean stn_bandActive70;
|
boolean stn_bandActive70;
|
||||||
@@ -1052,6 +1056,20 @@ public class ChatPreferences {
|
|||||||
this.stn_pstRotatorEnabled = stn_pstRotatorEnabled;
|
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() {
|
public String getStn_pstRotatorHost() {
|
||||||
return stn_pstRotatorHost;
|
return stn_pstRotatorHost;
|
||||||
}
|
}
|
||||||
@@ -1224,7 +1242,9 @@ public class ChatPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setStn_qtfDefault(double stn_qtfDefault) {
|
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() {
|
public boolean isStn_loginAFKState() {
|
||||||
@@ -1742,6 +1762,12 @@ public class ChatPreferences {
|
|||||||
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
||||||
station.appendChild(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");
|
Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters");
|
||||||
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
|
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
|
||||||
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
|
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
|
||||||
@@ -2628,7 +2654,7 @@ public class ChatPreferences {
|
|||||||
|
|
||||||
stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg");
|
stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg");
|
||||||
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
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(
|
stn_pathAnalysisOwnAntennaHeightMeters = getDouble(
|
||||||
stationEl,
|
stationEl,
|
||||||
@@ -2738,6 +2764,16 @@ public class ChatPreferences {
|
|||||||
stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G");
|
stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G");
|
||||||
|
|
||||||
stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled");
|
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(
|
setStn_pstRotatorHost(
|
||||||
getText(
|
getText(
|
||||||
@@ -3411,10 +3447,18 @@ public class ChatPreferences {
|
|||||||
System.out.println(e.getMessage());
|
System.out.println(e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeActualQtfFromDefaultIfRequired();
|
||||||
return true;
|
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
|
* @return double array with separated values for each doublevalue, seperated by ";", empty array if none
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<praktiKST>
|
<praktiKST>
|
||||||
<configVersion>8</configVersion>
|
<configVersion>9</configVersion>
|
||||||
<station>
|
<station>
|
||||||
<LoginCallSign>DO5AMF</LoginCallSign>
|
<LoginCallSign>DO5AMF</LoginCallSign>
|
||||||
<LoginPassword>changeMe</LoginPassword>
|
<LoginPassword>changeMe</LoginPassword>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<stn_antennaBeamWidthDeg>60.0</stn_antennaBeamWidthDeg>
|
<stn_antennaBeamWidthDeg>60.0</stn_antennaBeamWidthDeg>
|
||||||
<stn_maxQRBDefault>1333.0</stn_maxQRBDefault>
|
<stn_maxQRBDefault>1333.0</stn_maxQRBDefault>
|
||||||
<stn_qtfDefault>135.0</stn_qtfDefault>
|
<stn_qtfDefault>135.0</stn_qtfDefault>
|
||||||
|
<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>false</stn_qtfFilterDirectionAsAntennaHeadingOptedOut>
|
||||||
<stn_bandActive144>true</stn_bandActive144>
|
<stn_bandActive144>true</stn_bandActive144>
|
||||||
<stn_bandActive432>true</stn_bandActive432>
|
<stn_bandActive432>true</stn_bandActive432>
|
||||||
<stn_bandActive1240>true</stn_bandActive1240>
|
<stn_bandActive1240>true</stn_bandActive1240>
|
||||||
|
|||||||
@@ -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, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>8</configVersion>
|
||||||
|
<station>
|
||||||
|
<stn_pstRotatorEnabled>false</stn_pstRotatorEnabled>
|
||||||
|
<stn_useQtfFilterDirectionAsAntennaHeading>false</stn_useQtfFilterDirectionAsAntennaHeading>
|
||||||
|
</station>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences migrated = read(file);
|
||||||
|
|
||||||
|
assertTrue(migrated.isStn_useQtfFilterDirectionAsAntennaHeading());
|
||||||
|
assertTrue(migrated.isQtfFilterDirectionAsAntennaHeadingEnabled());
|
||||||
|
|
||||||
|
assertTrue(migrated.writePreferencesToXmlFile());
|
||||||
|
String migratedXml = Files.readString(file);
|
||||||
|
assertTrue(migratedXml.contains("<configVersion>9</configVersion>"));
|
||||||
|
assertTrue(migratedXml.contains(
|
||||||
|
"<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>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(
|
||||||
|
"<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>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(
|
||||||
|
"<stn_qtfDefault>247.5</stn_qtfDefault>",
|
||||||
|
"<stn_qtfDefault>" + invalidValue + "</stn_qtfDefault>"));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,8 @@ class ChatPreferencesRoundTripTest {
|
|||||||
assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked"));
|
assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked"));
|
||||||
assertTrue(fromTemplate.isStn_bandActive144());
|
assertTrue(fromTemplate.isStn_bandActive144());
|
||||||
assertFalse(fromTemplate.isStn_bandActive10G());
|
assertFalse(fromTemplate.isStn_bandActive10G());
|
||||||
|
assertTrue(fromTemplate.isStn_useQtfFilterDirectionAsAntennaHeading());
|
||||||
|
assertTrue(fromTemplate.isQtfFilterDirectionAsAntennaHeadingEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -211,6 +213,8 @@ class ChatPreferencesRoundTripTest {
|
|||||||
assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat());
|
assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat());
|
||||||
assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber());
|
assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber());
|
||||||
assertEquals(8123, legacy.getNotify_dxclusterServerPort());
|
assertEquals(8123, legacy.getNotify_dxclusterServerPort());
|
||||||
|
assertTrue(legacy.isStn_useQtfFilterDirectionAsAntennaHeading());
|
||||||
|
assertTrue(legacy.isQtfFilterDirectionAsAntennaHeadingEnabled());
|
||||||
// A tag that is missing keeps the compiled-in default.
|
// A tag that is missing keeps the compiled-in default.
|
||||||
assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(),
|
assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(),
|
||||||
legacy.getStn_on4kstServersPort());
|
legacy.getStn_on4kstServersPort());
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ChatPreferencesDebugModeTest {
|
|||||||
assertTrue(written.writePreferencesToXmlFile());
|
assertTrue(written.writePreferencesToXmlFile());
|
||||||
|
|
||||||
String writtenXml = Files.readString(preferencesFile);
|
String writtenXml = Files.readString(preferencesFile);
|
||||||
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
|
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
|
||||||
assertTrue(writtenXml.contains(
|
assertTrue(writtenXml.contains(
|
||||||
"<messageHandling_debugModeToFileEnabled>false"
|
"<messageHandling_debugModeToFileEnabled>false"
|
||||||
+ "</messageHandling_debugModeToFileEnabled>"));
|
+ "</messageHandling_debugModeToFileEnabled>"));
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class ChatPreferencesLayoutPersistenceTest {
|
|||||||
assertFalse(writtenXml.contains("UNSAVED-CALL"));
|
assertFalse(writtenXml.contains("UNSAVED-CALL"));
|
||||||
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
|
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
|
||||||
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
|
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
|
||||||
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
|
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
|
||||||
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
|
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
|
||||||
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||||
+ "</GUIstationMapClusteringEnabled>"));
|
+ "</GUIstationMapClusteringEnabled>"));
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest {
|
|||||||
assertTrue(written.writePreferencesToXmlFile());
|
assertTrue(written.writePreferencesToXmlFile());
|
||||||
|
|
||||||
String writtenXml = Files.readString(preferencesFile);
|
String writtenXml = Files.readString(preferencesFile);
|
||||||
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
|
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
|
||||||
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||||
+ "</GUIstationMapClusteringEnabled>"));
|
+ "</GUIstationMapClusteringEnabled>"));
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.
|
- 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.
|
- 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
|
## Autoanswer / Beacon
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| **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 |
|
| **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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -836,6 +836,7 @@ Die Einstellungen befinden sich im Reiter **Station**:
|
|||||||
| Einstellung | Standardwert | Verwendung |
|
| Einstellung | Standardwert | Verwendung |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| **Enable PSTRotator** | deaktiviert | Startet die UDP-Kommunikation mit PSTRotator |
|
| **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 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 |
|
| **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
|
### Ü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.
|
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.
|
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.
|
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.
|
KST4Contest verwendet derzeit nur den Azimut. Eine Elevationssteuerung oder eine vollständige Azimut-/Elevationsnachführung ist nicht Bestandteil dieser Integration.
|
||||||
|
|
||||||
### Änderungen übernehmen
|
### Änderungen übernehmen
|
||||||
|
|||||||
@@ -838,6 +838,7 @@ The settings are located in the **Station** tab:
|
|||||||
| Setting | Default | Purpose |
|
| Setting | Default | Purpose |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| **Enable PSTRotator** | disabled | Starts UDP communication with PSTRotator |
|
| **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 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 |
|
| **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
|
### 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`.
|
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.
|
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.
|
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.
|
The current integration uses azimuth only. Elevation control and complete azimuth/elevation tracking are outside its present scope.
|
||||||
|
|
||||||
### Applying changed settings
|
### Applying changed settings
|
||||||
|
|||||||
@@ -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 |
|
| **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 |
|
| **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.
|
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.
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user