mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-08 22:38:36 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643ac3e693 | ||
|
|
c23982c339 |
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
+38
-36
@@ -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()
|
||||
}
|
||||
+29
-27
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-51
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-17
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-21
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-24
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-15
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-12
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user