mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-26 16:15:51 +01:00
Path analysis: own ground height ASL and highest point of the remote locator square (#133)
* Path analysis: own ground height ASL and highest point of the remote square Two new settings in the path-analysis section of the Station tab: - Own ground height ASL (stn_pathAnalysisOwnHeightAslMeters). Empty keeps the elevation model value at the own locator centre; a value from -500 to 9000 m replaces the elevation of the first profile sample. The antenna height AGL is still added on top. The field commits on Enter or focus loss into the settings draft and reports a refused entry; it keeps the operator's spelling while that names the drafted number, because the draft is Compose state and would otherwise swap "25" for "25.0" under the cursor right after Enter. - Target station on the highest point of its locator square (stn_pathAnalysisDxHighestPointInSquare, on by default). The remote square is sampled on a 5 x 5 grid of cell centres in one batched request against the Open-Meteo provider, the highest sample becomes the path end with its position and elevation. LocatorHighestPointResolver caches the result per locator for the session and backs off five minutes after a failure (failure list pruned and bounded); the analysis then falls back to the square centre and that result is not kept. Both settings are drafted like the other path-analysis settings and take effect with Apply/Close or Save; the reachability service reads the stored values only. The lookup runs on the reachability thread only. Building a request peeks the resolver cache without blocking, so a cached square moves the endpoint right away. The calculation key now includes the own height and the remote ground elevation at full precision, and the switch, so changing either recalculates. Also assign the result of sanitizeEffectiveEarthRadiusFactor in the PathAnalysisRequest constructor; it was called and dropped. No behaviour change: the sanitizer is the identity for valid factors, and the analysis service sanitizes again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document own ground height and highest-point setting of the path analysis Changelog v1.50 (New), configuration and features pages in English and German, and the terrain section of PROJECT_CONTEXT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee4f1fe02e
commit
92c2576a81
29 files changed
+1789
-23
No files matched your search
@@ -75,6 +75,12 @@ class DraftedSettingsTableTest {
|
||||
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
|
||||
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
|
||||
// Through the field's commit, which validates and then stages.
|
||||
Case("station own ground height ASL", { it.station.commitOwnHeightAsl("312") },
|
||||
{ it.prefs.getStn_pathAnalysisOwnHeightAslMeters() }),
|
||||
flag("station target on highest point", { it.station.stn_pathAnalysisDxHighestPointInSquare }) { f, v ->
|
||||
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
||||
},
|
||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = 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() }),
|
||||
|
||||
@@ -14,6 +14,10 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
@@ -254,4 +258,169 @@ class StationTabScreenTest {
|
||||
waitForIdle()
|
||||
fieldBeside("Callsign").assertIsEnabled()
|
||||
}
|
||||
|
||||
private val ownHeightLabel = "Own ground height ASL, metres"
|
||||
private val antennaHeightLabel = "Own antenna height, metres"
|
||||
|
||||
/** Far down the tab: the row lookup compares on-screen bounds, so bring it into view. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.scrollToOwnHeight() {
|
||||
onNodeWithText(ownHeightLabel).performScrollTo()
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/** Types [keys] one by one into the cleared own-height field, as an operator would. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.typeKeysIntoOwnHeight(keys: String) {
|
||||
val field = fieldBeside(ownHeightLabel)
|
||||
field.performClick()
|
||||
field.performTextClearance()
|
||||
keys.forEach { field.performTextInput(it.toString()) }
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/** Leaves the own-height field by clicking the antenna height field above it. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.leaveOwnHeight() {
|
||||
fieldBeside(antennaHeightLabel).performClick()
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/*
|
||||
* Typed key by key, nothing is staged until the field is left, nothing reaches the
|
||||
* preferences before Apply, and the typed text is never replaced by the value read back
|
||||
* ("2" did turn into "2.0" while typing "250").
|
||||
*/
|
||||
@Test
|
||||
fun theOwnHeightIsTypedKeyByKeyAndCommittedOnLeavingTheField() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab { StationTab(s.state) { refusals += it } }
|
||||
scrollToOwnHeight()
|
||||
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("")
|
||||
|
||||
typeKeysIntoOwnHeight("250")
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "staged before the field was left")
|
||||
|
||||
leaveOwnHeight()
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
typeKeysIntoOwnHeight("12,5")
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||
leaveOwnHeight()
|
||||
assertEquals(12.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "the height took effect before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
waitForIdle()
|
||||
assertEquals(12.5, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||
|
||||
assertEquals(listOf<String>(), refusals)
|
||||
}
|
||||
|
||||
/*
|
||||
* After Enter the field keeps the operator's draft while the stored value reads back
|
||||
* differently ("250" against "250.0"). A recomposition of the tab must not swap the
|
||||
* draft for the stored text and drop what was typed since. The tab is forced to
|
||||
* recompose down to the field: the refusal callback captures a counter, so a new
|
||||
* counter value hands the path-analysis section a new lambda and it re-reads the
|
||||
* stored text. A connection change, the tab's own state, is driven as well.
|
||||
*/
|
||||
@Test
|
||||
fun aRecompositionAfterACommitKeepsTheKeysTypedSince() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val tick = mutableStateOf(0)
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab {
|
||||
val seen = tick.value
|
||||
StationTab(s.state) { refusals += "$it ($seen)" }
|
||||
}
|
||||
scrollToOwnHeight()
|
||||
|
||||
typeKeysIntoOwnHeight("25")
|
||||
fieldBeside(ownHeightLabel).performKeyInput { pressKey(Key.Enter) }
|
||||
waitForIdle()
|
||||
assertEquals(25.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
fieldBeside(ownHeightLabel).performTextInput("0")
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
runOnIdle { tick.value++ }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
fieldBeside(ownHeightLabel).performTextInput("1")
|
||||
waitForIdle()
|
||||
runOnIdle { tick.value++ }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||
|
||||
leaveOwnHeight()
|
||||
assertEquals(2501.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertEquals(listOf<String>(), refusals)
|
||||
|
||||
// Applying changes the stored spelling to "2501.0"; the field must keep "2501".
|
||||
runOnIdle { s.draft.apply() }
|
||||
waitForIdle()
|
||||
assertEquals(2501.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOutOfRangeOwnHeightIsRefusedAndFallsBackToTheStoredValue() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab { StationTab(s.state) { refusals += it } }
|
||||
scrollToOwnHeight()
|
||||
|
||||
typeAndCommit(ownHeightLabel, "250")
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
typeKeysIntoOwnHeight("9500")
|
||||
leaveOwnHeight()
|
||||
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "an out-of-range height was written")
|
||||
assertEquals(1, refusals.size, "the refusal was not reported")
|
||||
assertTrue(refusals.single().contains("9500"))
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearingTheOwnHeightHandsItBackToTheElevationModel() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
s.prefs.stn_pathAnalysisOwnHeightAslMeters = 312.0
|
||||
showTab { StationTab(s.state) }
|
||||
scrollToOwnHeight()
|
||||
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("312.0")
|
||||
|
||||
typeAndCommit(ownHeightLabel, "")
|
||||
|
||||
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "clearing did not hand back to the model")
|
||||
assertEquals(312.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "cleared before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHighestPointCheckboxIsOnAndTakesEffectOnApply() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
val label = "Target station on the highest point of its locator square"
|
||||
|
||||
checkBeside(label).assertIsOn()
|
||||
|
||||
toggle(label)
|
||||
checkBeside(label).assertIsOff()
|
||||
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare, "the switch took effect before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare)
|
||||
}
|
||||
}
|
||||
@@ -143,4 +143,87 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertTrue(prefs.isLoginToSecondChatEnabled())
|
||||
assertEquals(true, state.loginToSecondChatEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the own height above sea level reads empty while the elevation model decides`() {
|
||||
val s = Session()
|
||||
|
||||
assertEquals("", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl("312,5"))
|
||||
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "a commit took effect before Apply")
|
||||
assertEquals(312.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText, "the field keeps the committed spelling")
|
||||
|
||||
s.draft.apply()
|
||||
assertEquals(312.5, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl(" "))
|
||||
s.draft.apply()
|
||||
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "clearing hands it back to the model")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded own height shows the stored value again`() {
|
||||
val s = Session()
|
||||
s.prefs.setStn_pathAnalysisOwnHeightAslMeters(400.0)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||
assertEquals("250", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals("400.0", s.state.stn_pathAnalysisOwnHeightAslText, "the discarded spelling is still shown")
|
||||
assertEquals(400.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an own height that does not parse or is out of range is not staged`() {
|
||||
val s = Session()
|
||||
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||
|
||||
listOf("25x", "9500", "-600", "NaN").forEach { entry ->
|
||||
val refusal = s.state.commitOwnHeightAsl(entry)
|
||||
assertTrue(refusal != null && refusal.contains(entry), "no refusal naming \"$entry\": $refusal")
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "\"$entry\" was staged")
|
||||
}
|
||||
|
||||
s.draft.apply()
|
||||
assertEquals(250.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an own height draft is unchanged when it names the stored number`() {
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged("250", "250.0"))
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged("250,0", "250.0"))
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged(" ", ""))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("", "250.0"))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("250", ""))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("12.5", "12.0"))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("9500", "250.0"), "an unparsable draft must be committed and refused")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsing the own height`() {
|
||||
assertTrue(StationTabState.parseOwnHeightAsl("")!!.isNaN())
|
||||
assertEquals(-430.0, StationTabState.parseOwnHeightAsl("-430"))
|
||||
assertEquals(8848.0, StationTabState.parseOwnHeightAsl(" 8848 "))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("9000.5"))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("-"))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("NaN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the highest point switch waits in the draft until it is applied`() {
|
||||
val s = Session()
|
||||
assertTrue(s.state.stn_pathAnalysisDxHighestPointInSquare, "on by default")
|
||||
|
||||
s.state.stn_pathAnalysisDxHighestPointInSquare = false
|
||||
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare(), "the switch took effect before Apply")
|
||||
assertFalse(s.state.stn_pathAnalysisDxHighestPointInSquare)
|
||||
|
||||
s.draft.apply()
|
||||
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare())
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,10 @@ setStn_on4kstServersPort
|
||||
setStn_pathAnalysisDefaultTargetAntennaGainDbi
|
||||
setStn_pathAnalysisDefaultTargetTxPowerWatts
|
||||
setStn_pathAnalysisDemRootDirectory
|
||||
setStn_pathAnalysisDxHighestPointInSquare
|
||||
setStn_pathAnalysisOwnAntennaGainDbi
|
||||
setStn_pathAnalysisOwnAntennaHeightMeters
|
||||
setStn_pathAnalysisOwnHeightAslMeters
|
||||
setStn_pathAnalysisOwnTxPowerWatts
|
||||
setStn_pstRotatorEnabled
|
||||
setStn_pstRotatorHost
|
||||
|
||||
Reference in new issue
Block a user