fix: apply beacon switches at runtime

This commit is contained in:
Marc Froehlich committed 2026-11-04 01:09:26 +01:00
1 parent 4d96822c0c
commit b8bb86977f
30 files changed
+903 -133

No files matched your search

@@ -3278,6 +3278,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}),
newJoined,
reachability);
composeMainWindowState.getThreadButtons().registerAction(
ChatController.BEACON_STATUS_SOURCE,
() -> {
chatcontroller.toggleUsableBeacons();
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.getThreadButtons().registerStatusProvider(
ChatController.BEACON_STATUS_SOURCE,
chatcontroller::currentBeaconStatus);
chatcontroller.refreshBeaconStatus();
/*
* The feed, not the window construction, owns the timeline's data from here.
@@ -1,7 +1,11 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kst4contest.observe.ObservableValue
import java.util.function.Consumer
@@ -35,3 +39,19 @@ class ObservedValue<T>(private val source: ObservableValue<T>) {
source.removeListener(follower)
}
}
/** Observes a domain value only for the lifetime of the current composition. */
@Composable
internal fun <T> ObservableValue<T>.observeAsState(): State<T?> {
val state = remember(this) { mutableStateOf(get()) }
DisposableEffect(this) {
val follower = Consumer<T> { updated -> state.value = updated }
addListener(follower)
// Close the gap between the initial read and listener registration.
state.value = get()
onDispose { removeListener(follower) }
}
return state
}
@@ -148,6 +148,7 @@ fun buildSettingsTabs(
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
refreshBeaconStatus = controller::refreshBeaconStatus,
)
val notificationState = NotificationTabState(
@@ -185,6 +186,8 @@ fun buildSettingsTabs(
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
setMainBeaconEnabled = controller::setMainBeaconEnabled,
setSecondBeaconEnabled = controller::setSecondBeaconEnabled,
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback),
)
@@ -7,6 +7,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -25,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -39,11 +41,9 @@ import kst4contest.controller.On4KstConnectionState
* only the indicators are left here — which is also what finally makes the bar wrap
* properly on a narrow window instead of pushing the badges out of sight.
*
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
* status badge must never take the keyboard. The background-thread buttons were ordinary
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
* which is what they always behaved like.
* The connection and notice indicators remain non-clickable. A background-thread status is
* actionable only when the application explicitly registers an action for that source; the
* beacon uses this for its quick switch, while every other worker remains a plain label.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -206,10 +206,16 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
val actionModifier = if (button.hasAction) {
Modifier.clickable(role = Role.Button) { button.activate() }
} else {
Modifier
}
Box(
modifier = Modifier
.defaultMinSize(minHeight = 22.dp)
.background(background, NOTICE_SHAPE)
.then(actionModifier)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
@@ -176,20 +176,34 @@ object StatusIndicators {
class ThreadStatusButtons {
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
private val actions = LinkedHashMap<String, () -> Unit>()
private val statusProviders = LinkedHashMap<String, () -> ThreadStateMessage>()
private val ordered = mutableStateListOf<ThreadStatusButton>()
val buttons: List<ThreadStatusButton> get() = ordered
fun update(sourceName: String, message: ThreadStateMessage) {
val effectiveMessage = statusProviders[sourceName]?.invoke() ?: message
val button = bySource.getOrPut(sourceName) {
ThreadStatusButton(sourceName).also { ordered.add(it) }
ThreadStatusButton(sourceName, actions[sourceName]).also { ordered.add(it) }
}
button.report(
label = "$sourceName: ${message.runningInformationTextDescription}",
tooltip = message.runningInformation.orEmpty(),
label = "$sourceName: ${effectiveMessage.runningInformationTextDescription}",
tooltip = effectiveMessage.runningInformation.orEmpty(),
)
}
/** Gives one status source an explicit action without changing any other source. */
fun registerAction(sourceName: String, action: () -> Unit) {
actions[sourceName] = action
bySource[sourceName]?.setAction(action)
}
/** Resolves a status only when its queued UI update is actually applied. */
fun registerStatusProvider(sourceName: String, provider: () -> ThreadStateMessage) {
statusProviders[sourceName] = provider
}
companion object {
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
const val HIGHLIGHT_MILLIS = 200L
@@ -197,7 +211,10 @@ class ThreadStatusButtons {
}
/** One background thread's button. */
class ThreadStatusButton(val sourceName: String) {
class ThreadStatusButton(
val sourceName: String,
initialAction: (() -> Unit)? = null,
) {
var label: String by mutableStateOf(sourceName)
private set
@@ -212,6 +229,12 @@ class ThreadStatusButton(val sourceName: String) {
var revision: Int by mutableStateOf(0)
private set
var action: (() -> Unit)? by mutableStateOf(initialAction)
private set
val hasAction: Boolean
get() = action != null
internal fun report(label: String, tooltip: String) {
this.label = label
this.tooltip = tooltip
@@ -222,4 +245,15 @@ class ThreadStatusButton(val sourceName: String) {
fun clearHighlight() {
highlighted = false
}
internal fun setAction(action: () -> Unit) {
this.action = action
}
/** Runs the registered action and reports whether this status is actionable. */
fun activate(): Boolean {
val current = action ?: return false
current()
return true
}
}
@@ -4,9 +4,11 @@ import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
import kst4contest.view.compose.observeAsState
/**
* The beacon tab: one CQ template per chat category and the interval they share.
@@ -18,12 +20,14 @@ import kst4contest.view.compose.Form
@Composable
fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
val strings = LocalStrings.current
val mainEnabled by state.mainBeaconEnabled.observeAsState()
val secondEnabled by state.secondBeaconEnabled.observeAsState()
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section(strings.beaconSectionFor(state.mainCategoryName)) {
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledMainCat) {
Form.check(strings.beaconEnabled, mainEnabled == true) {
state.bcn_beaconsEnabledMainCat = it
}
Form.committed(
@@ -36,7 +40,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
}
Form.section(strings.beaconSectionFor(state.secondCategoryName)) {
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledSecondCat) {
Form.check(strings.beaconEnabled, secondEnabled == true) {
state.bcn_beaconsEnabledSecondCat = it
}
Form.committed(
@@ -12,8 +12,9 @@ import kst4contest.view.compose.setting
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
* read it. Accepted templates and intervals are staged in the window's [SettingsDraft]
* and take effect when the operator applies them. The two enable switches deliberately
* bypass the draft so the running timer sees their values immediately.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
@@ -27,15 +28,20 @@ class BeaconTabState(
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
private val setMainBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledMainCat,
private val setSecondBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledSecondCat,
) {
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledMainCat() },
) { prefs.setBcn_beaconsEnabledMainCat(it) }
val mainBeaconEnabled = prefs.bcnBeaconsEnabledMainCatProperty()
val secondBeaconEnabled = prefs.bcnBeaconsEnabledSecondCatProperty()
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledSecondCat() },
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) = setMainBeaconEnabled(value)
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) = setSecondBeaconEnabled(value)
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
@@ -69,7 +75,7 @@ class BeaconTabState(
*
* The clamp is not cosmetic: a stored zero would otherwise be displayed as a
* valid setting while `ChatController` sends at the minimum anyway
* (ChatController:3425). The JavaFX field applied the same `Math.max` when it
* when scheduling the timer. The JavaFX field applied the same `Math.max` when it
* restored the value after a rejected entry.
*/
val beaconIntervalMinutes: Int
@@ -44,6 +44,8 @@ class StationTabState(
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
*/
private val applySecondRotator: () -> Unit = {},
/** Recomputes status whose available-count depends on the second-chat switch. */
private val refreshBeaconStatus: () -> Unit = {},
) {
var loginChatCategoryMain: ChatCategory?
@@ -234,7 +236,10 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
var loginToSecondChatEnabled: Boolean by draft.setting(
{ prefs.isLoginToSecondChatEnabled() },
effect = { refreshBeaconStatus() },
) { prefs.setLoginToSecondChatEnabled(it) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
@@ -35,7 +35,9 @@ class SettingsFieldCoverageTest {
""
}
val missing = expected.filterNot { composeSources.contains("$it(") }
val missing = expected.filterNot {
composeSources.contains("$it(") || composeSources.contains("::$it")
}
assertTrue(missing.isEmpty(),
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
@@ -0,0 +1,48 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ThreadStateMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class StatusBarScreenTest {
@Test
fun onlyAThreadWithARegisteredActionIsClickable() = runComposeUiTest {
val buttons = ThreadStatusButtons()
var beaconClicks = 0
buttons.registerAction("MyBeacon") { beaconClicks++ }
buttons.update("MyBeacon", status("off"))
buttons.update("Cluster", status("on"))
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StatusBar(
connectionState = null,
connectionDetail = null,
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = buttons,
showConnectionBadge = false,
)
}
}
waitForIdle()
onNodeWithText("MyBeacon: off").assertHasClickAction().performClick()
waitForIdle()
assertEquals(1, beaconClicks)
onNodeWithText("Cluster: on").assertHasNoClickAction()
}
private fun status(description: String): ThreadStateMessage =
ThreadStateMessage("worker", true, description, false).apply {
runningInformationTextDescription = description
}
}
@@ -407,6 +407,52 @@ class StatusIndicatorStateTest {
assertEquals("Cluster: b", button.label)
}
@Test
fun `an action registered before the first report reaches the created button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.registerAction("MyBeacon") { clicks++ }
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `an action registered after a report reaches the existing button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.registerAction("MyBeacon") { clicks++ }
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `a thread without an action cannot be activated`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "cluster", description = "on", info = "running"))
assertFalse(buttons.buttons.single().activate())
}
@Test
fun `a status provider replaces a stale worker snapshot when the UI applies it`() {
val buttons = ThreadStatusButtons()
var current = threadState(nick = "beacon", description = "on", info = "enabled")
buttons.registerStatusProvider("MyBeacon") { current }
val queuedSnapshot = current
current = threadState(nick = "beacon", description = "off", info = "disabled")
buttons.update("MyBeacon", queuedSnapshot)
assertEquals("MyBeacon: off", buttons.buttons.single().label)
assertEquals("disabled", buttons.buttons.single().tooltip)
}
private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
ThreadStateMessage(nick, true, info, false).apply {
setRunningInformationTextDescription(description)
@@ -41,7 +41,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `beacon edits wait for apply, and the timer restarts only then`() {
fun `enable changes immediately while template and interval wait for apply`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
@@ -51,7 +51,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat(), "the runtime switch did not take effect immediately")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
@@ -65,6 +65,18 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(1, s.timerRestarts)
}
@Test
fun `enable changes do not become pending draft edits`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
@@ -162,8 +162,6 @@ class DraftedSettingsTableTest {
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
@@ -3,6 +3,7 @@ package kst4contest.view.compose.tabs
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
@@ -190,6 +191,19 @@ class MessagingTabsScreenTest {
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
}
@Test
fun anExternalBeaconToggleUpdatesTheOpenSettingsTab() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.isBcn_beaconsEnabledMainCat = false
showTab { BeaconTab(s.state) { s.refusals += it } }
checkBeside("Send the beacon in this category").assertIsOff()
runOnIdle { s.prefs.isBcn_beaconsEnabledMainCat = true }
waitForIdle()
checkBeside("Send the beacon in this category").assertIsOn()
}
/*
* The tab reads the interval from a plain preference getter, so nothing recomposes it
* after Enter commits. Compared with that stale value, leaving the field afterwards
@@ -107,14 +107,14 @@ class SettingsApplyScreenTest {
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
fun beaconEnableTakesEffectImmediatelyWhileTheIntervalWaitsForApply() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the runtime switch did not take effect immediately")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
@@ -178,19 +178,19 @@ class SettingsApplyScreenTest {
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
/*
@@ -264,7 +264,7 @@ class SettingsApplyScreenTest {
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
@@ -273,7 +273,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(w.closed)
}
@@ -288,7 +288,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the immediate runtime switch was unexpectedly reverted")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
@@ -299,7 +299,7 @@ class SettingsApplyScreenTest {
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -307,7 +307,7 @@ class SettingsApplyScreenTest {
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
@@ -377,13 +377,13 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@@ -393,7 +393,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -408,14 +408,14 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
@Test
@@ -423,7 +423,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -432,7 +432,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "applied despite the refusal")
}
@Test
@@ -457,7 +457,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -477,7 +477,7 @@ class SettingsApplyScreenTest {
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
runOnIdle { w.x() }
@@ -489,7 +489,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@@ -20,6 +20,7 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val secondRotatorCalls = mutableListOf<Unit>()
var beaconStatusRefreshes = 0
val draft = SettingsDraft()
val state = StationTabState(
prefs,
@@ -27,6 +28,7 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
applySecondRotator = { secondRotatorCalls.add(Unit) },
refreshBeaconStatus = { beaconStatusRefreshes++ },
)
}
@@ -305,6 +307,18 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
assertEquals(true, state.loginToSecondChatEnabled)
}
@Test
fun `applying the second chat switch refreshes aggregate beacon status`() {
val session = Session()
session.state.loginToSecondChatEnabled = true
assertEquals(0, session.beaconStatusRefreshes)
session.draft.apply()
assertEquals(1, session.beaconStatusRefreshes)
}
@Test
fun `the own height above sea level reads empty while the elevation model decides`() {
val s = Session()
@@ -0,0 +1,75 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
/** Keeps category failures and builds the single status shown for both beacons. */
final class BeaconStatus {
static final String SOURCE_NAME = ChatController.BEACON_STATUS_SOURCE;
private boolean mainInvalid;
private boolean secondInvalid;
private String activity = "Beacon configuration loaded.";
/** Records the result of one complete timer run. */
synchronized void recordRun(
boolean mainInvalid,
boolean secondInvalid,
String activity
) {
this.mainInvalid = mainInvalid;
this.secondInvalid = secondInvalid;
this.activity = activity;
}
/** Updates the explanation without hiding a still-relevant category failure. */
synchronized void recordActivity(String activity) {
this.activity = activity;
}
/** Builds a fresh aggregate from the current preferences and remembered failures. */
synchronized ThreadStateMessage message(ChatPreferences preferences) {
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean mainEnabled = preferences.isBcn_beaconsEnabledMainCat();
boolean secondEnabled = secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat();
int available = secondAvailable ? 2 : 1;
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
boolean critical = (mainInvalid && mainEnabled)
|| (secondInvalid && secondEnabled);
String description;
if (critical) {
description = "FAILED";
} else if (enabled == 0) {
description = "off";
} else if (enabled == available) {
description = "on";
} else {
description = enabled + "/" + available;
}
String details = "Main category beacon: " + onOff(mainEnabled)
+ "; second category beacon: "
+ (secondAvailable
? onOff(secondEnabled)
: "unavailable");
String information = activity == null || activity.isBlank()
? details
: activity + "\n" + details;
ThreadStateMessage message = new ThreadStateMessage(
SOURCE_NAME,
enabled > 0,
information,
critical
);
message.setRunningInformationTextDescription(description);
return message;
}
private static String onOff(boolean enabled) {
return enabled ? "on" : "off";
}
}
@@ -4,7 +4,6 @@ import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
/**
* Sends the configured public-chat beacons for both active chat categories.
@@ -20,10 +19,9 @@ import kst4contest.model.ThreadStateMessage;
*/
public class BeaconTask extends TimerTask {
private static final String THREAD_NICKNAME = "MyBeacon";
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
private final BeaconStatus beaconStatus;
/**
* Creates one execution of the shared beacon timer.
@@ -37,30 +35,34 @@ public class BeaconTask extends TimerTask {
) {
this.chatController = chatController;
this.callbackToController = callbackToController;
this.beaconStatus = chatController.beaconStatusState();
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
Attempt main = sendMainCategoryBeacon();
Attempt second = sendSecondCategoryBeacon();
String activity = "Main category: " + main.description
+ "; second category: " + second.description;
beaconStatus.recordRun(
main == Attempt.INVALID,
second == Attempt.INVALID,
activity
);
callbackToController.onThreadStatus(
BeaconStatus.SOURCE_NAME,
beaconStatus.message(chatController.getChatPreferences())
);
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon() {
private Attempt sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -72,13 +74,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -88,31 +84,19 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
return Attempt.SENT;
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
private Attempt sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) {
return Attempt.UNAVAILABLE;
}
if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -124,13 +108,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -140,13 +118,7 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
return Attempt.SENT;
}
/**
@@ -199,24 +171,16 @@ public class BeaconTask extends TimerTask {
}
}
/**
* Forwards one state update to the existing thread-status display.
*/
private void reportStatus(
String threadName,
boolean running,
String information,
boolean criticalState
) {
ThreadStateMessage stateMessage = new ThreadStateMessage(
threadName,
running,
information,
criticalState
);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
private enum Attempt {
SENT("sent"),
OFF("off"),
UNAVAILABLE("unavailable"),
INVALID("invalid text");
private final String description;
Attempt(String description) {
this.description = description;
}
}
}
}
@@ -64,9 +64,11 @@ public class ChatController implements ThreadStatusCallback {
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private final BeaconStatus beaconStatus = new BeaconStatus();
private Consumer<Double> viewRotorCallback;
@@ -3381,6 +3383,64 @@ private SimpleRoster<String>
this.dbHandler = dbHandler;
}
/** Applies the main-category beacon switch to the running session immediately. */
public void setMainBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/** Applies the second-category beacon switch to the running session immediately. */
public void setSecondBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/**
* Toggles every beacon usable in the current chat configuration.
*
* <p>The dormant second-category preference is deliberately left unchanged when
* the second chat is disabled. Enabling that chat later therefore restores the
* operator's previous category-specific choice.</p>
*/
public void toggleUsableBeacons() {
ChatPreferences preferences = getChatPreferences();
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat()
|| (secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat());
boolean enable = !anyEnabled;
preferences.setBcn_beaconsEnabledMainCat(enable);
if (secondAvailable) {
preferences.setBcn_beaconsEnabledSecondCat(enable);
}
publishBeaconStatus("Beacon status toggled from the main window.");
}
/** Publishes the loaded state without changing either category switch. */
public void refreshBeaconStatus() {
publishBeaconStatus("Beacon configuration loaded.");
}
/** Returns the shared state used by the timer and manual controls. */
BeaconStatus beaconStatusState() {
return beaconStatus;
}
/** Builds the current aggregate when the UI is ready to display it. */
public ThreadStateMessage currentBeaconStatus() {
return beaconStatus.message(getChatPreferences());
}
/** Publishes the aggregate state used by the single beacon status chip. */
private void publishBeaconStatus(String activity) {
beaconStatus.recordActivity(activity);
onThreadStatus(
BeaconStatus.SOURCE_NAME,
currentBeaconStatus()
);
}
/**
* Validates a configured beacon template before it is stored.
@@ -366,8 +366,8 @@ public class ChatPreferences {
* Beacon prefs
*/
boolean bcn_beaconsEnabledMainCat = true;
boolean bcn_beaconsEnabledSecondCat = false;
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
int bcn_beaconIntervalInMinutesMainCat = 20;
int bcn_beaconIntervalInMinutesSecondCat = 20;
String bcn_beaconTextMainCat = "Hi, pse call us";
@@ -737,11 +737,15 @@ public class ChatPreferences {
}
public boolean isBcn_beaconsEnabledSecondCat() {
return bcn_beaconsEnabledSecondCat;
return Boolean.TRUE.equals(bcn_beaconsEnabledSecondCat.get());
}
public void setBcn_beaconsEnabledSecondCat(boolean bcn_beaconsEnabledSecondCat) {
this.bcn_beaconsEnabledSecondCat = bcn_beaconsEnabledSecondCat;
this.bcn_beaconsEnabledSecondCat.set(bcn_beaconsEnabledSecondCat);
}
public MutableValue<Boolean> bcnBeaconsEnabledSecondCatProperty() {
return bcn_beaconsEnabledSecondCat;
}
public int getBcn_beaconIntervalInMinutesSecondCat() {
@@ -1579,6 +1583,10 @@ public class ChatPreferences {
// }
public boolean isBcn_beaconsEnabledMainCat() {
return Boolean.TRUE.equals(bcn_beaconsEnabledMainCat.get());
}
public MutableValue<Boolean> bcnBeaconsEnabledMainCatProperty() {
return bcn_beaconsEnabledMainCat;
}
@@ -1601,7 +1609,7 @@ public class ChatPreferences {
}
public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) {
this.bcn_beaconsEnabledMainCat = bcn_beaconsEnabledMainCat;
this.bcn_beaconsEnabledMainCat.set(bcn_beaconsEnabledMainCat);
}
public int getBcn_beaconIntervalInMinutesMainCat() {
@@ -3170,11 +3178,11 @@ public class ChatPreferences {
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
if (beaconCQEl != null) {
bcn_beaconsEnabledMainCat = getBoolean(beaconCQEl, bcn_beaconsEnabledMainCat, "bcn_beaconsEnabledMainCat", "beaconCQEnabled");
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes");
bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText");
bcn_beaconsEnabledSecondCat = getBoolean(beaconCQEl, bcn_beaconsEnabledSecondCat, "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat");
setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"));
bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat");
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
@@ -0,0 +1,97 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconControlTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private ChatController controller;
private StatusUpdateListener listener;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
controller = new ChatController();
controller.setChatPreferences(preferences);
listener = mock(StatusUpdateListener.class);
controller.setStatusListener(listener);
}
@Test
void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() {
preferences.setLoginToSecondChatEnabled(false);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat(),
"the dormant second-category choice must not be overwritten");
assertLastDescription("on");
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("on");
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void anIndividualCheckboxPublishesThePartialStateImmediately() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.setMainBeaconEnabled(true);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("1/2");
}
private void assertLastDescription(String expected) {
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
org.mockito.Mockito.verify(listener, org.mockito.Mockito.atLeastOnce())
.onThreadStatusChanged(key.capture(), message.capture());
assertEquals("MyBeacon", key.getValue());
assertEquals(expected, message.getValue().getRunningInformationTextDescription());
}
}
@@ -0,0 +1,123 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconTaskTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private LinkedBlockingQueue<ChatMessage> txBus;
private List<ThreadStateMessage> statuses;
private ChatController controller;
private BeaconTask task;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setLoginChatCategoryMain(new ChatCategory(ChatCategory.VUHF));
preferences.setLoginChatCategorySecond(new ChatCategory(ChatCategory.MICROWAVE));
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconTextMainCat("CQ MAIN");
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
statuses = new ArrayList<>();
controller = new ChatController();
controller.setChatPreferences(preferences);
txBus = new LinkedBlockingQueue<>();
controller.setMessageTXBus(txBus);
controller.setStatusListener(new StatusUpdateListener() {
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage message) {
statuses.add(message);
}
@Override
public void onUserListUpdated() {
// Not used by beacon tests.
}
@Override
public void onUserListUpdated(String reason) {
// Not used by beacon tests.
}
});
task = new BeaconTask(controller, (key, message) -> statuses.add(message));
}
@Test
void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
assertTrue(txBus.isEmpty());
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isRunning());
assertTrue(status.isCriticalState());
}
@Test
void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
statuses.clear();
controller.setSecondBeaconEnabled(true);
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isCriticalState());
}
@Test
void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() {
task.run();
assertTrue(txBus.isEmpty());
assertAggregateStatus("off", false);
statuses.clear();
preferences.setBcn_beaconsEnabledMainCat(true);
task.run();
assertEquals(1, txBus.size());
assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber());
assertAggregateStatus("1/2", true);
statuses.clear();
preferences.setBcn_beaconsEnabledSecondCat(true);
task.run();
assertEquals(3, txBus.size());
assertAggregateStatus("on", true);
}
private void assertAggregateStatus(String description, boolean running) {
assertEquals(1, statuses.size(), "one timer run must leave one authoritative chip state");
ThreadStateMessage status = statuses.get(0);
assertEquals(description, status.getRunningInformationTextDescription());
assertEquals(running, status.isRunning());
assertFalse(status.isCriticalState());
}
}
@@ -0,0 +1,57 @@
package kst4contest.model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChatPreferencesBeaconStateTest {
@TempDir
Path tempDir;
@Test
void beaconActivationChangesAreObservableAndIndependent() {
ChatPreferences preferences = preferences("observable.xml");
List<Boolean> mainChanges = new ArrayList<>();
List<Boolean> secondChanges = new ArrayList<>();
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertEquals(List.of(false), mainChanges);
assertEquals(List.of(true), secondChanges);
}
@Test
void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() {
ChatPreferences preferences = preferences("persistence.xml");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writePreferencesToXmlFile());
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(false);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
ChatPreferences restored = preferences("persistence.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
}
private ChatPreferences preferences(String fileName) {
return new ChatPreferences(tempDir.resolve(fileName));
}
}
+2
View File
@@ -215,6 +215,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Automated-message loops must be prevented.
- Cooldown/minimum-interval rules must be preserved.
- A reply rejected before a complete valid TX item is queued must not consume cooldown.
- Both category beacons share one timer, while their enable flags and templates remain profile-scoped and independent. The enable flags are observable runtime preferences: Settings changes and the `MyBeacon` status-chip action update them immediately, and every timer run reads their current values without restarting the timer or causing an immediate transmission. Only the explicit full settings write persists those flags; layout autosave deliberately leaves the on-disk functional state untouched.
- `MyBeacon` owns one aggregate thread-status entry for both categories (`off`, a partial `enabled/available` count, `on`, or `FAILED`). Its action disables all currently usable beacons if any is enabled, otherwise it enables all currently usable beacons. A disabled second chat is not usable and its dormant enable flag must not be changed by the aggregate action. Category-specific timer failures remain authoritative while the affected beacon stays active; independent switch changes cannot hide them. The status bar resolves the aggregate from current controller state when a queued UI update is applied, so a delayed timer snapshot cannot overwrite a newer manual switch. Open Settings views observe the same preference values, so changes from the status chip remain synchronized without a parallel UI state.
- Current implementation/tests define the exact message markers and timer details.
## Build / Verification
@@ -0,0 +1,108 @@
# Live Beacon Control 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 both beacon enable switches effective immediately at runtime and make the aggregate beacon status chip an accurate shared toggle.
**Architecture:** Keep `ChatPreferences` as the profile-scoped source of truth, but expose the two activation flags as thread-safe observable values. Route settings and chip actions through `ChatController`, publish one aggregate `MyBeacon` status, and give only that thread-status chip an optional click action.
**Tech Stack:** Java 21, Kotlin 2.2, Compose Multiplatform, JUnit 5, Mockito, Gradle wrapper
**Spec:** `docs/superpowers/specs/2026-11-04-live-beacon-control-design.md`
## Global Constraints
- Work directly on `main`, as explicitly authorized.
- Do not stage, commit, push, merge, tag, release or deploy.
- Keep beacon templates, interval handling, TX framing and second-chat eligibility unchanged.
- Write source-code and configuration comments in English.
- Use focused tests first and finish with `./gradlew.bat build --console=plain`.
## Review Focus
- A disabled second chat must leave its dormant second-beacon flag unchanged when the chip is clicked.
- An external chip toggle must update an already open Beacon settings tab without reopening it.
- A runtime toggle must not restart the timer or enqueue an immediate message.
- An invalid beacon template must still be reported as a critical status rather than hidden by the aggregate state.
- Profile reload must replace the observable values without carrying the previous profile's runtime flags across.
---
### Task 1: Observable profile-scoped beacon activation
**Files:**
- Modify: `core/src/main/java/kst4contest/model/ChatPreferences.java`
- Test: `core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java`
**Interfaces:**
- Produces: existing boolean getters/setters plus observable main/second beacon value accessors
- Consumes: existing `MutableValue` / `SimpleValue` infrastructure
- [x] Write tests that changes are observable, values remain independent, XML reload restores both values, and an unsaved in-memory change does not survive a fresh load.
- [x] Run `./gradlew.bat :core:test --tests kst4contest.model.ChatPreferencesBeaconStateTest --console=plain` and verify the new observable-accessor tests fail because the API is absent.
- [x] Back the two flags with `SimpleValue<Boolean>` while preserving the existing getters, setters and XML element names.
- [x] Re-run the focused test and the existing ChatPreferences tests.
### Task 2: Immediate settings effect and aggregate controller semantics
**Files:**
- Modify: `core/src/main/java/kst4contest/controller/ChatController.java`
- Modify: `core/src/main/java/kst4contest/controller/BeaconTask.java`
- Create: `core/src/main/java/kst4contest/controller/BeaconStatus.java`
- Test: `core/src/test/java/kst4contest/controller/BeaconControlTest.java`
- Test: `core/src/test/java/kst4contest/controller/BeaconTaskTest.java`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsApplyScreenTest.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt`
**Interfaces:**
- Consumes: observable preference values from Task 1
- Produces: controller setters, usable-beacon toggle, aggregate status publication, Compose observation bridge
- [x] Write failing controller tests for `off`, `1/2`, `on`, main-only toggling when second chat is disabled, and both-category toggling when it is enabled.
- [x] Write failing BeaconTask tests proving a flag changed between two runs changes queued output without restarting a timer, and that the final status is aggregate.
- [x] Change Beacon settings tests so activation is immediate while templates and interval still wait for Apply; verify RED against the current draft-backed flags.
- [x] Implement the aggregate status helper and controller actions without changing the outbound-message path.
- [x] Make only the enable flags bypass `SettingsDraft`, observe their preference values in Compose, and wire their setters through the controller.
- [x] Run the core and app-desktop focused tests and verify GREEN.
### Task 3: Clickable beacon status chip
**Files:**
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt`
- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt`
- Create or modify: focused Compose status-bar rendering test under `app-desktop/src/test/kotlin/kst4contest/view/compose/`
**Interfaces:**
- Consumes: controller usable-beacon toggle from Task 2
- Produces: optional per-source chip action registered only for `MyBeacon`
- [x] Write failing state tests for actions registered before and after the first status report and for non-action thread chips.
- [x] Write a failing Compose test proving the beacon chip invokes its action while an ordinary status chip remains non-clickable.
- [x] Add optional actions to thread-status buttons and apply button semantics only when an action exists.
- [x] Register the controller toggle for `MyBeacon` during main-window state construction.
- [x] Run the focused status tests and the affected main-window tests.
### Task 4: Documentation and verification
**Files:**
- Modify: relevant beacon sections in `github_docs/de-Konfiguration.md`
- Modify: corresponding sections in `github_docs/en-Configuration.md`
- Modify: `docs/PROJECT_CONTEXT.md`
- Review: relevant screenshots and changelog impact only
**Interfaces:**
- Consumes: completed runtime and UI behavior from Tasks 1-3
- Produces: aligned user documentation and durable state/persistence invariant
- [x] Update both manuals with immediate runtime effect, explicit-save persistence, aggregate chip behavior and second-chat handling.
- [x] Record the observable canonical state, timer-read rule and persistence boundary in `docs/PROJECT_CONTEXT.md`.
- [x] Run all focused tests, then `./gradlew.bat build --console=plain` and read test-result XML summaries.
- [x] Run `git diff --check`, inspect PMD/SpotBugs XML for findings in changed production files, and review the final diff for unrelated changes.
- [x] Leave all changes unstaged and uncommitted and report remaining manual UI checks.
@@ -0,0 +1,46 @@
# Live Beacon Control Design
Freigegeben von Marc am 2026-11-04.
## Ziel
Die beiden Beacon-Aktivierungsschalter wirken in der laufenden Sitzung sofort. Der gemeinsame
Beacon-Timer bleibt bestehen und liest bei jedem Lauf die aktuellen Zustände. Der Beacon-Chip
im Hauptfenster zeigt den zusammengefassten Zustand und kann die aktuell nutzbaren Beacons
gemeinsam ein- oder ausschalten.
## Verbindliches Verhalten
- Die gespeicherten Beacon-Zustände werden beim Profilstart geladen.
- Ein Klick auf eine Beacon-Checkbox wirkt sofort im Speicher und damit beim nächsten
regulären Timerlauf.
- Aktivieren sendet nicht sofort und startet den Timer nicht neu.
- Erst **Einstellungen speichern** schreibt die Zustände in die Profil-XML. Ohne explizites
Speichern gilt die Änderung nur für die laufende Sitzung.
- Beacon-Text und Intervall bleiben normale Settings-Entwurfswerte. Das Intervall startet den
Timer erst beim Anwenden neu.
- Der Status-Chip zeigt `off`, `1/2` oder `on` für die aktuell nutzbaren Kategorien.
- Sind alle nutzbaren Beacons aus, aktiviert ein Chip-Klick alle nutzbaren Beacons. Ist
mindestens eine nutzbare Beacon an, deaktiviert der Klick alle nutzbaren Beacons.
- Bei deaktiviertem zweiten Chat schaltet der Chip nur die Hauptkategorie und verändert das
gespeicherte Flag der zweiten Kategorie nicht.
- Chip und geöffnetes Settings-Fenster zeigen denselben beobachtbaren Zustand.
- Nur der Beacon-Chip wird anklickbar. Andere Thread-Status-Chips bleiben reine Anzeigen.
## Unveränderte Grenzen
- Beide Kategorien behalten eigene Aktivierungsflags und Texte.
- Der zweite Beacon-Versand setzt weiterhin einen aktivierten zweiten Chat voraus.
- Nachrichtenvalidierung, TX-Warteschlange und ON4KST-Protokoll bleiben unverändert.
- Mindestintervall, gemeinsamer Timer und Intervall-Persistenz bleiben unverändert.
- Profilzustände werden nicht miteinander vermischt.
## Technischer Ansatz
Die vorhandenen `ChatPreferences`-Getter und -Setter bleiben die kompatible Schnittstelle,
werden für die beiden Aktivierungsflags aber auf thread-sichere beobachtbare Werte gestellt.
Der Settings-Tab liest diese Werte als Compose-State und schreibt Aktivierungen direkt durch
den Controller. Der Controller veröffentlicht nach jeder manuellen Umschaltung einen
zusammengefassten `MyBeacon`-Status. `BeaconTask` meldet nach einem Timerlauf ebenfalls genau
diesen zusammengefassten Status. `ThreadStatusButtons` erhält eine optionale Aktion pro
Quelle; die Anwendung registriert sie nur für `MyBeacon`.
+1 -1
View File
@@ -560,7 +560,7 @@ Bei einer ungünstigen Aufteilung sollten zuerst die Divider und Spaltenbreiten
## Tipps zur Bedienung
- **Einstellungsfenster geöffnet lassen**: Schneller Zugriff auf Beacon-Aktivierung/Deaktivierung.
- **Beacon schnell schalten**: Die Aktivierung ist im geöffneten Einstellungsfenster je Kategorie möglich. Ein Klick auf den Status-Chip **MyBeacon** schaltet alle derzeit nutzbaren Beacons gemeinsam ein oder aus.
- **Rechtsklick in der Benutzerliste**: Öffnet das Snippet-Menü und weitere Aktionen (QRZ.com-Profil, NOT-QRV-Tags setzen).
- **Enter aus dem Chat heraus**: Wenn im Sendfeld Text steht, sendet Enter direkt – auch wenn der Fokus woanders liegt.
- **Beacon stoppen**: Beim Scannen von Frequenzen den Beacon ausschalten, damit der Chat nicht mit Meldungen überflutet wird.
+5 -1
View File
@@ -4,7 +4,7 @@
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken sofort.
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache, die Anmeldedaten im Reiter **Station** und die beiden Beacon-Aktivierungsschalter wirken sofort.
>
> Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
@@ -603,6 +603,10 @@ KST4Contest verwendet einen gemeinsamen Timer für beide Chat-Kategorien. Aktivi
Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn die zweite Kategorie in derselben ON4KST-Sitzung aktiviert und vollständig synchronisiert ist.
Die beiden Aktivierungsschalter wirken sofort auf den laufenden Timer. Sie starten weder den Countdown neu noch senden sie unmittelbar eine Nachricht; der nächste reguläre Timer-Lauf prüft den aktuellen Zustand. Erst **Save Settings** speichert die Aktivierung für den nächsten Programmstart. Nachrichtentexte und Intervall folgen weiterhin der normalen Apply-/Save-Logik.
Der Status-Chip **MyBeacon** im Hauptfenster fasst beide nutzbaren Kategorien zusammen: **off** bedeutet kein aktiver Beacon, **1/2** genau eine von zwei aktiven Kategorien und **on** alle derzeit nutzbaren Kategorien. Ein Klick auf den Chip schaltet alle nutzbaren Beacons gemeinsam ein oder aus. Ist der zweite Chat deaktiviert, ändert der Chip nur den Beacon der Hauptkategorie und lässt die gespeicherte Einstellung des zweiten Beacons unverändert.
### Intervall und Timer-Verhalten
Das Intervall wird in ganzen Minuten angegeben. Der kleinste zulässige Wert ist eine Minute.
+5 -1
View File
@@ -4,7 +4,7 @@
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language and the login data on the **Station** tab take effect immediately.
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language, the login data on the **Station** tab and both beacon enable settings take effect immediately.
>
> Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
@@ -603,6 +603,10 @@ KST4Contest uses one shared timer for both chat categories. Each category nevert
When both beacons are enabled, they are sent one after the other in their respective categories during the same timer run. The second beacon is only considered while the second category is enabled and fully synchronised in the same ON4KST session.
Both enable settings affect the running timer immediately. They neither restart the countdown nor send a message straight away; the next regular timer run checks the current state. Only **Save Settings** stores the enable state for the next program start. Message templates and the interval continue to follow the normal Apply/Save workflow.
The **MyBeacon** status chip in the main window combines the currently usable categories: **off** means that no beacon is enabled, **1/2** that one of two categories is enabled, and **on** that every usable category is enabled. Clicking the chip enables or disables all usable beacons together. If the second chat is disabled, the chip changes only the primary-category beacon and leaves the stored secondary-beacon setting untouched.
### Interval and timer behaviour
The interval is entered in whole minutes. The minimum permitted value is one minute.
+1 -1
View File
@@ -565,7 +565,7 @@ If the layout has become inconvenient, first move the dividers and column widths
## Operating Tips
- **Keep the settings window open**: This provides quick access to the beacon controls.
- **Toggle beacons quickly**: Keep the settings window open to control each category separately. Click the **MyBeacon** status chip to enable or disable all currently usable beacons together.
- **Right-click in the user list**: Opens the snippet menu and additional actions, including QRZ.com profiles and NOT-QRV marks.
- **Press Enter while working in the chat**: If the send field contains text, Enter sends it directly even when another control has focus.
- **Stop the beacon while scanning**: Disable the beacon while moving through frequencies to avoid flooding the chat with unnecessary messages.