Files
kst4contest/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.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

174 lines
6.4 KiB
Kotlin

package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
* nothing Compose can see. Mirroring them into Compose state is what makes the button row
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
assertEquals(listOf("MYQRG", "pse sked"), mirror.entries)
}
/**
* The roster is written through by the settings window on every committed edit. The
* mirror only catches up when it is told to, which is what the refresh hook is for.
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "SECONDQRG", "qrz?"))
assertEquals(listOf("MYQRG"), mirror.entries, "the mirror moved without being refreshed")
mirror.refresh()
assertEquals(listOf("MYQRG", "SECONDQRG", "qrz?"), mirror.entries)
}
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
prefs.getLst_txtShortCutBtnList().clear()
mirror.refresh()
assertTrue(mirror.entries.isEmpty())
}
/**
* The button row is drawn from this, so reading it has to register as a read — the
* failure that produced most of this migration's invisible defects.
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
mirror.entries.forEach { it.length }
}
assertTrue(read.isNotEmpty(), "the mirror was read without subscribing")
}
}
/**
* The splitters of the main window against the positions the profile actually stores.
*
* A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so.
*/
class MainWindowSplitterDefaultsTest {
private val prefs = ChatPreferences()
@Test
fun `the message column has as many dividers as the profile stores positions`() {
val stored = prefs.getGUImessageSectionSplitpane_dividerposition()
assertEquals(
MainWindowState.MESSAGE_PANE_COUNT - 1,
stored.size,
"the message splitter has ${MainWindowState.MESSAGE_PANE_COUNT} panes but the " +
"profile stores ${stored.size} positions",
)
val splitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, stored) {}
assertEquals(
stored.map { it.toFloat() },
splitter.positions,
"the stored positions were rejected and silently replaced by an even split",
)
}
@Test
fun `the station column has as many dividers as the profile stores positions`() {
val stored = prefs.getGUImainWindowRightSplitPane_dividerposition()
assertEquals(MainWindowState.RIGHT_PANE_COUNT - 1, stored.size)
val splitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
@Test
fun `the outer splitter takes the one position the profile stores`() {
val stored = prefs.getGUImainWindowLeftSplitPane_dividerposition()
assertEquals(1, stored.size)
val splitter = SplitterState(paneCount = 2, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
}
/**
* What the window draws about its surroundings — the link, and which other windows are
* open — has to be Compose state as well.
*
* These come from the controller and from AtomicBooleans inside the window hosts, none of
* which Compose can see. Reading them through a plain lambda at composition time draws them
* once: the badge would freeze on whatever the link was when the window opened, and the
* menu's "hide options" would keep saying the wrong thing.
*/
class MainWindowSurroundingsTest {
@Test
fun `the connection state starts disconnected and follows what it is told`() {
val state = MainWindowSurroundings()
assertEquals(On4KstConnectionState.DISCONNECTED, state.connectionState)
state.connectionState = On4KstConnectionState.ONLINE
assertEquals(On4KstConnectionState.ONLINE, state.connectionState)
}
@Test
fun `drawing the surroundings subscribes to every value`() {
val state = MainWindowSurroundings()
val read = mutableSetOf<String>()
Snapshot.observe(readObserver = { read += it.toString() }) {
state.connectionState
state.connectionDetail
state.settingsWindowOpen
state.monitorWindowOpen
}
assertEquals(4, read.size, "a value was read without subscribing: $read")
}
/**
* The menu and the badge must never disagree about the link — they are two renderings of
* one fact, and an operator comparing them would not know which to believe.
*/
@Test
fun `the menu follows the same connection state as the badge`() {
val menu = MainMenuState()
val state = MainWindowSurroundings(menu)
state.connectionState = On4KstConnectionState.ONLINE
assertEquals(On4KstConnectionState.ONLINE, menu.connectionState)
assertTrue(menu.canUseChatActions)
}
}