Compare commits

...
Author SHA1 Message Date
Marc Froehlich 643ac3e693 fix: filter public messages correctly
Fixes #162
2026-11-03 23:15:09 +01:00
Rsclub2_2 c23982c339 test: fix the CI hang in the Compose rendering tests (#157)
* test: print all threads when a test times out

The rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a
monitor in SnapshotStateObserver.observeReads (line 643 is the monitorenter
of observedScopeMapsLock in Compose 1.8.2). The timed-out thread's stack
shows where it waits but not which thread holds the lock. With the thread
dump enabled, JUnit prints every thread to the test's standard output, which
ends up in the XML report of the test-results artifact.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y

* test: render every ImageComposeScene on the AWT event thread

The rare CI hang is a lock-order deadlock in Compose Multiplatform 1.8.2,
shown by the thread dump of run 40299943329:

- the test thread renders the scene and holds the FlushCoroutineDispatcher
  runLock while LazyListState.scrollToItem measures and wants the
  SnapshotStateObserver's observedScopeMapsLock;
- the AWT event thread sends the global snapshot notifications and holds
  observedScopeMapsLock in drainChanges while a re-read derived state wakes
  the scene's snapshotFlow collector, whose dispatch wants runLock.

The application renders on the event thread, where both monitors are simply
re-entered. The tests now do the same: onEventThread builds, renders and
closes each scene there and rethrows what the block throws.

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
2026-10-26 11:21:43 +01:00
dependabot[bot] 834f4b131a build(deps-dev): bump brace-expansion from 1.1.15 to 1.1.21 in /website (#155)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.15 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.15...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:22:26 +01:00
dependabot[bot] 1868f21c06 build(deps-dev): bump liquidjs from 10.27.1 to 10.30.0 in /website (#154)
Bumps [liquidjs](https://github.com/harttle/liquidjs) from 10.27.1 to 10.30.0.
- [Release notes](https://github.com/harttle/liquidjs/releases)
- [Changelog](https://github.com/harttle/liquidjs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.30.0)

---
updated-dependencies:
- dependency-name: liquidjs
  dependency-version: 10.30.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:36 +01:00
dependabot[bot] 9d433f9ba4 build(deps): bump markdown-it from 14.3.0 to 14.3.3 in /website (#153)
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.0 to 14.3.3.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/14.3.3/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.0...14.3.3)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.3.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:19 +01:00
dependabot[bot] f4f046c04f build(deps): bump js-yaml in /website (#152)
Bumps  and [js-yaml](https://github.com/nodeca/js-yaml). These dependencies needed to be updated together.

Updates `js-yaml` from 3.15.0 to 3.15.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2)

Updates `js-yaml` from 4.3.0 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.0...3.15.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.15.2
  dependency-type: indirect
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 10:16:06 +01:00
Rsclub2_2 8fc3b9f424 fix: resolve the open CodeQL alerts (#156)
Strip HTML tags and XML comments until the text no longer changes, so that
nested input cannot reassemble a tag or comment after a single pass
(js/incomplete-multi-character-sanitization in website/.eleventy.js and
website/scripts/validate-version-info.js).

Limit the GITHUB_TOKEN of the PR compile check and the test version-XML
workflow to read access (actions/missing-workflow-permissions).

Claude-Session: https://claude.ai/code/session_01FJBfPYm6rCH4zD6T7bcC7Y
2026-10-26 10:13:10 +01:00
21 changed files with 504 additions and 255 deletions

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -3,6 +3,10 @@ name: TEST publish-version-xml (local only)
on:
workflow_dispatch:
permissions:
contents: read
issues: read
jobs:
publish-version-xml:
name: Publish Version Info XML via WebDAV
@@ -3032,6 +3032,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
/**
* Fills the three global Compose message tables from their filtered
* controller views.
*/
/* package */ static void refreshComposeMessageTables(
final ChatController controller,
final DataTableState<ChatMessage> directed,
final DataTableState<ChatMessage> publicMessages,
final DataTableState<ChatMessage> qsoOfTheOther) {
directed.replaceRows(controller.toMeMessages());
publicMessages.replaceRows(controller.toAllMessages());
qsoOfTheOther.replaceRows(controller.toOtherMessages());
}
/**
* Opens the Compose main window beside the JavaFX one.
*
@@ -3342,11 +3356,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
return kotlin.Unit.INSTANCE;
});
composeChatListener = rows -> uiDispatcher.runOnUi(() -> {
directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(rows);
qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
});
composeChatListener = rows -> uiDispatcher.runOnUi(() -> refreshComposeMessageTables(
chatcontroller,
directed,
publicMessages,
qsoOfTheOther));
chatcontroller.getLst_globalChatMessageList().addListener(composeChatListener);
composeClusterListener = rows -> uiDispatcher.runOnUi(() -> cluster.replaceRows(rows));
@@ -3361,10 +3375,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
stations.replaceRows(initialMembers);
newJoined.updateActiveMembers(initialMembers);
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
refreshComposeMessageTables(chatcontroller, directed, publicMessages, qsoOfTheOther);
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
/*
* The only push that does not check the feed for null, and deliberately so: the
* feed is assigned earlier in this same method, so a guard here could never fire
@@ -0,0 +1,60 @@
package kst4contest.view
import kst4contest.controller.ChatController
import kst4contest.model.ChatMessage
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.RowKeys
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
class ComposeMessageTableRefreshTest {
@Test
fun `refresh keeps public table on the filtered controller view`() {
val controller = mock(ChatController::class.java)
val privateToMe = message("private to me")
val publicInitial = message("public initial")
val betweenOthers = message("between others")
val directed = table()
val publicMessages = table()
val qsoOfTheOther = table()
`when`(controller.toMeMessages()).thenReturn(listOf(privateToMe))
`when`(controller.toAllMessages()).thenReturn(listOf(publicInitial))
`when`(controller.toOtherMessages()).thenReturn(listOf(betweenOthers))
Kst4ContestApplication.refreshComposeMessageTables(
controller,
directed,
publicMessages,
qsoOfTheOther,
)
assertEquals(listOf(privateToMe), directed.rows)
assertEquals(listOf(publicInitial), publicMessages.rows)
assertEquals(listOf(betweenOthers), qsoOfTheOther.rows)
val publicLater = message("public later")
`when`(controller.toAllMessages()).thenReturn(listOf(publicLater, publicInitial))
Kst4ContestApplication.refreshComposeMessageTables(
controller,
directed,
publicMessages,
qsoOfTheOther,
)
assertEquals(listOf(publicLater, publicInitial), publicMessages.rows)
}
private fun table() = DataTableState(
columns = emptyList(),
rowKey = RowKeys.byReference<ChatMessage>(),
)
private fun message(text: String) = ChatMessage().apply {
messageText = text
}
}
@@ -55,45 +55,47 @@ class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Pat
}
val filters = StationFilterState()
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(Modifier.padding(4.dp)) {
TimelineView(
state = TimelineState(),
onCandidateClicked = {},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
StationFilterBar(
filters = filters,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
reachability = ReachabilityState(
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
),
onChanged = {},
)
Spacer(Modifier.height(12.dp))
ChatInputRow(
state = chatInputState,
prefs = preferences,
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
antennaQtf = ObservedValue(preferences.getActualQTF()),
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
so2rEnabled = false,
)
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(Modifier.padding(4.dp)) {
TimelineView(
state = TimelineState(),
onCandidateClicked = {},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
StationFilterBar(
filters = filters,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
reachability = ReachabilityState(
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
),
onChanged = {},
)
Spacer(Modifier.height(12.dp))
ChatInputRow(
state = chatInputState,
prefs = preferences,
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
antennaQtf = ObservedValue(preferences.getActualQTF()),
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
so2rEnabled = false,
)
}
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
}
@@ -0,0 +1,37 @@
package kst4contest.view.compose
import java.awt.EventQueue
import java.lang.reflect.InvocationTargetException
/*
* An ImageComposeScene must live on the AWT event thread, the thread the application renders
* on. Compose Multiplatform 1.8.2 sends the global snapshot notifications there
* (GlobalSnapshotManager), and doing so takes two locks in the opposite order from a render
* on any other thread:
*
* - the render holds the scene's FlushCoroutineDispatcher runLock while a task it runs
* (here LazyListState.scrollToItem) measures and wants the SnapshotStateObserver's
* observedScopeMapsLock;
* - the event thread holds observedScopeMapsLock in drainChanges while a derived state it
* re-reads wakes a snapshotFlow collector of that scene, whose dispatch wants runLock.
*
* Both waited forever in CI runs 40252077018, 40252089738 and 40299943329; the thread dump of
* the last one shows both stacks. On one thread the two monitors are simply re-entered, which
* is why the application never hangs this way.
*/
/**
* Runs [block] on the AWT event thread, waits for it and returns its result. Whatever
* [block] throws is rethrown here unwrapped, so a failed assertion still reads as one.
*/
internal fun <T> onEventThread(block: () -> T): T {
if (EventQueue.isDispatchThread()) return block()
var result: Result<T>? = null
try {
EventQueue.invokeAndWait { result = runCatching(block) }
} catch (wrapped: InvocationTargetException) {
throw wrapped.cause ?: wrapped
}
return result!!.getOrThrow()
}
@@ -84,37 +84,39 @@ class SelectedStationMessageUiVisualRenderingTest {
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
}
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Row(Modifier.fillMaxSize().padding(6.dp)) {
MessageTabs(
publicMessages = publicMessages,
clusterMessages = clusterTable(),
qsoOfTheOther = qsoMessages,
modifier = Modifier.weight(1f),
)
SelectedStationPanel(
state = selectedState,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.width(panelWidth).padding(start = 6.dp),
)
return onEventThread {
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Row(Modifier.fillMaxSize().padding(6.dp)) {
MessageTabs(
publicMessages = publicMessages,
clusterMessages = clusterTable(),
qsoOfTheOther = qsoMessages,
modifier = Modifier.weight(1f),
)
SelectedStationPanel(
state = selectedState,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.width(panelWidth).padding(start = 6.dp),
)
}
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
}
@@ -87,62 +87,64 @@ class StationWorkspaceVisualRenderingTest {
RowKeys.byReference<ChatMessage>(),
)
val scene = ImageComposeScene(
width = 1366,
height = scenario.height,
density = Density(scale),
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.width(scenario.width.dp)
.fillMaxHeight()
.align(Alignment.TopEnd)
.padding(4.dp),
return onEventThread {
val scene = ImageComposeScene(
width = 1366,
height = scenario.height,
density = Density(scale),
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
StationFilterBar(
filters = StationFilterState(),
activeBands = EnumSet.of(Band.B_144, Band.B_432),
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
onChanged = {},
)
DataTableView(
state = stations,
modifier = Modifier.weight(0.45f),
emphasizeCell = { member, column ->
column.id == "callsign" && newJoined.isRecent(member)
},
)
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
SelectedStationPanel(
state = selected,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432),
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.weight(0.55f),
)
}
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.width(scenario.width.dp)
.fillMaxHeight()
.align(Alignment.TopEnd)
.padding(4.dp),
) {
StationFilterBar(
filters = StationFilterState(),
activeBands = EnumSet.of(Band.B_144, Band.B_432),
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
onChanged = {},
)
DataTableView(
state = stations,
modifier = Modifier.weight(0.45f),
emphasizeCell = { member, column ->
column.id == "callsign" && newJoined.isRecent(member)
},
)
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
SelectedStationPanel(
state = selected,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432),
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.weight(0.55f),
)
}
}
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
}
@@ -32,17 +32,19 @@ class TopPriorityCandidatesWindowTest {
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
}
return picked
}
@@ -78,13 +80,15 @@ class TopPriorityCandidatesWindowTest {
@Test
fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking ->
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
}
try {
scene.render()
} finally {
scene.close()
onEventThread {
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
}
try {
scene.render()
} finally {
scene.close()
}
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapViewport
import kst4contest.view.map.TileFetcher
@@ -71,27 +72,29 @@ class ComposeStationMapDrawingTest {
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
): BufferedImage {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap(
markers = markers,
tileFetcher = offlineTileFetcher,
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = false,
selectedMarkerCallsign = null,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = 90.0,
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
onMarkerClicked = null,
workedGrossFields = workedGrossFields,
darkMode = darkMode,
modifier = Modifier.fillMaxSize(),
)
}
try {
return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
} finally {
scene.close()
return onEventThread {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap(
markers = markers,
tileFetcher = offlineTileFetcher,
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = false,
selectedMarkerCallsign = null,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = 90.0,
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
onMarkerClicked = null,
workedGrossFields = workedGrossFields,
darkMode = darkMode,
modifier = Modifier.fillMaxSize(),
)
}
try {
ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
} finally {
scene.close()
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Test
@@ -33,30 +34,32 @@ class ComposeStationMapLayoutTest {
)
private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
ComposeStationMap(
markers = stations,
tileFetcher = TileFetcher(),
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = true,
selectedMarkerCallsign = "F5JMI",
ownLocator6 = "JO50JP",
antennaAzimuthDeg = 229.0,
antennaBeamWidthDeg = 60.0,
maxQrbKm = 800.0,
onMarkerClicked = null,
darkMode = true,
modifier = Modifier.fillMaxSize(),
)
}
try {
scene.render()
} finally {
scene.close()
onEventThread {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
ComposeStationMap(
markers = stations,
tileFetcher = TileFetcher(),
mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
groupingEnabled = true,
selectedMarkerCallsign = "F5JMI",
ownLocator6 = "JO50JP",
antennaAzimuthDeg = 229.0,
antennaBeamWidthDeg = 60.0,
maxQrbKm = 800.0,
onMarkerClicked = null,
darkMode = true,
modifier = Modifier.fillMaxSize(),
)
}
try {
scene.render()
} finally {
scene.close()
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Test
@@ -25,21 +26,23 @@ class PathAnalysisDetailsLayoutTest {
private val narrowWidthPx = 120
private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
val scene = ImageComposeScene(
width = widthPx,
height = 600,
density = Density(1f),
) {
PathAnalysisDetails(
result = result,
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
scene.render()
} finally {
scene.close()
onEventThread {
val scene = ImageComposeScene(
width = widthPx,
height = 600,
density = Density(1f),
) {
PathAnalysisDetails(
result = result,
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
scene.render()
} finally {
scene.close()
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -31,18 +32,20 @@ class PathAnalysisDetailsWidthTest {
private val assessmentBackground = 0x33383E
private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
val png = scene.render().encodeToData()!!.bytes
return ImageIO.read(ByteArrayInputStream(png))
} finally {
scene.close()
return onEventThread {
val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
PathAnalysisDetails(
result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
darkMode = true,
modifier = Modifier.fillMaxWidth(),
)
}
try {
val png = scene.render().encodeToData()!!.bytes
ImageIO.read(ByteArrayInputStream(png))
} finally {
scene.close()
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.compose.onEventThread
import org.junit.jupiter.api.Test
/**
@@ -18,17 +19,19 @@ import org.junit.jupiter.api.Test
class PathProfileChartLayoutTest {
private fun renderAt(widthPx: Int, heightPx: Int) {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true)
}
try {
scene.render()
} finally {
scene.close()
onEventThread {
val scene = ImageComposeScene(
width = widthPx,
height = heightPx,
density = Density(1f),
) {
PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true)
}
try {
scene.render()
} finally {
scene.close()
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp
import kst4contest.view.compose.SplitterOrientation
import kst4contest.view.compose.SplitterPane
import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.onEventThread
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
@@ -88,13 +89,15 @@ class StationMapBodyLayoutTest {
}
private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
Body(pathAnalysisVisible)
}
try {
scene.render()
} finally {
scene.close()
onEventThread {
val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
Body(pathAnalysisVisible)
}
try {
scene.render()
} finally {
scene.close()
}
}
}
+7
View File
@@ -151,6 +151,13 @@ subprojects {
*/
systemProperty("junit.jupiter.execution.timeout.default", "2m")
systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD")
/*
* The timed-out thread's stack shows where a test waits, not who it waits for: the
* rare CI hang in SelectedStationMessageUiVisualRenderingTest blocks on a monitor
* that another thread holds. On a timeout JUnit prints all threads to the test's
* standard output, which lands in its XML report (the test-results artifact).
*/
systemProperty("junit.jupiter.execution.timeout.threaddump.enabled", "true")
// The default short format prints only the first frame of each cause, which for a
// timed-out test is the line it hung in but not the call that blocked there.
testLogging {
@@ -0,0 +1,68 @@
# Public Message Tab Filter Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the main-window **Public messages** tab show only ON4KST messages whose protocol receiver is public (`0`, represented as `ALL`).
**Architecture:** Keep the global message roster as the complete event store and reuse `ChatController.toAllMessages()` as the single existing public-message view. A small package-visible refresh method updates all three Compose message tables so initial population and live updates use the same filtered sources.
**Tech Stack:** Java 21, Kotlin 2.2, Compose Multiplatform, JUnit 5, Mockito, Gradle
**Spec:** `github_docs/en-User-Interface.md` and `github_docs/de-Benutzeroberflaeche.md` document the intended global-tab semantics.
## Global Constraints
- Preserve ON4KST parsing, the complete global message history, message order and chat-category identity.
- Preserve the selected-station message modes and the existing `pm to me`/`pm to other` semantics.
- Do not stage, commit, push or publish without separate authorization.
- Source comments remain English.
## Review Focus
- A genuine public frame remains in the public tab after initial population and a later refresh.
- A PM to the local callsign never enters the public tab.
- A directed message between other stations never enters the public tab.
- Directed and QSO-of-the-other tables continue to receive their existing filtered views.
- Repeated table refreshes replace rows without changing their newest-first order.
---
### Task 1: Route the Compose public table through the existing filtered view
**Files:**
- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Create: `app-desktop/src/test/kotlin/kst4contest/view/ComposeMessageTableRefreshTest.kt`
**Interfaces:**
- Consumes: `ChatController.toMeMessages()`, `ChatController.toAllMessages()`, and `ChatController.toOtherMessages()`
- Produces: package-visible `Kst4ContestApplication.refreshComposeMessageTables(...)`
- [x] **Step 1: Write the failing regression test**
Create a mixed-message test whose mocked controller exposes separate directed, public and other-station views. Assert that the public table receives only `toAllMessages()`, then change the mocked public result, refresh again and assert the replacement remains filtered.
- [x] **Step 2: Run the focused test and verify it fails**
Run: `./gradlew.bat :app-desktop:test --tests kst4contest.view.ComposeMessageTableRefreshTest --console=plain`
Expected: compilation failure because `refreshComposeMessageTables(...)` does not yet exist.
- [x] **Step 3: Implement the shared refresh method**
Add package-visible static `refreshComposeMessageTables(ChatController, DataTableState<ChatMessage>, DataTableState<ChatMessage>, DataTableState<ChatMessage>)`. It must populate the tables from `toMeMessages()`, `toAllMessages()` and `toOtherMessages()` respectively.
- [x] **Step 4: Use the method for both update paths**
Call the shared method from the global-message listener on the UI dispatcher and from the initial Compose table population. Remove both raw writes to the public table.
- [x] **Step 5: Run focused and related tests**
Run the new test plus `MessageTabsScreenTest`, `MessageBusManagementThreadChatFrameTest`, `SelectedStationMessageFlowTest`, and `SelectedStationMessagePredicateTest`.
- [x] **Step 6: Run full verification**
Run `./gradlew.bat build --console=plain`, inspect test-result XML and changed-file static-analysis reports, then run `git diff --check` and `git status --short --branch`.
- [x] **Step 7: Assess documentation impact**
Confirm the German and English manuals already state that the global public tab contains public messages only. Update no documentation unless implementation reveals a conflicting invariant.
+18 -3
View File
@@ -32,11 +32,26 @@ const MANUAL_PAGE_ORDER = {
]
};
// Removes HTML tags until none are left, so that nested input such as
// "<<b>script>" cannot reassemble a tag after a single pass.
function stripHtmlTags(value) {
let previous;
let current = value;
do {
previous = current;
current = current.replace(/<[^>]+>/g, "");
} while (current !== previous);
return current;
}
function markdownToPlainText(value) {
return (value || "")
const withoutLinks = (value || "")
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
return stripHtmlTags(withoutLinks)
.replace(/[*_~`]/g, "")
.replace(/\s+/g, " ")
.trim();
+19 -19
View File
@@ -7,15 +7,15 @@
"": {
"name": "kst4contest-website",
"version": "0.1.0",
"engines": {
"node": ">=18.19.1"
},
"dependencies": {
"markdown-it": "^14.3.0",
"markdown-it": "^14.3.3",
"markdown-it-anchor": "^9.2.0"
},
"devDependencies": {
"@11ty/eleventy": "^3.0.0"
},
"engines": {
"node": ">=18.19.1"
}
},
"node_modules/@11ty/dependency-tree": {
@@ -397,9 +397,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -831,9 +831,9 @@
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1026,9 +1026,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -1098,9 +1098,9 @@
}
},
"node_modules/liquidjs": {
"version": "10.27.1",
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.1.tgz",
"integrity": "sha512-ylE+1q2kSef1UxAyxqbyuWM3FRWS1v48JK1Y3CoW3bD6TSNXZh0+GsVnihujEpKyR+Jejx2aRAFfC3AHm9rElg==",
"version": "10.30.0",
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.0.tgz",
"integrity": "sha512-Fw4wA+8CZsJkaeLEIlPE3AoponfjEZhj7FQvYNcAEcg7jNRik7mEMoaco382FBdrYlykVf6w4vClPNctz2OYjg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1136,9 +1136,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"version": "14.3.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
"funding": [
{
"type": "github",
+1 -1
View File
@@ -15,7 +15,7 @@
"@11ty/eleventy": "^3.0.0"
},
"dependencies": {
"markdown-it": "^14.3.0",
"markdown-it": "^14.3.3",
"markdown-it-anchor": "^9.2.0"
}
}
+18 -3
View File
@@ -28,14 +28,29 @@ function extractTag(xml, tagName) {
return match ? match[1].trim() : null;
}
// Applies the removal until nothing changes, so that nested input such as
// "<!-<!---->-->" cannot leave a fresh comment behind after a single pass.
function removeUntilStable(value, pattern) {
let previous;
let current = value;
do {
previous = current;
current = current.replace(pattern, "");
} while (current !== previous);
return current;
}
function assertWellFormedStructure(xml) {
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
fail("the XML contains an unescaped ampersand");
}
const withoutCommentsAndDeclaration = xml
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<\?[\s\S]*?\?>/g, "");
const withoutCommentsAndDeclaration = removeUntilStable(
xml,
/<!--[\s\S]*?-->|<\?[\s\S]*?\?>/g
);
const tagPattern =
/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;