mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
* Add a minimum QRB filter to the station filter bar (#21) Next to "Show only QRB [km] <=" the bar gets "Show only QRB [km] >": it hides every station up to the entered distance. With both switched on the list keeps a range, min < QRB <= max. The minimum is exclusive, as the issue asks, so the two filters split the list without overlap at the same value. A station without a known QRB is hidden while either is on, as the maximum already did. The value lives in the bar for the running session only, like the maximum, and the station map follows through activeFilters. The distance check moves from the controller predicate into StationFilterState.acceptsQrb so it can be tested; the maximum keeps its behaviour (null or > max is hidden). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document the minimum QRB filter (#21) Changelog v1.50 under New, the filter tables of the user interface pages, the distance filter section of the feature pages, and the station filter page of the website. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the first filter bar row wrap instead of clipping (#21) With both QRB filters the first row of the station filter bar no longer fits a narrow station pane, and as a plain Row it cut the bearing buttons off at the right edge. It is a FlowRow now. Reset, the QRB pair, the QTF controls and the bearings each move to the next line as one group; the QRB pair and the bearings only break up inside their group when even the group is wider than the pane. Separators in the wrapping row have a fixed height, because a flow row line has no intrinsic height to fill. A screen test at 300 dp checks that every bearing button, both QRB labels and fields and the QTF label are displayed and end inside the bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse NaN, Infinity and negatives in the QRB filter fields (#21) toDoubleOrNull also reads "NaN" and "Infinity": a pasted "Infinity" as the minimum hid every station, "NaN" let every one through, and the maximum field had the same hole before. Both QRB fields now go through parseQrbKm, which takes only finite, non-negative distances; anything else leaves the field at its last valid value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the station map follow the station filters (#21) The Compose map built its markers from all station rows, so no filter reached it, although its status line reported "filtered view active". The JavaFX map, up to beta-v1.50.0-beta-001, took the sorted, filtered list (StationMapBridge.refreshNow), so this is a regression of the Compose port that never shipped. The markers now come from the rows the list shows, and the filter is a key of the remembered markers so a filter change redraws them. A selected station that a filter hides gets no marker, as in the JavaFX map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Filter again on redraw; no path analysis for a hidden station (#21) DataTableState.visibleRows caches the filtered rows, and forceRedraw only bumped contentRevision. The filter reads fields that change in place (QRB, worked and QRV flags, last activity), so a redraw could hand back rows filtered before the change -- in the list and, since the map draws the same rows, on the map. forceRedraw now drops the cache, and visibleRows reads contentRevision so a reader recomputes after it. In the running application the user-list refresh also sets a new row filter, which dropped the cache before; this makes the redraw itself sufficient. A selected station without a marker -- hidden by a filter, or without a locator -- no longer starts a path analysis: the map used to set "Loading" with an empty target and send the request. The analysis is cleared instead, and the effect re-runs when the station's marker appears or disappears. 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
f17ae0905f
commit
ee4f1fe02e
19 files changed
+621
-73
No files matched your search
@@ -235,6 +235,7 @@ column.lastQrgTx=Letzte QRG TX
|
||||
filter.find=Suchen...
|
||||
filter.reset=Filter zurücksetzen
|
||||
filter.showOnlyQrb=Nur QRB [km] <=
|
||||
filter.showOnlyQrbAbove=Nur QRB [km] >
|
||||
filter.showOnlyQtf=Nur QTF:
|
||||
filter.hideWorked=Gearbeitete ausblenden:
|
||||
filter.inactiveStations=Inaktive Stationen
|
||||
|
||||
@@ -244,6 +244,7 @@ column.lastQrgTx=Last QRG TX
|
||||
filter.find=Find...
|
||||
filter.reset=Reset filters
|
||||
filter.showOnlyQrb=Show only QRB [km] <=
|
||||
filter.showOnlyQrbAbove=Show only QRB [km] >
|
||||
filter.showOnlyQtf=Show only QTF:
|
||||
filter.hideWorked=Hide worked:
|
||||
filter.inactiveStations=Inactive stations
|
||||
|
||||
@@ -2628,10 +2628,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
|
||||
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
|
||||
|
||||
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
|
||||
Double qrb = member.getQrb();
|
||||
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
|
||||
}
|
||||
if (!state.acceptsQrb(member.getQrb())) return false;
|
||||
|
||||
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
|
||||
Double qtf = member.getQTFdirection();
|
||||
|
||||
@@ -275,7 +275,14 @@ class DataTableState<T>(
|
||||
var contentRevision by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Redraws the rows and filters them again. The filter reads mutable row fields (a
|
||||
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
|
||||
* from before the change could keep a row the filter now excludes, or hide one it
|
||||
* now lets through -- in the list and in the station map, which draws the same rows.
|
||||
*/
|
||||
fun forceRedraw() {
|
||||
invalidateOrder()
|
||||
contentRevision++
|
||||
}
|
||||
|
||||
@@ -299,6 +306,7 @@ class DataTableState<T>(
|
||||
*/
|
||||
val currentRows = rows
|
||||
val currentFilter = rowFilter
|
||||
contentRevision // read for the subscription: forceRedraw drops the cache
|
||||
val currentColumn = sortedColumnId
|
||||
val ascending = sortAscending
|
||||
|
||||
|
||||
@@ -48,11 +48,19 @@ fun StationFilterBar(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
// ROW 1: Reset | QRB | QTF + bearings
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
/*
|
||||
* ROW 1: Reset | QRB max + min | QTF | bearings
|
||||
*
|
||||
* It wraps (#21): with both QRB filters the line no longer fits a narrow station
|
||||
* pane, and a plain row cut the bearing buttons off at the right edge. Each group
|
||||
* stays together on its line; only the QRB pair and the bearings may break up
|
||||
* inside their group when even that group is wider than the pane.
|
||||
*/
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Reset filters
|
||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||
@@ -60,65 +68,105 @@ fun StationFilterBar(
|
||||
onChanged()
|
||||
}
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
// QRB checkbox + field
|
||||
CompactCheckbox(
|
||||
checked = filters.maxQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.maxQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.maxQrbKm = parsed
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.maxQrbKm = 0.0
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
// QRB maximum + minimum (#21): together they make a range, so they stay one group
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.maxQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.maxQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.maxQrbKm = parsed
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.maxQrbKm = 0.0
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
// QTF checkbox + field + bearings
|
||||
CompactCheckbox(
|
||||
checked = filters.qtfEnabled,
|
||||
onCheckedChange = {
|
||||
filters.qtfEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.minQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.minQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.minQrbKm = parsed
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.minQrbKm = 0.0
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(bearing.name, on = filters.bearing == bearing) {
|
||||
filters.toggleBearing(bearing)
|
||||
onChanged()
|
||||
// QTF checkbox + field
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
CompactCheckbox(
|
||||
checked = filters.qtfEnabled,
|
||||
onCheckedChange = {
|
||||
filters.qtfEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
// Bearings
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(bearing.name, on = filters.bearing == bearing) {
|
||||
filters.toggleBearing(bearing)
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +283,31 @@ private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Controls that belong together and move to the next line of a wrapping row as one. */
|
||||
@Composable
|
||||
private fun FilterGroup(content: @Composable () -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills
|
||||
* the row's intrinsic height, which a line of a flow row does not offer.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupSeparator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.height(18.dp)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
|
||||
/** Thin vertical separator matching JavaFX's group dividers. */
|
||||
@Composable
|
||||
private fun VerticalSeparator() {
|
||||
|
||||
@@ -18,6 +18,7 @@ enum class StationFilter {
|
||||
NEW_BANDS,
|
||||
AIRSCOUT_NEXT_5_MIN,
|
||||
MAX_QRB,
|
||||
MIN_QRB,
|
||||
QTF,
|
||||
SEARCH_TEXT,
|
||||
HIDE_INACTIVE,
|
||||
@@ -33,6 +34,17 @@ enum class StationFilter {
|
||||
HIDE_WORKED_3,
|
||||
}
|
||||
|
||||
/**
|
||||
* What a QRB field of the filter bar makes of the text typed into it: a distance in km, or
|
||||
* `null` when the text is none.
|
||||
*
|
||||
* `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum
|
||||
* hid every station while "NaN" let every one through. A negative distance means nothing
|
||||
* either. All of these are refused, so the field keeps its last valid value (#21).
|
||||
*/
|
||||
internal fun parseQrbKm(text: String): Double? =
|
||||
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.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),
|
||||
@@ -99,6 +111,16 @@ class StationFilterState {
|
||||
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
||||
var maxQrbKm: Double by mutableStateOf(0.0)
|
||||
|
||||
var minQrbEnabled: Boolean
|
||||
get() = isOn(StationFilter.MIN_QRB)
|
||||
set(value) = switch(StationFilter.MIN_QRB, value)
|
||||
|
||||
/**
|
||||
* Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is
|
||||
* hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21).
|
||||
*/
|
||||
var minQrbKm: Double by mutableStateOf(0.0)
|
||||
|
||||
var qtfEnabled: Boolean
|
||||
get() = isOn(StationFilter.QTF)
|
||||
set(value) = switch(StationFilter.QTF, value)
|
||||
@@ -130,6 +152,23 @@ class StationFilterState {
|
||||
|
||||
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
|
||||
|
||||
/**
|
||||
* Whether a station [qrb] km away passes the distance filters that are switched on.
|
||||
*
|
||||
* Both on make a range: `min < qrb <= max`. A station without a known distance is
|
||||
* hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance
|
||||
* can be in neither range.
|
||||
*/
|
||||
fun acceptsQrb(qrb: Double?): Boolean {
|
||||
val max = isOn(StationFilter.MAX_QRB)
|
||||
val min = isOn(StationFilter.MIN_QRB)
|
||||
if (!max && !min) return true
|
||||
if (qrb == null) return false
|
||||
if (max && qrb > maxQrbKm) return false
|
||||
if (min && qrb <= minQrbKm) return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
|
||||
|
||||
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
||||
|
||||
@@ -129,7 +129,19 @@ object StationMapWindow {
|
||||
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
||||
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
||||
|
||||
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
|
||||
/*
|
||||
* The markers are the stations the list shows, not all of them (#21): the
|
||||
* JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and
|
||||
* the status line below already says "filtered view active". A selected
|
||||
* station that a filter hides gets no marker either, as it did there.
|
||||
*/
|
||||
val markers by remember(
|
||||
mainWindowState.stations.rows,
|
||||
mainWindowState.stations.rowFilter,
|
||||
mainWindowState.stations.contentRevision,
|
||||
selectedMember,
|
||||
mainWindowState.activeBands,
|
||||
) {
|
||||
derivedStateOf {
|
||||
val builder = MapCallsignRawSnapshotBuilder()
|
||||
val activeBands = mainWindowState.activeBands
|
||||
@@ -140,7 +152,7 @@ object StationMapWindow {
|
||||
}
|
||||
|
||||
builder.buildSnapshots(
|
||||
mainWindowState.stations.rows,
|
||||
mainWindowState.stations.visibleRows,
|
||||
selectedMember,
|
||||
bandSet
|
||||
)
|
||||
@@ -152,17 +164,29 @@ object StationMapWindow {
|
||||
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
||||
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
||||
|
||||
LaunchedEffect(selectedMember) {
|
||||
/*
|
||||
* Whether the selected station has a marker. A filter can hide it (#21), and a
|
||||
* station without a locator never has one; then there is nothing to analyse.
|
||||
*/
|
||||
val selectedHasMarker = selectedMember != null &&
|
||||
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
LaunchedEffect(selectedMember, selectedHasMarker) {
|
||||
if (selectedMember == null) {
|
||||
pathAnalysisResult = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
if (selectedSnapshot == null) {
|
||||
// No marker: no request, and no "Loading" for a station the map does not show.
|
||||
pathAnalysisResult = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val analysis = currentPathAnalysis()
|
||||
if (analysis != null) {
|
||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
|
||||
if (selectedSnapshot.hasUsablePosition()) {
|
||||
/*
|
||||
* Pan to the station and leave the zoom alone. The Leaflet map
|
||||
* did exactly this (focusCallsignRaw) and touched the zoom only
|
||||
@@ -183,8 +207,8 @@ object StationMapWindow {
|
||||
|
||||
pathAnalysisResult = PathAnalysisResult.loading(
|
||||
prefs.stn_loginLocatorMainCat,
|
||||
selectedSnapshot?.locator6() ?: "",
|
||||
selectedSnapshot?.callSignRaw() ?: ""
|
||||
selectedSnapshot.locator6(),
|
||||
selectedSnapshot.callSignRaw()
|
||||
)
|
||||
|
||||
analysis.request(selectedMember, selectedSnapshot) { result ->
|
||||
|
||||
@@ -322,6 +322,26 @@ class DataTableStateTest {
|
||||
|
||||
assertSame(kept, table.selectedRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter reads fields of the row objects, which change in place: a station's QRB,
|
||||
* its worked flags, its last activity. forceRedraw is how the application says so, and
|
||||
* the cached visible rows must be filtered again after it (#21).
|
||||
*/
|
||||
@Test
|
||||
fun `a redraw filters rows again whose fields changed in place`() {
|
||||
val table = stationTable()
|
||||
val moving = Station("DN9APW", 100)
|
||||
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
|
||||
table.rowFilter = { it.qrb > 300 }
|
||||
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
|
||||
|
||||
moving.qrb = 500
|
||||
table.forceRedraw()
|
||||
|
||||
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
|
||||
"the cached rows from before the change were handed back")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.view.compose.tabs.checkBeside
|
||||
import kst4contest.view.compose.tabs.fieldBeside
|
||||
import kst4contest.view.compose.tabs.showTab
|
||||
import kst4contest.view.compose.tabs.toggle
|
||||
import kst4contest.view.compose.tabs.type
|
||||
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 java.util.EnumSet
|
||||
|
||||
/**
|
||||
* The QRB part of the station filter bar, drawn (#21).
|
||||
*
|
||||
* [StationFilterStateTest] pins which distances pass; this pins that the two QRB controls
|
||||
* on screen write into their own filter and not into the other one. The two labels differ
|
||||
* only in `<=` and `>`, so a swapped binding would look right and filter the wrong side.
|
||||
*
|
||||
* Headless: only a composition, no window.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StationFilterBarScreenTest {
|
||||
|
||||
private val maxLabel = "Show only QRB [km] <="
|
||||
private val minLabel = "Show only QRB [km] >"
|
||||
|
||||
private class Bar {
|
||||
val filters = StationFilterState()
|
||||
var changes = 0
|
||||
}
|
||||
|
||||
private fun ComposeUiTest.showBar(): Bar {
|
||||
val bar = Bar()
|
||||
showTab {
|
||||
StationFilterBar(
|
||||
filters = bar.filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
onChanged = { bar.changes++ },
|
||||
)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
type(minLabel, "300")
|
||||
toggle(minLabel)
|
||||
|
||||
assertTrue(bar.filters.minQrbEnabled)
|
||||
assertEquals(300.0, bar.filters.minQrbKm)
|
||||
assertEquals(setOf(StationFilter.MIN_QRB), bar.filters.activeFilters)
|
||||
assertEquals(0.0, bar.filters.maxQrbKm, "the minimum field wrote into the maximum")
|
||||
assertTrue(bar.changes > 0, "switching the filter on did not reapply the filters")
|
||||
checkBeside(minLabel).assertIsOn()
|
||||
checkBeside(maxLabel).assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothQrbControlsTogetherMakeARange() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
type(maxLabel, "500")
|
||||
toggle(maxLabel)
|
||||
type(minLabel, "200")
|
||||
toggle(minLabel)
|
||||
|
||||
assertEquals(setOf(StationFilter.MAX_QRB, StationFilter.MIN_QRB), bar.filters.activeFilters)
|
||||
fieldBeside(maxLabel).assertTextEquals("500")
|
||||
fieldBeside(minLabel).assertTextEquals("200")
|
||||
assertFalse(bar.filters.acceptsQrb(150.0))
|
||||
assertTrue(bar.filters.acceptsQrb(350.0))
|
||||
assertFalse(bar.filters.acceptsQrb(650.0))
|
||||
}
|
||||
|
||||
/** A pasted "Infinity" as the minimum hid every station, a "NaN" let every one through. */
|
||||
@Test
|
||||
fun theQrbFieldsKeepTheirValueWhenTheTextIsNoDistance() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
type(maxLabel, "500")
|
||||
type(minLabel, "200")
|
||||
|
||||
listOf("Infinity", "NaN", "-5").forEach { text ->
|
||||
type(maxLabel, text)
|
||||
type(minLabel, text)
|
||||
}
|
||||
|
||||
assertEquals(500.0, bar.filters.maxQrbKm)
|
||||
assertEquals(200.0, bar.filters.minQrbKm)
|
||||
fieldBeside(maxLabel).assertTextEquals("500")
|
||||
fieldBeside(minLabel).assertTextEquals("200")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resettingTheFiltersUnticksTheMinimumQrb() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
toggle(minLabel)
|
||||
checkBeside(minLabel).assertIsOn()
|
||||
|
||||
onNodeWithText("Reset filters").performClick()
|
||||
waitForIdle()
|
||||
|
||||
checkBeside(minLabel).assertIsOff()
|
||||
assertFalse(bar.filters.minQrbEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first row used to be a plain row: with both QRB filters in it, a narrow station
|
||||
* pane cut the bearing buttons off at the right edge. It wraps now, and every control
|
||||
* has to stay on screen.
|
||||
*/
|
||||
@Test
|
||||
fun aNarrowBarWrapsInsteadOfCuttingControlsOff() = runComposeUiTest {
|
||||
val filters = StationFilterState()
|
||||
val width = 300.dp
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(width)) {
|
||||
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
val limit = with(density) { width.toPx() }
|
||||
val controls = Bearing.entries.map { onNodeWithText(it.name) } +
|
||||
listOf(
|
||||
onNodeWithText(maxLabel),
|
||||
onNodeWithText(minLabel),
|
||||
onNodeWithText("Show only QTF:"),
|
||||
fieldBeside(maxLabel),
|
||||
fieldBeside(minLabel),
|
||||
)
|
||||
controls.forEach { node ->
|
||||
node.assertIsDisplayed()
|
||||
val right = node.fetchSemanticsNode().boundsInRoot.right
|
||||
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,108 @@ class StationFilterStateTest {
|
||||
assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the minimum QRB filter needs its own switch`() {
|
||||
val filters = StationFilterState()
|
||||
|
||||
filters.minQrbKm = 300.0
|
||||
assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing")
|
||||
|
||||
filters.minQrbEnabled = true
|
||||
assertEquals(setOf(StationFilter.MIN_QRB), filters.activeFilters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetting switches both QRB filters off`() {
|
||||
val filters = StationFilterState()
|
||||
filters.maxQrbEnabled = true
|
||||
filters.minQrbEnabled = true
|
||||
|
||||
filters.reset()
|
||||
|
||||
assertTrue(filters.activeFilters.isEmpty())
|
||||
assertFalse(filters.minQrbEnabled)
|
||||
assertFalse(filters.maxQrbEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without a QRB filter every distance passes, an unknown one too`() {
|
||||
val filters = StationFilterState()
|
||||
filters.maxQrbKm = 100.0
|
||||
filters.minQrbKm = 500.0
|
||||
|
||||
assertTrue(filters.acceptsQrb(50.0))
|
||||
assertTrue(filters.acceptsQrb(1000.0))
|
||||
assertTrue(filters.acceptsQrb(null))
|
||||
}
|
||||
|
||||
/** The behaviour the controller predicate had before #21 moved it here. */
|
||||
@Test
|
||||
fun `the maximum QRB keeps stations up to and including the distance`() {
|
||||
val filters = StationFilterState()
|
||||
filters.maxQrbEnabled = true
|
||||
filters.maxQrbKm = 300.0
|
||||
|
||||
assertTrue(filters.acceptsQrb(299.9))
|
||||
assertTrue(filters.acceptsQrb(300.0))
|
||||
assertFalse(filters.acceptsQrb(300.1))
|
||||
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a short one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the minimum QRB keeps only stations further away than the distance`() {
|
||||
val filters = StationFilterState()
|
||||
filters.minQrbEnabled = true
|
||||
filters.minQrbKm = 300.0
|
||||
|
||||
assertFalse(filters.acceptsQrb(299.9))
|
||||
assertFalse(filters.acceptsQrb(300.0), "the issue asks for QRB > X, so X itself is out")
|
||||
assertTrue(filters.acceptsQrb(300.1))
|
||||
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a long one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both QRB filters together keep a distance range`() {
|
||||
val filters = StationFilterState()
|
||||
filters.minQrbEnabled = true
|
||||
filters.minQrbKm = 200.0
|
||||
filters.maxQrbEnabled = true
|
||||
filters.maxQrbKm = 500.0
|
||||
|
||||
assertFalse(filters.acceptsQrb(200.0))
|
||||
assertTrue(filters.acceptsQrb(200.1))
|
||||
assertTrue(filters.acceptsQrb(500.0))
|
||||
assertFalse(filters.acceptsQrb(500.1))
|
||||
assertFalse(filters.acceptsQrb(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a range with the minimum above the maximum keeps nothing`() {
|
||||
val filters = StationFilterState()
|
||||
filters.minQrbEnabled = true
|
||||
filters.minQrbKm = 500.0
|
||||
filters.maxQrbEnabled = true
|
||||
filters.maxQrbKm = 200.0
|
||||
|
||||
listOf(100.0, 300.0, 600.0).forEach {
|
||||
assertFalse(filters.acceptsQrb(it), "$it km passed an empty range")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a QRB field reads plain distances`() {
|
||||
assertEquals(300.0, parseQrbKm("300"))
|
||||
assertEquals(12.5, parseQrbKm("12.5"))
|
||||
assertEquals(0.0, parseQrbKm("0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a QRB field refuses what is no distance`() {
|
||||
listOf("NaN", "Infinity", "-Infinity", "-5", "abc", "").forEach {
|
||||
assertNull(parseQrbKm(it), "\"$it\" was taken as a distance")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search text filters while it is there and stops when it is cleared`() {
|
||||
val filters = StationFilterState()
|
||||
|
||||
@@ -195,4 +195,30 @@ class StationListScreenTest {
|
||||
onNodeWithText("DL2BBB").assertDoesNotExist()
|
||||
assertEquals(null, state.selectedRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* A station's fields change in place and the application calls forceRedraw: the list
|
||||
* filters again, so a station the filter now excludes leaves the screen (#21).
|
||||
*/
|
||||
@Test
|
||||
fun aRedrawAfterAFieldChangedFiltersTheListAgain() = runComposeUiTest {
|
||||
val state = stationList()
|
||||
val moving = station("DL1MOVE", qrb = 500.0)
|
||||
show(state)
|
||||
runOnIdle {
|
||||
state.replaceRows(listOf(moving, station("DL2STAY", qrb = 800.0)))
|
||||
state.rowFilter = { (it.qrb ?: 0.0) > 300.0 }
|
||||
}
|
||||
waitForIdle()
|
||||
onNodeWithText("DL1MOVE").assertExists()
|
||||
|
||||
runOnIdle {
|
||||
moving.qrb = 100.0
|
||||
state.forceRedraw()
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("DL1MOVE").assertDoesNotExist()
|
||||
onNodeWithText("DL2STAY").assertExists()
|
||||
}
|
||||
}
|
||||
+89
@@ -4,7 +4,9 @@ import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -381,4 +383,91 @@ class StationMapWindowScreenTest {
|
||||
onNode(isToggleable()).assertIsOn()
|
||||
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* The map shows the stations the list shows (#21). The Compose map drew every station
|
||||
* whatever the filter bar said, although its status line reported a filtered view; the
|
||||
* JavaFX map took the filtered list. A filter switched on after the first draw has to
|
||||
* take the marker away too, and a click where it stood selects nothing.
|
||||
*/
|
||||
@Test
|
||||
fun aStationTheFiltersHideHasNoMarker() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
|
||||
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
|
||||
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
|
||||
onNodeWithText("Showing 2 visible stations").assertExists()
|
||||
|
||||
runOnIdle {
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
|
||||
|
||||
// The map looks straight at the hidden station: a click there finds no marker.
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
|
||||
waitForIdle()
|
||||
assertEquals(emptyList<ChatMember?>(), selectorCalls, "the hidden station still had a marker")
|
||||
}
|
||||
|
||||
/** A station whose QRB changed in place leaves the map after the redraw, as it leaves the list. */
|
||||
@Test
|
||||
fun aRedrawAfterAFieldChangedFiltersTheMapAgain() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val moving = station("DL1MOVE", 2, locator = "JO50JP").apply { qrb = 500.0 }
|
||||
val stay = station("DL2STAY", 2, locator = "JO62QM").apply { qrb = 800.0 }
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
|
||||
show(state, listOf(moving, stay), mapLookingAt("JO50JP", zoom = 12f))
|
||||
onNodeWithText("Showing 2 visible stations | filtered view active").assertExists()
|
||||
|
||||
runOnIdle {
|
||||
moving.qrb = 100.0
|
||||
state.stations.forceRedraw()
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected station the filters hide has no marker, and so nothing to analyse: no
|
||||
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
|
||||
* The visible station beside it is the control: selecting it does ask and does load.
|
||||
*/
|
||||
@Test
|
||||
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
|
||||
val state = windowState(prefs(pathAnalysisVisible = true))
|
||||
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
|
||||
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
val analysis = RecordingPathAnalysis()
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9f)
|
||||
val home = Location(ownLocator)
|
||||
|
||||
show(state, listOf(hidden, shown), mapState, analysis)
|
||||
|
||||
runOnIdle { state.selectedStation.select(hidden) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
|
||||
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
|
||||
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
|
||||
|
||||
runOnIdle { state.selectedStation.select(shown) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf(shown), analysis.requested)
|
||||
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user