Files
kst4contest/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt
T
Rsclub2_2 cc8c90204e Draw the whole interface in Compose
Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
2026-10-05 22:33:28 +02:00

227 lines
6.9 KiB
Kotlin

package kst4contest.view.compose
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The panel below the station list, for the station currently selected.
*
* Its nine "tag not qrv" marks are written back to the station and propagated to the
* active members, so getting one wrong hides a station from every list that filters on
* it. The sked controls create a real reminder. Both are worth pinning.
*/
class SelectedStationStateTest {
private class Session {
val propagated = mutableListOf<String>()
var skedsCreated = 0
var lastSkedMinutes: Int? = null
var lastSkedMode: String? = null
val failMarked = mutableListOf<String>()
val failReset = mutableListOf<String>()
fun state() = SelectedStationState(
propagateNotQrv = { propagated.add(it.callSign.orEmpty()) },
createSked = { _, minutes, _, mode ->
skedsCreated++
lastSkedMinutes = minutes
lastSkedMode = mode
true
},
markSkedFail = { failMarked.add(it.callSign.orEmpty()) },
resetSkedFail = { failReset.add(it.callSign.orEmpty()) },
)
}
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun `without a selection there is nothing to act on`() {
val state = Session().state()
assertNull(state.selected)
assertFalse(state.canCreateSked, "a sked needs a station to be with")
assertFalse(state.canLookUp, "there is no callsign to look up")
}
@Test
fun `tagging a band writes it to the station and propagates`() {
val session = Session()
val state = session.state()
val dn9apw = member("DN9APW")
state.select(dn9apw)
state.tagNotQrv(NotQrvBand.B_144, true)
assertFalse(dn9apw.isQrv144, "not QRV means the QRV flag goes false")
assertEquals(listOf("DN9APW"), session.propagated,
"every list filtering on QRV has to learn about it")
}
@Test
fun `tag not qrv all covers every band`() {
val session = Session()
val state = session.state()
val member = member("DO5AMF")
state.select(member)
state.tagNotQrvAll(true)
NotQrvBand.entries.forEach { band ->
assertTrue(state.isTaggedNotQrv(band), "band ${band.label}")
}
assertFalse(member.isQrv50)
assertFalse(member.isQrv10G)
}
@Test
fun `clearing tag not qrv all puts every band back`() {
val state = Session().state()
val member = member("DO5AMF")
state.select(member)
state.tagNotQrvAll(true)
state.tagNotQrvAll(false)
NotQrvBand.entries.forEach { band ->
assertFalse(state.isTaggedNotQrv(band), "band ${band.label}")
}
assertTrue(member.isQrv144)
}
@Test
fun `selecting another station shows that station's tags`() {
val state = Session().state()
val tagged = member("DN9APW").apply { isQrv432 = false }
val untagged = member("DO5AMF")
state.select(tagged)
assertTrue(state.isTaggedNotQrv(NotQrvBand.B_432))
state.select(untagged)
assertFalse(state.isTaggedNotQrv(NotQrvBand.B_432),
"the panel shows the selected station, not the one before it")
}
@Test
fun `a sked needs a selection and is created once`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.skedMinutes = 10
assertTrue(state.canCreateSked)
assertTrue(state.createSked())
assertEquals(1, session.skedsCreated)
assertEquals(10, session.lastSkedMinutes)
}
@Test
fun `creating a sked without a selection does nothing`() {
val session = Session()
val state = session.state()
assertFalse(state.createSked())
assertEquals(0, session.skedsCreated)
}
@Test
fun `the message filter is one choice, not four`() {
val state = Session().state()
state.messageFilter = SelectedMessageFilter.PM_TO_ME
assertEquals(SelectedMessageFilter.PM_TO_ME, state.messageFilter)
state.messageFilter = SelectedMessageFilter.PUBLIC
assertEquals(SelectedMessageFilter.PUBLIC, state.messageFilter,
"the JavaFX radio buttons were one toggle group; two cannot be on")
}
/**
* No filter is chosen until the operator chooses one. JavaFX has the
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
* and starting on a filter would silently hide messages the operator never asked to
* hide.
*/
@Test
fun `no message filter is chosen to begin with`() {
assertNull(Session().state().messageFilter)
}
@Test
fun `the lookup addresses carry the selected callsign`() {
val state = Session().state()
state.select(member("DN9APW"))
assertTrue(state.qrzComUrl().endsWith("DN9APW"))
assertTrue(state.qrzCqUrl().endsWith("DN9APW"))
}
@Test
fun `the sked offers the minutes the JavaFX choice offered`() {
assertEquals(
listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20),
SelectedStationState.SKED_MINUTES,
)
assertEquals(5, Session().state().skedMinutes, "the default it selected")
}
@Test
fun `the sked offers the two modes`() {
assertEquals(listOf("SSB", "CW"), SelectedStationState.SKED_MODES)
}
@Test
fun `the reminder offers the three offset patterns`() {
assertEquals(listOf("2+1", "5+2+1", "10+5+2+1"), SelectedStationState.REMINDER_OFFSETS)
}
@Test
fun `the chosen mode reaches the sked`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.skedMode = "CW"
state.createSked()
assertEquals("CW", session.lastSkedMode)
}
@Test
fun `marking a sked as failed names the selected station`() {
val session = Session()
val state = session.state()
state.select(member("DN9APW"))
state.markSkedFail()
assertEquals(listOf("DN9APW"), session.failMarked)
}
@Test
fun `resetting the failure names it too`() {
val session = Session()
val state = session.state()
state.select(member("DO5AMF"))
state.resetSkedFail()
assertEquals(listOf("DO5AMF"), session.failReset)
}
@Test
fun `marking a failure without a selection does nothing`() {
val session = Session()
session.state().markSkedFail()
assertTrue(session.failMarked.isEmpty())
}
}