From f13fe54fde3bcdab3cb7260cc6d490c7e012d010 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Sat, 14 Nov 2026 08:56:04 +0100 Subject: [PATCH] Harden beacon sessions and mark automatic messages --- .../src/main/i18n/strings_de.properties | 12 +- .../src/main/i18n/strings_en.properties | 12 +- .../view/ComposeChatInputActions.java | 6 + .../view/compose/BeaconStatusPresentation.kt | 58 +++++ .../kst4contest/view/compose/ChatInputRow.kt | 4 +- .../view/compose/ChatInputState.kt | 7 + .../kst4contest/view/compose/SettingsTabs.kt | 4 + .../kst4contest/view/compose/StatusBar.kt | 57 ++++- .../view/compose/StatusIndicatorState.kt | 19 +- .../view/compose/tabs/BeaconTabState.kt | 9 + .../view/compose/BeaconStatusColorsTest.kt | 58 +++++ .../compose/BeaconStatusPresentationTest.kt | 59 +++++ .../view/compose/ChatInputStateTest.kt | 17 ++ .../view/compose/tabs/BeaconTabStateTest.kt | 13 +- .../RemainingTextsFollowTheLanguageTest.kt | 2 +- .../AutomaticChatMessageFormatter.java | 15 ++ .../controller/BeaconFrequencyParser.java | 66 +++++ .../controller/BeaconSessionState.java | 225 ++++++++++++++++++ .../kst4contest/controller/BeaconStatus.java | 63 ++--- .../kst4contest/controller/BeaconTask.java | 31 ++- .../controller/ChatController.java | 108 +++++++-- .../MessageBusManagementThread.java | 16 +- .../controller/ReadUDPByWintestThread.java | 10 +- .../controller/ReadUDPbyUCXMessageThread.java | 14 +- .../model/BeaconStatusSnapshot.java | 83 +++++++ .../kst4contest/model/ChatPreferences.java | 25 +- .../kst4contest/model/ThreadStateMessage.java | 9 + .../main/resources/praktiKSTpreferences.xml | 4 +- .../AutomaticChatMessageFormatterTest.java | 27 +++ .../controller/BeaconControlTest.java | 50 +++- .../controller/BeaconFrequencyParserTest.java | 31 +++ .../controller/BeaconSessionStateTest.java | 136 +++++++++++ .../controller/BeaconTaskTest.java | 39 ++- ...anagementThreadAutoAnswerDecisionTest.java | 8 +- ...sageBusManagementThreadAutoAnswerTest.java | 14 +- ...ssageBusManagementThreadChatFrameTest.java | 2 +- .../controller/On4KstProtocolTest.java | 28 ++- ...ReadUDPbyUCXMessageThreadLoopbackTest.java | 37 +++ .../controller/WinTestStatusSlotTest.java | 30 +-- .../model/ChatPreferencesBeaconStateTest.java | 40 +++- .../model/ChatPreferencesRoundTripTest.java | 4 + docs/PROJECT_CONTEXT.md | 19 +- .../plans/2026-11-11-beacon-session-safety.md | 207 ++++++++++++++++ .../plans/2026-11-13-automatic-chat-suffix.md | 96 ++++++++ .../2026-11-04-live-beacon-control-design.md | 32 +-- ...2026-11-11-beacon-session-safety-design.md | 128 ++++++++++ ...2026-11-13-automatic-chat-suffix-design.md | 27 +++ github_docs/client_settings_window_beacon.png | Bin 64391 -> 54170 bytes github_docs/de-Benutzeroberflaeche.md | 4 +- github_docs/de-Changelog.md | 4 + github_docs/de-Contest-Workflow.md | 4 +- github_docs/de-Funktionen.md | 10 +- github_docs/de-Konfiguration.md | 44 ++-- github_docs/de-Makros-und-Variablen.md | 10 +- github_docs/en-Changelog.md | 4 + github_docs/en-Configuration.md | 44 ++-- github_docs/en-Contest-Workflow.md | 4 +- github_docs/en-Features.md | 10 +- github_docs/en-Macros-and-Variables.md | 10 +- github_docs/en-User-Interface.md | 4 +- website/src/features/automatic-replies.md | 4 +- website/src/features/macros.md | 8 +- .../src/features/trx-qrg-synchronisation.md | 32 +-- 63 files changed, 1905 insertions(+), 252 deletions(-) create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/BeaconStatusPresentation.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusColorsTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusPresentationTest.kt create mode 100644 core/src/main/java/kst4contest/controller/AutomaticChatMessageFormatter.java create mode 100644 core/src/main/java/kst4contest/controller/BeaconFrequencyParser.java create mode 100644 core/src/main/java/kst4contest/controller/BeaconSessionState.java create mode 100644 core/src/main/java/kst4contest/model/BeaconStatusSnapshot.java create mode 100644 core/src/test/java/kst4contest/controller/AutomaticChatMessageFormatterTest.java create mode 100644 core/src/test/java/kst4contest/controller/BeaconFrequencyParserTest.java create mode 100644 core/src/test/java/kst4contest/controller/BeaconSessionStateTest.java create mode 100644 docs/superpowers/plans/2026-11-11-beacon-session-safety.md create mode 100644 docs/superpowers/plans/2026-11-13-automatic-chat-suffix.md create mode 100644 docs/superpowers/specs/2026-11-11-beacon-session-safety-design.md create mode 100644 docs/superpowers/specs/2026-11-13-automatic-chat-suffix-design.md diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index e0c69113..d006c514 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -473,7 +473,17 @@ profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel profiles.alreadyActive=Dieses Profil ist bereits aktiv. profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen. beacon.invalidMessage=Der Bakentext ist ungültig: {0} -beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben. +beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minuten eingeben. +beacon.status.main=Hauptkategorie: {0} +beacon.status.second=Zweite Kategorie: {0} +beacon.status.enabled=aktiv +beacon.status.failed=Versand fehlgeschlagen +beacon.status.unavailable=nicht verfügbar +beacon.reason.session=in dieser Sitzung noch nicht aktiviert +beacon.reason.user=durch den Benutzer ausgeschaltet +beacon.reason.primaryQrg=Haupt-QRG fehlt oder ist ungültig +beacon.reason.secondaryQrg=zweite QRG fehlt oder ist ungültig +beacon.reason.automaticQrg=automatische QRG-Änderung um mehr als 5 kHz notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben. notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen. notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben. diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 403a1b78..da98bd70 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -482,7 +482,17 @@ profiles.changeAfterSwitch=The change takes effect after switching to this profi profiles.alreadyActive=This profile is already active. profiles.selectFirst=Select a profile in the table first. beacon.invalidMessage=The beacon message is invalid: {0} -beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute. +beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minutes. +beacon.status.main=Main category: {0} +beacon.status.second=Second category: {0} +beacon.status.enabled=active +beacon.status.failed=send failed +beacon.status.unavailable=unavailable +beacon.reason.session=not activated in this session +beacon.reason.user=switched off by the operator +beacon.reason.primaryQrg=primary QRG missing or invalid +beacon.reason.secondaryQrg=second QRG missing or invalid +beacon.reason.automaticQrg=automatic QRG change greater than 5 kHz notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535. notification.invalidSpotter="{0}" is not a valid spotter callsign. notification.invalidCallsign=Please enter a valid callsign. diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java index cbbe6286..0cc584c3 100644 --- a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java +++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java @@ -1,5 +1,6 @@ package kst4contest.view; +import kst4contest.controller.RadioSlot; import kst4contest.model.ChatCategory; import kst4contest.model.ChatMember; import kst4contest.model.ChatMessage; @@ -97,4 +98,9 @@ public final class ComposeChatInputActions implements ChatInputActions { String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get(); return value == null ? "" : value; } + + @Override + public void applyManualQrg(final RadioSlot slot, final String value) { + application.chatcontroller.applyManualQrg(slot, value); + } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/BeaconStatusPresentation.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/BeaconStatusPresentation.kt new file mode 100644 index 00000000..d6993d6f --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/BeaconStatusPresentation.kt @@ -0,0 +1,58 @@ +package kst4contest.view.compose + +import kst4contest.model.BeaconStatusSnapshot +import kst4contest.model.BeaconStatusSnapshot.AggregateState +import kst4contest.model.BeaconStatusSnapshot.CategoryStatus +import kst4contest.model.BeaconStatusSnapshot.InactiveReason +import kst4contest.view.i18n.CurrentStrings +import kst4contest.view.i18n.Strings + +enum class ThreadStatusVisualStyle { + DEFAULT, + BEACON_OFF, + BEACON_ACTIVE, + BEACON_FAILED, +} + +data class BeaconStatusPresentation( + val label: String, + val tooltip: String, + val style: ThreadStatusVisualStyle, +) { + companion object { + fun from( + snapshot: BeaconStatusSnapshot, + strings: Strings = CurrentStrings.get(), + ): BeaconStatusPresentation { + val label = when (snapshot.aggregateState) { + AggregateState.OFF -> "off" + AggregateState.PARTIAL -> "1/2" + AggregateState.ON -> "on" + AggregateState.FAILED -> "FAILED" + } + val style = when (snapshot.aggregateState) { + AggregateState.OFF -> ThreadStatusVisualStyle.BEACON_OFF + AggregateState.PARTIAL, AggregateState.ON -> ThreadStatusVisualStyle.BEACON_ACTIVE + AggregateState.FAILED -> ThreadStatusVisualStyle.BEACON_FAILED + } + val tooltip = listOf( + strings.beaconStatusMain(describe(snapshot.main, strings)), + strings.beaconStatusSecond(describe(snapshot.second, strings)), + ).joinToString("\n") + return BeaconStatusPresentation(label, tooltip, style) + } + + private fun describe(category: CategoryStatus, strings: Strings): String { + if (!category.isAvailable()) return strings.beaconStatusUnavailable + if (category.isFailed()) return strings.beaconStatusFailed + if (category.isEnabled()) return strings.beaconStatusEnabled + return when (category.inactiveReason) { + InactiveReason.NOT_ACTIVATED_THIS_SESSION -> strings.beaconReasonSession + InactiveReason.USER_DISABLED -> strings.beaconReasonUser + InactiveReason.PRIMARY_QRG_UNAVAILABLE -> strings.beaconReasonPrimaryQrg + InactiveReason.SECONDARY_QRG_UNAVAILABLE -> strings.beaconReasonSecondaryQrg + InactiveReason.AUTOMATIC_QRG_CHANGE -> strings.beaconReasonAutomaticQrg + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt index db59fe40..d52a25ba 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt @@ -163,12 +163,12 @@ fun ChatInputRow( // profile on every keystroke. InlineCommittedField( stored = ownQrgMain.value.orEmpty(), - onCommit = { prefs.getMYQRGFirstCat().set(it) }, + onCommit = { state.commitOwnQrg(mainCategory = true, it) }, tooltip = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip, ) InlineCommittedField( stored = ownQrgSecond.value.orEmpty(), - onCommit = { prefs.getMYQRGSecondCat().set(it) }, + onCommit = { state.commitOwnQrg(mainCategory = false, it) }, tooltip = if (so2rEnabled) { kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r } else { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt index 2fb7874c..09e120f7 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import kst4contest.controller.RadioSlot import kst4contest.model.ChatCategory import kst4contest.model.ChatMember import kst4contest.model.ChatMessage @@ -46,6 +47,8 @@ interface ChatInputActions { fun ownQrgMain(): String fun ownQrgSecond(): String + + fun applyManualQrg(slot: RadioSlot, value: String) {} } /** @@ -150,6 +153,10 @@ class ChatInputState(private val actions: ChatInputActions) { appendAndFocus("$qrg ") } + fun commitOwnQrg(mainCategory: Boolean, value: String) { + actions.applyManualQrg(if (mainCategory) RadioSlot.FIRST else RadioSlot.SECOND, value) + } + /** * Replaces the field with "/cq CALL ", resolved. * diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt index 3200d19a..c8d0a28f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -194,6 +194,10 @@ fun buildSettingsTabs( setSecondBeaconEnabled = controller::setSecondBeaconEnabled, mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback), secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback), + onBeaconTemplatesApplied = { + controller.onMainBeaconTemplateApplied() + controller.onSecondBeaconTemplateApplied() + }, ) val logSynchState = LogSynchTabState( diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt index a63bb3e0..c2254143 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt @@ -26,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.graphics.luminance import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -202,8 +203,20 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) { } val scheme = MaterialTheme.colorScheme - val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant - val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant + val colors = if (button.visualStyle == ThreadStatusVisualStyle.DEFAULT) { + ThreadButtonColors( + background = if (button.highlighted) scheme.primary else scheme.surfaceVariant, + border = null, + text = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant, + ) + } else { + beaconThreadButtonColors( + style = button.visualStyle, + highlighted = button.highlighted, + darkTheme = scheme.surface.luminance() < 0.5f, + neutralBackground = scheme.surfaceVariant, + ) + } TooltipArea(tooltip = { TooltipCard(button.tooltip) }) { val actionModifier = if (button.hasAction) { @@ -211,17 +224,20 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) { } else { Modifier } - Box( - modifier = Modifier + var boxModifier = Modifier .defaultMinSize(minHeight = 22.dp) - .background(background, NOTICE_SHAPE) + .background(colors.background, NOTICE_SHAPE) .then(actionModifier) - .padding(NOTICE_PADDING), + colors.border?.let { border -> + boxModifier = boxModifier.border(1.dp, border, NOTICE_SHAPE) + } + Box( + modifier = boxModifier.padding(NOTICE_PADDING), contentAlignment = Alignment.Center, ) { Text( text = button.label, - color = foreground, + color = colors.text, style = MaterialTheme.typography.labelMedium, maxLines = 1, ) @@ -229,6 +245,33 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) { } } +internal data class ThreadButtonColors( + val background: Color, + val border: Color?, + val text: Color, +) + +internal fun beaconThreadButtonColors( + style: ThreadStatusVisualStyle, + highlighted: Boolean, + darkTheme: Boolean, + neutralBackground: Color, +): ThreadButtonColors = when (style) { + ThreadStatusVisualStyle.BEACON_ACTIVE -> if (highlighted) { + ThreadButtonColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B)) + } else { + ThreadButtonColors(Color(0xFF238636), Color(0xFF56D364), Color.White) + } + ThreadStatusVisualStyle.BEACON_FAILED -> + ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White) + ThreadStatusVisualStyle.BEACON_OFF -> ThreadButtonColors( + neutralBackground, + null, + if (darkTheme) Color(0xFFFF6B6B) else Color(0xFFD50000), + ) + ThreadStatusVisualStyle.DEFAULT -> error("Default worker colours come from the theme") +} + /** The tooltips in this bar carry the long text the badges had to cut. */ @Composable internal fun TooltipCard(text: String) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt index 7fbf6712..b708bd83 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt @@ -187,9 +187,11 @@ class ThreadStatusButtons { val button = bySource.getOrPut(sourceName) { ThreadStatusButton(sourceName, actions[sourceName]).also { ordered.add(it) } } + val beacon = effectiveMessage.beaconStatusSnapshot?.let(BeaconStatusPresentation::from) button.report( - label = "$sourceName: ${effectiveMessage.runningInformationTextDescription}", - tooltip = effectiveMessage.runningInformation.orEmpty(), + label = "$sourceName: ${beacon?.label ?: effectiveMessage.runningInformationTextDescription}", + tooltip = beacon?.tooltip ?: effectiveMessage.runningInformation.orEmpty(), + style = beacon?.style ?: ThreadStatusVisualStyle.DEFAULT, ) } @@ -226,6 +228,9 @@ class ThreadStatusButton( var highlighted: Boolean by mutableStateOf(false) private set + var visualStyle: ThreadStatusVisualStyle by mutableStateOf(ThreadStatusVisualStyle.DEFAULT) + private set + var revision: Int by mutableStateOf(0) private set @@ -235,10 +240,16 @@ class ThreadStatusButton( val hasAction: Boolean get() = action != null - internal fun report(label: String, tooltip: String) { + internal fun report( + label: String, + tooltip: String, + style: ThreadStatusVisualStyle = ThreadStatusVisualStyle.DEFAULT, + ) { this.label = label this.tooltip = tooltip - highlighted = true + visualStyle = style + highlighted = style == ThreadStatusVisualStyle.DEFAULT || + style == ThreadStatusVisualStyle.BEACON_ACTIVE revision++ } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt index 333b6e46..3b044c69 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt @@ -30,6 +30,7 @@ class BeaconTabState( val secondCategoryName: String, private val setMainBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledMainCat, private val setSecondBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledSecondCat, + private val onBeaconTemplatesApplied: () -> Unit = {}, ) { val mainBeaconEnabled = prefs.bcnBeaconsEnabledMainCatProperty() @@ -45,10 +46,14 @@ class BeaconTabState( private var textMainCat: String by draft.setting( { prefs.getBcn_beaconTextMainCat() }, + effectKey = BEACON_TEMPLATE_EFFECT, + effect = onBeaconTemplatesApplied, ) { prefs.setBcn_beaconTextMainCat(it) } private var textSecondCat: String by draft.setting( { prefs.getBcn_beaconTextSecondCat() }, + effectKey = BEACON_TEMPLATE_EFFECT, + effect = onBeaconTemplatesApplied, ) { prefs.setBcn_beaconTextSecondCat(it) } /* @@ -123,4 +128,8 @@ class BeaconTabState( interval = minutes return null } + + private companion object { + const val BEACON_TEMPLATE_EFFECT = "beaconTemplates" + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusColorsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusColorsTest.kt new file mode 100644 index 00000000..c20e1e04 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusColorsTest.kt @@ -0,0 +1,58 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BeaconStatusColorsTest { + private val neutral = Color(0xFF123456) + + @Test + fun `active and activity colours are the approved traffic colours`() { + assertEquals( + ThreadButtonColors(Color(0xFF238636), Color(0xFF56D364), Color.White), + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_ACTIVE, false, false, neutral), + ) + assertEquals( + ThreadButtonColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B)), + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_ACTIVE, true, false, neutral), + ) + } + + @Test + fun `off uses neutral background and theme specific red text`() { + assertEquals(Color(0xFFD50000), + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, false, neutral).text) + assertEquals(Color(0xFFFF6B6B), + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, true, neutral).text) + assertEquals(neutral, + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_OFF, false, true, neutral).background) + } + + @Test + fun `failed stays red even when a highlight is requested`() { + val colors = beaconThreadButtonColors( + ThreadStatusVisualStyle.BEACON_FAILED, + true, + false, + neutral, + ) + assertEquals(ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White), colors) + } + + @Test + fun `failed suppresses the flash while active status allows it`() { + val button = ThreadStatusButton("MyBeacon") + button.report("MyBeacon: on", "active", ThreadStatusVisualStyle.BEACON_ACTIVE) + assertTrue(button.highlighted) + + button.report("MyBeacon: FAILED", "failed", ThreadStatusVisualStyle.BEACON_FAILED) + assertFalse(button.highlighted) + assertEquals( + ThreadButtonColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White), + beaconThreadButtonColors(ThreadStatusVisualStyle.BEACON_FAILED, true, false, neutral), + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusPresentationTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusPresentationTest.kt new file mode 100644 index 00000000..34f785c5 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusPresentationTest.kt @@ -0,0 +1,59 @@ +package kst4contest.view.compose + +import kst4contest.model.BeaconStatusSnapshot +import kst4contest.model.BeaconStatusSnapshot.AggregateState +import kst4contest.model.BeaconStatusSnapshot.CategoryStatus +import kst4contest.model.BeaconStatusSnapshot.InactiveReason +import kst4contest.view.i18n.LanguageStore +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.Locale + +class BeaconStatusPresentationTest { + @Test + fun `partial status keeps the inactive category reason in English`() { + val presentation = BeaconStatusPresentation.from(snapshot(AggregateState.PARTIAL)) + + assertEquals("1/2", presentation.label) + assertTrue(presentation.tooltip.contains("Main category: active")) + assertTrue(presentation.tooltip.contains("primary QRG missing or invalid")) + } + + @Test + fun `the same category reasons are available in German`() { + val language = LanguageStore({ "de" }, {}, Locale.UK) + try { + val presentation = BeaconStatusPresentation.from( + snapshot(AggregateState.PARTIAL), + language.strings, + ) + + assertTrue(presentation.tooltip.contains("Hauptkategorie: aktiv")) + assertTrue(presentation.tooltip.contains("Haupt-QRG fehlt oder ist ungültig")) + assertFalse(presentation.tooltip.contains("144.")) + } finally { + language.use("en") + } + } + + @Test + fun `failed style overrides an active category`() { + val main = CategoryStatus(true, true, true, InactiveReason.USER_DISABLED) + val second = CategoryStatus(false, false, false, InactiveReason.NOT_ACTIVATED_THIS_SESSION) + val presentation = BeaconStatusPresentation.from( + BeaconStatusSnapshot(AggregateState.FAILED, main, second), + ) + + assertEquals("FAILED", presentation.label) + assertEquals(ThreadStatusVisualStyle.BEACON_FAILED, presentation.style) + assertTrue(presentation.tooltip.contains("send failed")) + } + + private fun snapshot(state: AggregateState): BeaconStatusSnapshot = BeaconStatusSnapshot( + state, + CategoryStatus(true, true, false, InactiveReason.USER_DISABLED), + CategoryStatus(true, false, false, InactiveReason.PRIMARY_QRG_UNAVAILABLE), + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt index 35f250fd..303f8171 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.text.input.TextFieldValue import kst4contest.model.ChatCategory import kst4contest.model.ChatMember import kst4contest.model.ChatMessage +import kst4contest.controller.RadioSlot import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame @@ -233,6 +234,20 @@ class ChatInputStateTest { assertEquals("432.200 ", state.value.text) } + @Test + fun `manual QRG commits keep their radio source explicit`() { + val actions = FakeActions() + val state = ChatInputState(actions) + + state.commitOwnQrg(mainCategory = true, "144.300") + state.commitOwnQrg(mainCategory = false, "1296.100") + + assertEquals( + listOf(RadioSlot.FIRST to "144.300", RadioSlot.SECOND to "1296.100"), + actions.manualQrgs, + ) + } + // ---- the snippets ------------------------------------------------------ /** @@ -407,6 +422,7 @@ class ChatInputStateTest { val resolvedTemplates = mutableListOf() val recordedCqs = mutableListOf() val recomputeReasons = mutableListOf() + val manualQrgs = mutableListOf>() override fun panelSelection(): ChatMember? = panelSelection override fun tableSelection(): ChatMember? = tableSelection @@ -431,5 +447,6 @@ class ChatInputStateTest { override fun queue(message: ChatMessage) { queued += message } override fun ownQrgMain(): String = ownQrgMain override fun ownQrgSecond(): String = ownQrgSecond + override fun applyManualQrg(slot: RadioSlot, value: String) { manualQrgs += slot to value } } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt index 23685a02..c7b70a9b 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt @@ -173,13 +173,24 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) { s.prefs.setBcn_beaconIntervalInMinutesMainCat(5) val state = s.state() - assertNotNull(state.commitBeaconInterval("0")) + assertNotNull(state.commitBeaconInterval("4")) assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat()) assertEquals(0, s.timerRestarts, "restarting the timer per rejected keystroke would disturb the cooldown") } + @Test + fun `exactly five minutes is accepted`() { + val s = Session() + val state = s.state() + + assertNull(state.commitBeaconInterval("5")) + + assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat()) + assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesSecondCat()) + } + @Test fun `an interval that is not a whole number is refused`() { val s = Session() diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/RemainingTextsFollowTheLanguageTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/RemainingTextsFollowTheLanguageTest.kt index d675f3d9..9550af60 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/i18n/RemainingTextsFollowTheLanguageTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/RemainingTextsFollowTheLanguageTest.kt @@ -73,7 +73,7 @@ class RemainingTextsFollowTheLanguageTest { assertEquals("Wertung", score.title(), "the column title stayed in the old language") assertEquals( - "Einen ganzzahligen Bakenabstand von mindestens 1 Minute eingeben.", + "Einen ganzzahligen Bakenabstand von mindestens 5 Minuten eingeben.", beacon.commitBeaconInterval("0"), "the refusal from the tab state stayed in the old language", ) diff --git a/core/src/main/java/kst4contest/controller/AutomaticChatMessageFormatter.java b/core/src/main/java/kst4contest/controller/AutomaticChatMessageFormatter.java new file mode 100644 index 00000000..21f03284 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/AutomaticChatMessageFormatter.java @@ -0,0 +1,15 @@ +package kst4contest.controller; + +/** Adds the fixed marker used by every user-visible automatic chat message. */ +final class AutomaticChatMessageFormatter { + + /** Exact marker appended to every user-visible automatic chat message. */ + /* default */ static final String SUFFIX = " *a*"; + + private AutomaticChatMessageFormatter() { + } + + /* default */ static String appendSuffix(final String messageText) { + return messageText + SUFFIX; + } +} diff --git a/core/src/main/java/kst4contest/controller/BeaconFrequencyParser.java b/core/src/main/java/kst4contest/controller/BeaconFrequencyParser.java new file mode 100644 index 00000000..f00dbdaf --- /dev/null +++ b/core/src/main/java/kst4contest/controller/BeaconFrequencyParser.java @@ -0,0 +1,66 @@ +package kst4contest.controller; + +import java.util.OptionalLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import kst4contest.model.Band; + +/** Parses complete beacon-reference frequencies without floating-point comparisons. */ +final class BeaconFrequencyParser { + private static final Pattern GROUPED = Pattern.compile( + "^(\\d{2,5})[.,](\\d{1,3})(?:[.,](\\d{1,3}))?$" + ); + private static final Pattern COMPACT = Pattern.compile("^\\d{5,8}$"); + + private BeaconFrequencyParser() { + } + + static OptionalLong parseHz(String value) { + if (value == null) { + return OptionalLong.empty(); + } + + String normalized = value.trim(); + if (normalized.isEmpty()) { + return OptionalLong.empty(); + } + + try { + long frequencyHz; + Matcher grouped = GROUPED.matcher(normalized); + if (grouped.matches()) { + long megahertz = Long.parseLong(grouped.group(1)); + long kilohertz = rightPadToThree(grouped.group(2)); + long hertz = grouped.group(3) == null + ? 0L + : rightPadToThree(grouped.group(3)); + frequencyHz = Math.addExact( + Math.multiplyExact(megahertz, 1_000_000L), + Math.addExact(Math.multiplyExact(kilohertz, 1_000L), hertz) + ); + } else if (COMPACT.matcher(normalized).matches()) { + long compact = Long.parseLong(normalized); + frequencyHz = Math.multiplyExact(compact, 1_000L); + } else { + return OptionalLong.empty(); + } + + if (frequencyHz <= 0L + || Band.fromFrequency(frequencyHz / 1_000_000.0) == null) { + return OptionalLong.empty(); + } + return OptionalLong.of(frequencyHz); + } catch (ArithmeticException | NumberFormatException ignored) { + return OptionalLong.empty(); + } + } + + private static long rightPadToThree(String digits) { + long value = Long.parseLong(digits); + for (int index = digits.length(); index < 3; index++) { + value *= 10L; + } + return value; + } +} diff --git a/core/src/main/java/kst4contest/controller/BeaconSessionState.java b/core/src/main/java/kst4contest/controller/BeaconSessionState.java new file mode 100644 index 00000000..ad4acefb --- /dev/null +++ b/core/src/main/java/kst4contest/controller/BeaconSessionState.java @@ -0,0 +1,225 @@ +package kst4contest.controller; + +import java.util.OptionalLong; + +import kst4contest.model.BeaconStatusSnapshot; +import kst4contest.model.BeaconStatusSnapshot.AggregateState; +import kst4contest.model.BeaconStatusSnapshot.CategoryStatus; +import kst4contest.model.BeaconStatusSnapshot.InactiveReason; +import kst4contest.model.ChatPreferences; + +/** Owns session-only beacon activation, QRG references, reasons, and send failures. */ +final class BeaconSessionState { + static final long MAX_AUTOMATIC_QRG_DELTA_HZ = 5_000L; + + enum Channel { + MAIN, + SECOND + } + + private final ChannelState main = new ChannelState(); + private final ChannelState second = new ChannelState(); + + void resetSession(ChatPreferences preferences) { + synchronized (this) { + main.reset(); + second.reset(); + } + project(preferences); + } + + boolean setEnabled(Channel channel, boolean enabled, ChatPreferences preferences) { + boolean accepted; + synchronized (this) { + ChannelState target = state(channel); + if (!enabled) { + target.disable(InactiveReason.USER_DISABLED); + accepted = true; + } else { + InactiveReason invalidReason = missingRequiredQrg(channel, preferences); + if (invalidReason != null) { + target.disable(invalidReason); + accepted = false; + } else { + target.enabled = true; + target.failed = false; + OptionalLong reference = ownQrg(channel, preferences); + target.referenceHz = reference.isPresent() ? reference.getAsLong() : null; + accepted = true; + } + } + } + project(preferences); + return accepted; + } + + void onManualQrgChanged(Channel channel, String value, ChatPreferences preferences) { + if (channel == Channel.MAIN) { + preferences.getMYQRGFirstCat().set(value); + } else { + preferences.getMYQRGSecondCat().set(value); + } + OptionalLong parsed = BeaconFrequencyParser.parseHz(value); + synchronized (this) { + ChannelState target = state(channel); + if (target.enabled) { + target.referenceHz = parsed.isPresent() ? parsed.getAsLong() : null; + } + revalidateEnabled(preferences); + } + project(preferences); + } + + void onExternalQrgChanged(Channel channel, long frequencyHz, ChatPreferences preferences) { + boolean valid = frequencyHz > 0L && ownQrg(channel, preferences).isPresent(); + synchronized (this) { + ChannelState target = state(channel); + if (target.enabled) { + if (!valid) { + target.referenceHz = null; + } else if (target.referenceHz == null) { + target.referenceHz = frequencyHz; + } else if (absoluteDifference(target.referenceHz, frequencyHz) + > MAX_AUTOMATIC_QRG_DELTA_HZ) { + target.disable(InactiveReason.AUTOMATIC_QRG_CHANGE); + } + } + revalidateEnabled(preferences); + } + project(preferences); + } + + void onTemplateChanged(Channel channel, ChatPreferences preferences) { + synchronized (this) { + ChannelState target = state(channel); + if (target.enabled) { + InactiveReason reason = missingRequiredQrg(channel, preferences); + if (reason != null) { + target.disable(reason); + } + } + } + project(preferences); + } + + synchronized boolean isEnabled(Channel channel) { + return state(channel).enabled; + } + + synchronized void recordSendFailure(Channel channel, boolean failed) { + ChannelState target = state(channel); + target.failed = target.enabled && failed; + } + + synchronized BeaconStatusSnapshot snapshot(ChatPreferences preferences) { + boolean secondAvailable = preferences.isLoginToSecondChatEnabled(); + boolean mainEnabled = main.enabled; + boolean secondEnabled = secondAvailable && second.enabled; + boolean failed = (mainEnabled && main.failed) || (secondEnabled && second.failed); + int available = secondAvailable ? 2 : 1; + int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0); + + AggregateState aggregate; + if (failed) { + aggregate = AggregateState.FAILED; + } else if (enabled == 0) { + aggregate = AggregateState.OFF; + } else if (enabled == available) { + aggregate = AggregateState.ON; + } else { + aggregate = AggregateState.PARTIAL; + } + + return new BeaconStatusSnapshot( + aggregate, + new CategoryStatus(true, mainEnabled, main.failed, main.reason), + new CategoryStatus(secondAvailable, secondEnabled, second.failed, second.reason) + ); + } + + private void revalidateEnabled(ChatPreferences preferences) { + revalidate(Channel.MAIN, preferences); + revalidate(Channel.SECOND, preferences); + } + + private void revalidate(Channel channel, ChatPreferences preferences) { + ChannelState target = state(channel); + if (!target.enabled) { + return; + } + InactiveReason reason = missingRequiredQrg(channel, preferences); + if (reason != null) { + target.disable(reason); + } + } + + private static InactiveReason missingRequiredQrg( + Channel channel, + ChatPreferences preferences + ) { + String template = channel == Channel.MAIN + ? preferences.getBcn_beaconTextMainCat() + : preferences.getBcn_beaconTextSecondCat(); + String safeTemplate = template == null ? "" : template; + if ((safeTemplate.contains("MYQRG") || safeTemplate.contains("MYQRGSHORT")) + && BeaconFrequencyParser.parseHz(preferences.getMYQRGFirstCat().get()).isEmpty()) { + return InactiveReason.PRIMARY_QRG_UNAVAILABLE; + } + if (safeTemplate.contains("SECONDQRG") + && BeaconFrequencyParser.parseHz(preferences.getMYQRGSecondCat().get()).isEmpty()) { + return InactiveReason.SECONDARY_QRG_UNAVAILABLE; + } + return null; + } + + private static OptionalLong ownQrg(Channel channel, ChatPreferences preferences) { + String value = channel == Channel.MAIN + ? preferences.getMYQRGFirstCat().get() + : preferences.getMYQRGSecondCat().get(); + return BeaconFrequencyParser.parseHz(value); + } + + private static long absoluteDifference(long first, long second) { + return first >= second ? first - second : second - first; + } + + private void project(ChatPreferences preferences) { + boolean mainEnabled; + boolean secondEnabled; + synchronized (this) { + mainEnabled = main.enabled; + secondEnabled = second.enabled; + } + if (preferences.isBcn_beaconsEnabledMainCat() != mainEnabled) { + preferences.setBcn_beaconsEnabledMainCat(mainEnabled); + } + if (preferences.isBcn_beaconsEnabledSecondCat() != secondEnabled) { + preferences.setBcn_beaconsEnabledSecondCat(secondEnabled); + } + } + + private ChannelState state(Channel channel) { + return channel == Channel.MAIN ? main : second; + } + + private static final class ChannelState { + private boolean enabled; + private boolean failed; + private Long referenceHz; + private InactiveReason reason = InactiveReason.NOT_ACTIVATED_THIS_SESSION; + + private void reset() { + enabled = false; + failed = false; + referenceHz = null; + reason = InactiveReason.NOT_ACTIVATED_THIS_SESSION; + } + + private void disable(InactiveReason inactiveReason) { + enabled = false; + failed = false; + referenceHz = null; + reason = inactiveReason; + } + } +} diff --git a/core/src/main/java/kst4contest/controller/BeaconStatus.java b/core/src/main/java/kst4contest/controller/BeaconStatus.java index 442f405b..caa0fc14 100644 --- a/core/src/main/java/kst4contest/controller/BeaconStatus.java +++ b/core/src/main/java/kst4contest/controller/BeaconStatus.java @@ -1,25 +1,18 @@ package kst4contest.controller; +import kst4contest.model.BeaconStatusSnapshot; import kst4contest.model.ChatPreferences; import kst4contest.model.ThreadStateMessage; -/** Keeps category failures and builds the single status shown for both beacons. */ +/** Builds the single status shown for both beacons from their session state. */ 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; + synchronized void recordRun(String activity) { this.activity = activity; } @@ -29,47 +22,33 @@ final class BeaconStatus { } /** 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); + synchronized ThreadStateMessage message( + ChatPreferences preferences, + BeaconSessionState sessionState + ) { + BeaconStatusSnapshot snapshot = sessionState.snapshot(preferences); + boolean critical = snapshot.getAggregateState() + == BeaconStatusSnapshot.AggregateState.FAILED; + boolean running = snapshot.getAggregateState() + != BeaconStatusSnapshot.AggregateState.OFF; String description; - if (critical) { - description = "FAILED"; - } else if (enabled == 0) { - description = "off"; - } else if (enabled == available) { - description = "on"; - } else { - description = enabled + "/" + available; + switch (snapshot.getAggregateState()) { + case FAILED -> description = "FAILED"; + case OFF -> description = "off"; + case ON -> description = "on"; + case PARTIAL -> description = "1/2"; + default -> throw new IllegalStateException("Unknown beacon status"); } - 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, + running, + activity, critical ); message.setRunningInformationTextDescription(description); + message.setBeaconStatusSnapshot(snapshot); return message; } - - private static String onOff(boolean enabled) { - return enabled ? "on" : "off"; - } } diff --git a/core/src/main/java/kst4contest/controller/BeaconTask.java b/core/src/main/java/kst4contest/controller/BeaconTask.java index 38f6c4a9..3a3f5907 100644 --- a/core/src/main/java/kst4contest/controller/BeaconTask.java +++ b/core/src/main/java/kst4contest/controller/BeaconTask.java @@ -22,6 +22,7 @@ public class BeaconTask extends TimerTask { private final ChatController chatController; private final ThreadStatusCallback callbackToController; private final BeaconStatus beaconStatus; + private final BeaconSessionState beaconSessionState; /** * Creates one execution of the shared beacon timer. @@ -36,6 +37,7 @@ public class BeaconTask extends TimerTask { this.chatController = chatController; this.callbackToController = callbackToController; this.beaconStatus = chatController.beaconStatusState(); + this.beaconSessionState = chatController.beaconSessionState(); } @Override @@ -45,23 +47,34 @@ public class BeaconTask extends TimerTask { Attempt second = sendSecondCategoryBeacon(); String activity = "Main category: " + main.description + "; second category: " + second.description; - beaconStatus.recordRun( - main == Attempt.INVALID, - second == Attempt.INVALID, - activity - ); + recordSendResult(BeaconSessionState.Channel.MAIN, main); + recordSendResult(BeaconSessionState.Channel.SECOND, second); + beaconStatus.recordRun(activity); callbackToController.onThreadStatus( BeaconStatus.SOURCE_NAME, - beaconStatus.message(chatController.getChatPreferences()) + beaconStatus.message( + chatController.getChatPreferences(), + beaconSessionState + ) ); } + private void recordSendResult( + BeaconSessionState.Channel channel, + Attempt attempt + ) { + if (attempt == Attempt.INVALID) { + beaconSessionState.recordSendFailure(channel, true); + } else if (attempt == Attempt.SENT) { + beaconSessionState.recordSendFailure(channel, false); + } + } + /** * Sends the main-category beacon if it is currently enabled. */ private Attempt sendMainCategoryBeacon() { - if (!chatController.getChatPreferences() - .isBcn_beaconsEnabledMainCat()) { + if (!beaconSessionState.isEnabled(BeaconSessionState.Channel.MAIN)) { return Attempt.OFF; } @@ -95,7 +108,7 @@ public class BeaconTask extends TimerTask { if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) { return Attempt.UNAVAILABLE; } - if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) { + if (!beaconSessionState.isEnabled(BeaconSessionState.Channel.SECOND)) { return Attempt.OFF; } diff --git a/core/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java index efadf213..4edc5d92 100644 --- a/core/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -65,9 +65,13 @@ public class ChatController implements ThreadStatusCallback { 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; + public static final int MIN_BEACON_INTERVAL_MINUTES = 5; + public static final int MAX_BEACON_PAYLOAD_LENGTH = 120; + public static final int MAX_BEACON_TEXT_LENGTH = + MAX_BEACON_PAYLOAD_LENGTH + + AutomaticChatMessageFormatter.SUFFIX.length(); private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L; + private final BeaconSessionState beaconSessionState = new BeaconSessionState(); private final BeaconStatus beaconStatus = new BeaconStatus(); private Consumer viewRotorCallback; @@ -3036,6 +3040,7 @@ private SimpleRoster chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences.readPreferencesFromXmlFile(); + beaconSessionState.resetSession(chatPreferences); Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath( ApplicationConstants.APPLICATION_NAME, MessageHistoryRecorder.CURRENT_FILE_NAME)); @@ -3288,6 +3293,7 @@ private SimpleRoster public void setChatPreferences(ChatPreferences chatPreferences) { this.chatPreferences = chatPreferences; + beaconSessionState.resetSession(chatPreferences); } public ChatMember getownChatMemberObject() { @@ -3385,13 +3391,21 @@ private SimpleRoster /** Applies the main-category beacon switch to the running session immediately. */ public void setMainBeaconEnabled(boolean enabled) { - getChatPreferences().setBcn_beaconsEnabledMainCat(enabled); + beaconSessionState.setEnabled( + BeaconSessionState.Channel.MAIN, + enabled, + getChatPreferences() + ); publishBeaconStatus("Beacon configuration changed."); } /** Applies the second-category beacon switch to the running session immediately. */ public void setSecondBeaconEnabled(boolean enabled) { - getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled); + beaconSessionState.setEnabled( + BeaconSessionState.Channel.SECOND, + enabled, + getChatPreferences() + ); publishBeaconStatus("Beacon configuration changed."); } @@ -3405,18 +3419,66 @@ private SimpleRoster public void toggleUsableBeacons() { ChatPreferences preferences = getChatPreferences(); boolean secondAvailable = preferences.isLoginToSecondChatEnabled(); - boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat() + boolean anyEnabled = beaconSessionState.isEnabled(BeaconSessionState.Channel.MAIN) || (secondAvailable - && preferences.isBcn_beaconsEnabledSecondCat()); + && beaconSessionState.isEnabled(BeaconSessionState.Channel.SECOND)); boolean enable = !anyEnabled; - preferences.setBcn_beaconsEnabledMainCat(enable); + beaconSessionState.setEnabled( + BeaconSessionState.Channel.MAIN, + enable, + preferences + ); if (secondAvailable) { - preferences.setBcn_beaconsEnabledSecondCat(enable); + beaconSessionState.setEnabled( + BeaconSessionState.Channel.SECOND, + enable, + preferences + ); } publishBeaconStatus("Beacon status toggled from the main window."); } + /** Applies a deliberate operator QRG entry and rebases the affected beacon. */ + public void applyManualQrg(RadioSlot slot, String value) { + BeaconSessionState.Channel channel = slot == RadioSlot.FIRST + ? BeaconSessionState.Channel.MAIN + : BeaconSessionState.Channel.SECOND; + beaconSessionState.onManualQrgChanged(channel, value, getChatPreferences()); + publishBeaconStatus("QRG changed by the operator."); + } + + /** Applies a logger QRG report and enforces the automatic-QSY safety boundary. */ + public void applyExternalQrg(RadioSlot slot, String displayValue, long frequencyHz) { + if (frequencyHz <= 0L) { + return; + } + slot.qrgIn(getChatPreferences()).set(displayValue); + BeaconSessionState.Channel channel = slot == RadioSlot.FIRST + ? BeaconSessionState.Channel.MAIN + : BeaconSessionState.Channel.SECOND; + beaconSessionState.onExternalQrgChanged(channel, frequencyHz, getChatPreferences()); + publishBeaconStatus("QRG updated by logging software."); + } + + /** Revalidates the applied main-category template. */ + public void onMainBeaconTemplateApplied() { + beaconSessionState.onTemplateChanged( + BeaconSessionState.Channel.MAIN, + getChatPreferences() + ); + publishBeaconStatus("Beacon template changed."); + } + + /** Revalidates the applied second-category template. */ + public void onSecondBeaconTemplateApplied() { + beaconSessionState.onTemplateChanged( + BeaconSessionState.Channel.SECOND, + getChatPreferences() + ); + publishBeaconStatus("Beacon template changed."); + } + /** Publishes the loaded state without changing either category switch. */ public void refreshBeaconStatus() { publishBeaconStatus("Beacon configuration loaded."); @@ -3427,9 +3489,13 @@ private SimpleRoster return beaconStatus; } + BeaconSessionState beaconSessionState() { + return beaconSessionState; + } + /** Builds the current aggregate when the UI is ready to display it. */ public ThreadStateMessage currentBeaconStatus() { - return beaconStatus.message(getChatPreferences()); + return beaconStatus.message(getChatPreferences(), beaconSessionState); } /** Publishes the aggregate state used by the single beacon status chip. */ @@ -3454,7 +3520,7 @@ private SimpleRoster * @param configuredText configured beacon template * @throws IllegalArgumentException if the template contains invalid protocol * content or currently resolves to more than - * {@value MAX_BEACON_TEXT_LENGTH} characters + * {@value MAX_BEACON_PAYLOAD_LENGTH} characters */ public void validateBeaconTemplate(String configuredText) { String normalizedTemplate = @@ -3473,7 +3539,10 @@ private SimpleRoster return; } - validateResolvedBeaconText(resolvedText); + validateBeaconText( + resolvedText, + MAX_BEACON_PAYLOAD_LENGTH + ); } /** @@ -3494,22 +3563,29 @@ private SimpleRoster new MessageVariableResolver(chatPreferences) .resolveGlobalVariables(configuredText); - return validateResolvedBeaconText(resolvedText); + String normalizedPayload = validateBeaconText( + resolvedText, + MAX_BEACON_PAYLOAD_LENGTH + ); + return validateBeaconText( + AutomaticChatMessageFormatter.appendSuffix(normalizedPayload), + MAX_BEACON_TEXT_LENGTH + ); } /** - * Applies the message-text and length rules to a fully resolved beacon. + * Applies the message-text and length rules to a beacon processing stage. */ - private String validateResolvedBeaconText(String resolvedText) { + private String validateBeaconText(String resolvedText, int maximumLength) { String normalizedText = On4KstProtocol.messageText(resolvedText); - if (normalizedText.length() > MAX_BEACON_TEXT_LENGTH) { + if (normalizedText.length() > maximumLength) { throw new IllegalArgumentException( "The resolved beacon message contains " + normalizedText.length() + " characters; maximum is " - + MAX_BEACON_TEXT_LENGTH + + maximumLength + "." ); } diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 5949ca8a..6808d4c6 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -872,10 +872,14 @@ public class MessageBusManagementThread extends Thread { versionInfo.setSender(itsMe); versionInfo.setReceiver(newMessageArrived.getSender()); versionInfo.setChatCategory(newMessageArrived.getChatCategory()); - versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() - + " " + AUTOANSWER_PREFIX - + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION - + " by DO5AMF"); + versionInfo.setMessageText( + AutomaticChatMessageFormatter.appendSuffix( + "/CQ " + newMessageArrived.getSender().getCallSign() + + " " + AUTOANSWER_PREFIX + + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION + + " by DO5AMF" + ) + ); this.client.getMessageTXBus().add(versionInfo); } @@ -2070,7 +2074,9 @@ public class MessageBusManagementThread extends Thread { + payload; try { - return On4KstProtocol.messageText(messageText); + return On4KstProtocol.messageText( + AutomaticChatMessageFormatter.appendSuffix(messageText) + ); } catch (IllegalArgumentException invalidMessage) { LOGGER.warning( diff --git a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java index 38f904e8..168c0cd8 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -535,6 +535,7 @@ public class ReadUDPByWintestThread extends Thread { * interpreted as frequencies. */ String formattedPassQrg = null; + Long passFrequencyRawValue = null; if (parts.size() > 11) { try { @@ -544,6 +545,7 @@ public class ReadUDPByWintestThread extends Thread { passFrequencyRaw / 10.0; if (passFrequencyKHz > 100.0) { + passFrequencyRawValue = passFrequencyRaw; formattedPassQrg = helper_formatWinTestFrequency( passFrequencyRaw @@ -566,10 +568,16 @@ public class ReadUDPByWintestThread extends Thread { usePassQrg && formattedPassQrg != null ? formattedPassQrg : formattedMainQrg; + final long selectedFrequencyRaw = + usePassQrg && passFrequencyRawValue != null + ? passFrequencyRawValue + : mainFrequencyRaw; + final long selectedFrequencyHz = Math.multiplyExact(selectedFrequencyRaw, 100L); if (prefs.isLogsynch_wintestQrgSyncEnabled()) { RadioSlot target = slot.get(); - client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet)); + client.uiDispatcher().runOnUi(() -> + client.applyExternalQrg(target, qrgToSet, selectedFrequencyHz)); } System.out.println( diff --git a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index c167ec46..329f6ed9 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -417,7 +417,19 @@ public class ReadUDPbyUCXMessageThread extends Thread { } String qrg = freqNode.getTextContent(); + qrg = qrg == null ? "" : qrg.trim(); String mode = modeNode.getTextContent(); + final long frequencyHz; + try { + frequencyHz = Math.multiplyExact(Long.parseLong(qrg), 10L); + } catch (NumberFormatException | ArithmeticException invalidFrequency) { + System.out.println("[ReadUDPFromUCX, warning]: RadioInfo with invalid Freq ignored"); + continue; + } + if (frequencyHz <= 0L) { + System.out.println("[ReadUDPFromUCX, warning]: RadioInfo with non-positive Freq ignored"); + continue; + } // System.out.println("QRG Length: " + qrg.length() + " // " + qrg); @@ -464,7 +476,7 @@ public class ReadUDPbyUCXMessageThread extends Thread { final String finalFormattedQRG = formattedQRG; final RadioSlot target = slot.get(); helper_runOnFxThread(() -> - target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG) + this.client.applyExternalQrg(target, finalFormattedQRG, frequencyHz) ); // System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG); diff --git a/core/src/main/java/kst4contest/model/BeaconStatusSnapshot.java b/core/src/main/java/kst4contest/model/BeaconStatusSnapshot.java new file mode 100644 index 00000000..7b1981e2 --- /dev/null +++ b/core/src/main/java/kst4contest/model/BeaconStatusSnapshot.java @@ -0,0 +1,83 @@ +package kst4contest.model; + +import java.util.Objects; + +/** Immutable, display-neutral status of both beacon categories. */ +public final class BeaconStatusSnapshot { + public enum AggregateState { + OFF, + PARTIAL, + ON, + FAILED + } + + public enum InactiveReason { + NOT_ACTIVATED_THIS_SESSION, + USER_DISABLED, + PRIMARY_QRG_UNAVAILABLE, + SECONDARY_QRG_UNAVAILABLE, + AUTOMATIC_QRG_CHANGE + } + + /** Immutable status of one beacon category. */ + public static final class CategoryStatus { + private final boolean available; + private final boolean enabled; + private final boolean failed; + private final InactiveReason inactiveReason; + + public CategoryStatus( + boolean available, + boolean enabled, + boolean failed, + InactiveReason inactiveReason + ) { + this.available = available; + this.enabled = enabled; + this.failed = failed; + this.inactiveReason = Objects.requireNonNull(inactiveReason); + } + + public boolean isAvailable() { + return available; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isFailed() { + return failed; + } + + public InactiveReason getInactiveReason() { + return inactiveReason; + } + } + + private final AggregateState aggregateState; + private final CategoryStatus main; + private final CategoryStatus second; + + public BeaconStatusSnapshot( + AggregateState aggregateState, + CategoryStatus main, + CategoryStatus second + ) { + this.aggregateState = Objects.requireNonNull(aggregateState); + this.main = Objects.requireNonNull(main); + this.second = Objects.requireNonNull(second); + } + + public AggregateState getAggregateState() { + return aggregateState; + } + + public CategoryStatus getMain() { + return main; + } + + public CategoryStatus getSecond() { + return second; + } +} diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index c4b31d5b..197993bd 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -366,7 +366,8 @@ public class ChatPreferences { * Beacon prefs */ - MutableValue bcn_beaconsEnabledMainCat = new SimpleValue<>(true); + private static final int MIN_BEACON_INTERVAL_MINUTES = 5; + MutableValue bcn_beaconsEnabledMainCat = new SimpleValue<>(false); MutableValue bcn_beaconsEnabledSecondCat = new SimpleValue<>(false); int bcn_beaconIntervalInMinutesMainCat = 20; int bcn_beaconIntervalInMinutesSecondCat = 20; @@ -2198,11 +2199,11 @@ public class ChatPreferences { beaconCQ.appendChild(bcn_beaconIntervalInMinutesMainCat); Element beaconCQEnabled = doc.createElement("beaconCQEnabled"); - beaconCQEnabled.setTextContent(this.isBcn_beaconsEnabledMainCat()+""); + beaconCQEnabled.setTextContent("false"); beaconCQ.appendChild(beaconCQEnabled); Element bcn_beaconsEnabledMainCat = doc.createElement("bcn_beaconsEnabledMainCat"); - bcn_beaconsEnabledMainCat.setTextContent(this.isBcn_beaconsEnabledMainCat()+""); + bcn_beaconsEnabledMainCat.setTextContent("false"); beaconCQ.appendChild(bcn_beaconsEnabledMainCat); @@ -2223,11 +2224,11 @@ public class ChatPreferences { beaconCQ.appendChild(bcn_beaconIntervalInMinutesSecondCat); Element beaconCQEnabledSecondCat = doc.createElement("beaconCQEnabledSecondCat"); - beaconCQEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+""); + beaconCQEnabledSecondCat.setTextContent("false"); beaconCQ.appendChild(beaconCQEnabledSecondCat); Element bcn_beaconsEnabledSecondCat = doc.createElement("bcn_beaconsEnabledSecondCat"); - bcn_beaconsEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+""); + bcn_beaconsEnabledSecondCat.setTextContent("false"); beaconCQ.appendChild(bcn_beaconsEnabledSecondCat); /** @@ -3178,12 +3179,18 @@ public class ChatPreferences { Element beaconCQEl = getFirstElement(doc, "beaconCQ"); if (beaconCQEl != null) { - setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled")); - bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes"); + setBcn_beaconsEnabledMainCat(false); + bcn_beaconIntervalInMinutesMainCat = Math.max( + MIN_BEACON_INTERVAL_MINUTES, + getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes") + ); bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText"); - setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat")); - bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat"); + setBcn_beaconsEnabledSecondCat(false); + bcn_beaconIntervalInMinutesSecondCat = Math.max( + MIN_BEACON_INTERVAL_MINUTES, + getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat") + ); bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText"); System.out.println("[ChatPreferences, info]: beaconCQ main='" + bcn_beaconTextMainCat + "', second='" + bcn_beaconTextSecondCat + "'"); diff --git a/core/src/main/java/kst4contest/model/ThreadStateMessage.java b/core/src/main/java/kst4contest/model/ThreadStateMessage.java index 0d2ed890..55df1e28 100644 --- a/core/src/main/java/kst4contest/model/ThreadStateMessage.java +++ b/core/src/main/java/kst4contest/model/ThreadStateMessage.java @@ -14,6 +14,7 @@ public class ThreadStateMessage { boolean criticalState; String criticalStateFurtherInfo; + BeaconStatusSnapshot beaconStatusSnapshot; @@ -99,5 +100,13 @@ public class ThreadStateMessage { public void setCriticalStateFurtherInfo(String criticalStateFurtherInfo) { this.criticalStateFurtherInfo = criticalStateFurtherInfo; } + + public BeaconStatusSnapshot getBeaconStatusSnapshot() { + return beaconStatusSnapshot; + } + + public void setBeaconStatusSnapshot(BeaconStatusSnapshot beaconStatusSnapshot) { + this.beaconStatusSnapshot = beaconStatusSnapshot; + } } diff --git a/core/src/main/resources/praktiKSTpreferences.xml b/core/src/main/resources/praktiKSTpreferences.xml index db8d900e..b9dd52f8 100644 --- a/core/src/main/resources/praktiKSTpreferences.xml +++ b/core/src/main/resources/praktiKSTpreferences.xml @@ -112,10 +112,10 @@ _.~'"´"'~.__.~'´ CQ CQ CQ NAC de MYCALL, pse try 144.317 for JO54 `'~.__.~'"`"'~._ - 1 + 5 false _.~'"´"'~.__.~'´ CQ CQ CQ de MYCALL, pse sked or SECONDQRG `'~.__.~'"`"'~._ - 3 + 5 false diff --git a/core/src/test/java/kst4contest/controller/AutomaticChatMessageFormatterTest.java b/core/src/test/java/kst4contest/controller/AutomaticChatMessageFormatterTest.java new file mode 100644 index 00000000..b3cd86e1 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/AutomaticChatMessageFormatterTest.java @@ -0,0 +1,27 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** Verifies the fixed suffix contract for automatic chat messages. */ +class AutomaticChatMessageFormatterTest { + + @Test + void appendsTheExactAutomaticChatSuffix() { + assertEquals( + "CQ test *a*", + AutomaticChatMessageFormatter.appendSuffix("CQ test"), + "the suffix must include its leading space" + ); + } + + @Test + void appendsTheSuffixEvenWhenTheTextAlreadyEndsWithIt() { + assertEquals( + "CQ test *a* *a*", + AutomaticChatMessageFormatter.appendSuffix("CQ test *a*"), + "an existing suffix must not be deduplicated" + ); + } +} diff --git a/core/src/test/java/kst4contest/controller/BeaconControlTest.java b/core/src/test/java/kst4contest/controller/BeaconControlTest.java index df03b31a..ab6761b6 100644 --- a/core/src/test/java/kst4contest/controller/BeaconControlTest.java +++ b/core/src/test/java/kst4contest/controller/BeaconControlTest.java @@ -36,8 +36,7 @@ class BeaconControlTest { @Test void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() { preferences.setLoginToSecondChatEnabled(false); - preferences.setBcn_beaconsEnabledMainCat(false); - preferences.setBcn_beaconsEnabledSecondCat(true); + controller.setSecondBeaconEnabled(true); controller.toggleUsableBeacons(); @@ -56,8 +55,6 @@ class BeaconControlTest { @Test void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() { preferences.setLoginToSecondChatEnabled(true); - preferences.setBcn_beaconsEnabledMainCat(false); - preferences.setBcn_beaconsEnabledSecondCat(false); controller.toggleUsableBeacons(); @@ -65,7 +62,7 @@ class BeaconControlTest { assertTrue(preferences.isBcn_beaconsEnabledSecondCat()); assertLastDescription("on"); - preferences.setBcn_beaconsEnabledSecondCat(false); + controller.setSecondBeaconEnabled(false); controller.toggleUsableBeacons(); assertFalse(preferences.isBcn_beaconsEnabledMainCat()); @@ -76,8 +73,6 @@ class BeaconControlTest { @Test void anIndividualCheckboxPublishesThePartialStateImmediately() { preferences.setLoginToSecondChatEnabled(true); - preferences.setBcn_beaconsEnabledMainCat(false); - preferences.setBcn_beaconsEnabledSecondCat(false); controller.setMainBeaconEnabled(true); @@ -86,6 +81,47 @@ class BeaconControlTest { assertLastDescription("1/2"); } + @Test + void activationRejectsOnlyTheTemplateWhoseRequiredQrgIsMissing() { + preferences.setLoginToSecondChatEnabled(true); + preferences.setMYQRGSecondCat("1296.200"); + preferences.setBcn_beaconTextMainCat("CQ SECONDQRG"); + preferences.setBcn_beaconTextSecondCat("CQ MYQRG"); + + controller.toggleUsableBeacons(); + + assertTrue(preferences.isBcn_beaconsEnabledMainCat()); + assertFalse(preferences.isBcn_beaconsEnabledSecondCat()); + } + + @Test + void manualQrgRebasesBeforeAnAutomaticMoveIsCompared() { + preferences.setBcn_beaconTextMainCat("CQ"); + controller.setMainBeaconEnabled(true); + controller.applyManualQrg(RadioSlot.FIRST, "144.300"); + controller.applyExternalQrg(RadioSlot.FIRST, "144.305", 144_305_000L); + assertTrue(preferences.isBcn_beaconsEnabledMainCat()); + + controller.applyManualQrg(RadioSlot.FIRST, "144.310"); + controller.applyExternalQrg(RadioSlot.FIRST, "144.315.01", 144_315_010L); + + assertFalse(preferences.isBcn_beaconsEnabledMainCat()); + } + + @Test + void nonPositiveExternalQrgCannotOverwriteTheDisplayOrClearTheReference() { + preferences.setBcn_beaconTextMainCat("CQ"); + controller.applyManualQrg(RadioSlot.FIRST, "144.300"); + controller.setMainBeaconEnabled(true); + + controller.applyExternalQrg(RadioSlot.FIRST, "0", 0L); + + assertEquals("144.300", preferences.getMYQRGFirstCat().get()); + controller.applyExternalQrg(RadioSlot.FIRST, "144.306", 144_306_000L); + assertFalse(preferences.isBcn_beaconsEnabledMainCat(), + "the rejected packet must not clear the 144.300 reference"); + } + private void assertLastDescription(String expected) { ArgumentCaptor key = ArgumentCaptor.forClass(String.class); ArgumentCaptor message = ArgumentCaptor.forClass(ThreadStateMessage.class); diff --git a/core/src/test/java/kst4contest/controller/BeaconFrequencyParserTest.java b/core/src/test/java/kst4contest/controller/BeaconFrequencyParserTest.java new file mode 100644 index 00000000..7c3ab800 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/BeaconFrequencyParserTest.java @@ -0,0 +1,31 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class BeaconFrequencyParserTest { + + @Test + void parsesEveryCompleteQrgFormWithoutLosingHertz() { + assertHz("144.300", 144_300_000L); + assertHz("144,300", 144_300_000L); + assertHz("144.300.03", 144_300_030L); + assertHz("1296.123.000", 1_296_123_000L); + assertHz("144300", 144_300_000L); + assertHz("10368100", 10_368_100_000L); + } + + @Test + void rejectsIncompleteMalformedAndUnsupportedValues() { + for (String value : new String[] {null, "", " ", "210", "144 MHz", "0", "-144.300", + "999999999999999999999999999999"}) { + assertTrue(BeaconFrequencyParser.parseHz(value).isEmpty(), value); + } + } + + private static void assertHz(String value, long expected) { + assertEquals(expected, BeaconFrequencyParser.parseHz(value).orElseThrow()); + } +} diff --git a/core/src/test/java/kst4contest/controller/BeaconSessionStateTest.java b/core/src/test/java/kst4contest/controller/BeaconSessionStateTest.java new file mode 100644 index 00000000..ab53a935 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/BeaconSessionStateTest.java @@ -0,0 +1,136 @@ +package kst4contest.controller; + +import static kst4contest.controller.BeaconSessionState.Channel.MAIN; +import static kst4contest.controller.BeaconSessionState.Channel.SECOND; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import kst4contest.model.BeaconStatusSnapshot; +import kst4contest.model.BeaconStatusSnapshot.AggregateState; +import kst4contest.model.BeaconStatusSnapshot.InactiveReason; +import kst4contest.model.ChatPreferences; + +class BeaconSessionStateTest { + @TempDir Path tempDir; + private ChatPreferences preferences; + private BeaconSessionState state; + + @BeforeEach + void setUp() { + preferences = new ChatPreferences(tempDir.resolve("preferences.xml")); + preferences.setLoginToSecondChatEnabled(true); + preferences.setBcn_beaconTextMainCat("CQ MAIN"); + preferences.setBcn_beaconTextSecondCat("CQ SECOND"); + preferences.setMYQRGFirstCat(""); + preferences.setMYQRGSecondCat(""); + state = new BeaconSessionState(); + state.resetSession(preferences); + } + + @Test + void resetStartsBothCategoriesOffWithSessionReason() { + BeaconStatusSnapshot snapshot = state.snapshot(preferences); + assertEquals(AggregateState.OFF, snapshot.getAggregateState()); + assertEquals(InactiveReason.NOT_ACTIVATED_THIS_SESSION, snapshot.getMain().getInactiveReason()); + assertEquals(InactiveReason.NOT_ACTIVATED_THIS_SESSION, snapshot.getSecond().getInactiveReason()); + assertFalse(preferences.isBcn_beaconsEnabledMainCat()); + assertFalse(preferences.isBcn_beaconsEnabledSecondCat()); + } + + @Test + void enablesValidCategoryAndRejectsOtherCategoryWithMissingCrossTemplateQrg() { + preferences.setMYQRGSecondCat("1296.123"); + preferences.setBcn_beaconTextMainCat("CQ SECONDQRG"); + preferences.setBcn_beaconTextSecondCat("CQ MYQRG"); + + assertTrue(state.setEnabled(MAIN, true, preferences)); + assertFalse(state.setEnabled(SECOND, true, preferences)); + + BeaconStatusSnapshot snapshot = state.snapshot(preferences); + assertEquals(AggregateState.PARTIAL, snapshot.getAggregateState()); + assertTrue(snapshot.getMain().isEnabled()); + assertEquals(InactiveReason.PRIMARY_QRG_UNAVAILABLE, snapshot.getSecond().getInactiveReason()); + } + + @Test + void manualQrgRebasesAndInvalidValueClearsReferenceWithoutPlaceholder() { + assertTrue(state.setEnabled(MAIN, true, preferences)); + state.onManualQrgChanged(MAIN, "144.300", preferences); + state.onExternalQrgChanged(MAIN, 144_306_000L, preferences); + assertFalse(state.isEnabled(MAIN)); + + assertTrue(state.setEnabled(MAIN, true, preferences)); + state.onManualQrgChanged(MAIN, "invalid", preferences); + assertTrue(state.isEnabled(MAIN)); + state.onExternalQrgChanged(MAIN, 144_310_000L, preferences); + assertTrue(state.isEnabled(MAIN), "first valid report establishes the cleared reference"); + } + + @Test + void invalidManualQrgDisablesEveryActiveTemplateThatNeedsIt() { + preferences.setMYQRGFirstCat("144.300"); + preferences.setBcn_beaconTextMainCat("CQ MYQRG"); + preferences.setBcn_beaconTextSecondCat("CQ MYQRGSHORT"); + assertTrue(state.setEnabled(MAIN, true, preferences)); + assertTrue(state.setEnabled(SECOND, true, preferences)); + + state.onManualQrgChanged(MAIN, "", preferences); + + assertFalse(state.isEnabled(MAIN)); + assertFalse(state.isEnabled(SECOND)); + assertEquals(InactiveReason.PRIMARY_QRG_UNAVAILABLE, + state.snapshot(preferences).getSecond().getInactiveReason()); + } + + @Test + void externalMoveUsesStrictFiveKhzBoundaryAndOnlyItsCategory() { + preferences.setMYQRGFirstCat("144.300"); + preferences.setMYQRGSecondCat("1296.100"); + assertTrue(state.setEnabled(MAIN, true, preferences)); + assertTrue(state.setEnabled(SECOND, true, preferences)); + + state.onExternalQrgChanged(MAIN, 144_305_000L, preferences); + assertTrue(state.isEnabled(MAIN)); + state.onExternalQrgChanged(MAIN, 144_305_001L, preferences); + + assertFalse(state.isEnabled(MAIN)); + assertTrue(state.isEnabled(SECOND)); + assertEquals(InactiveReason.AUTOMATIC_QRG_CHANGE, + state.snapshot(preferences).getMain().getInactiveReason()); + } + + @Test + void invalidExternalQrgClearsTheReferenceWithoutAPlaceholder() { + assertTrue(state.setEnabled(MAIN, true, preferences)); + preferences.setMYQRGFirstCat("7.100.00"); + state.onExternalQrgChanged(MAIN, 7_100_000L, preferences); + assertTrue(state.isEnabled(MAIN)); + + preferences.setMYQRGFirstCat("144.300"); + state.onExternalQrgChanged(MAIN, 144_300_000L, preferences); + + assertTrue(state.isEnabled(MAIN), "the first valid report must establish the reference"); + } + + @Test + void failureOverridesAggregateAndA_successClearsOnlyThatCategory() { + assertTrue(state.setEnabled(MAIN, true, preferences)); + assertTrue(state.setEnabled(SECOND, true, preferences)); + state.recordSendFailure(MAIN, true); + state.recordSendFailure(SECOND, true); + assertEquals(AggregateState.FAILED, state.snapshot(preferences).getAggregateState()); + + state.recordSendFailure(MAIN, false); + BeaconStatusSnapshot snapshot = state.snapshot(preferences); + assertEquals(AggregateState.FAILED, snapshot.getAggregateState()); + assertFalse(snapshot.getMain().isFailed()); + assertTrue(snapshot.getSecond().isFailed()); + } +} diff --git a/core/src/test/java/kst4contest/controller/BeaconTaskTest.java b/core/src/test/java/kst4contest/controller/BeaconTaskTest.java index 5da77b27..d2934366 100644 --- a/core/src/test/java/kst4contest/controller/BeaconTaskTest.java +++ b/core/src/test/java/kst4contest/controller/BeaconTaskTest.java @@ -65,8 +65,9 @@ class BeaconTaskTest { @Test void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() { - preferences.setBcn_beaconsEnabledMainCat(true); + controller.setMainBeaconEnabled(true); preferences.setBcn_beaconTextMainCat("invalid|template"); + statuses.clear(); task.run(); @@ -80,8 +81,9 @@ class BeaconTaskTest { @Test void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() { - preferences.setBcn_beaconsEnabledMainCat(true); + controller.setMainBeaconEnabled(true); preferences.setBcn_beaconTextMainCat("invalid|template"); + statuses.clear(); task.run(); statuses.clear(); @@ -93,6 +95,29 @@ class BeaconTaskTest { assertTrue(status.isCriticalState()); } + @Test + void unavailableCategoryRetainsFailureUntilItsNextSuccessfulRun() { + controller.setSecondBeaconEnabled(true); + preferences.setBcn_beaconTextSecondCat("invalid|template"); + task.run(); + assertTrue(controller.currentBeaconStatus() + .getBeaconStatusSnapshot().getSecond().isFailed()); + + preferences.setLoginToSecondChatEnabled(false); + task.run(); + assertTrue(controller.currentBeaconStatus() + .getBeaconStatusSnapshot().getSecond().isFailed(), + "an unavailable run did not succeed and must not clear the failure"); + + preferences.setLoginToSecondChatEnabled(true); + assertEquals("FAILED", controller.currentBeaconStatus() + .getRunningInformationTextDescription()); + preferences.setBcn_beaconTextSecondCat("CQ SECOND"); + task.run(); + assertFalse(controller.currentBeaconStatus() + .getBeaconStatusSnapshot().getSecond().isFailed()); + } + @Test void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() { task.run(); @@ -100,16 +125,22 @@ class BeaconTaskTest { assertAggregateStatus("off", false); statuses.clear(); - preferences.setBcn_beaconsEnabledMainCat(true); + controller.setMainBeaconEnabled(true); + statuses.clear(); task.run(); assertEquals(1, txBus.size()); assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber()); + assertEquals("CQ MAIN *a*", txBus.peek().getMessageText()); assertAggregateStatus("1/2", true); statuses.clear(); - preferences.setBcn_beaconsEnabledSecondCat(true); + controller.setSecondBeaconEnabled(true); + statuses.clear(); task.run(); assertEquals(3, txBus.size()); + List sentMessages = new ArrayList<>(txBus); + assertEquals("CQ MAIN *a*", sentMessages.get(1).getMessageText()); + assertEquals("CQ SECOND *a*", sentMessages.get(2).getMessageText()); assertAggregateStatus("on", true); } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java index f88568d7..fa611bc8 100644 --- a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java @@ -116,7 +116,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest { @Test void buildsTheQrgReply() { assertEquals( - "/CQ DL1ABC " + MARKER + " QRG is: 144.300", + "/CQ DL1ABC " + MARKER + " QRG is: 144.300 *a*", MessageBusManagementThread.buildAutoAnswerMessageText( "DL1ABC", VUHF, true, " 144.300 ", false, null)); } @@ -124,7 +124,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest { @Test void qrgRequestWinsOverTheGenericReply() { assertEquals( - "/CQ DL1ABC " + MARKER + " QRG is: 144.300", + "/CQ DL1ABC " + MARKER + " QRG is: 144.300 *a*", MessageBusManagementThread.buildAutoAnswerMessageText( "DL1ABC", VUHF, true, "144.300", true, "not qrv")); } @@ -143,7 +143,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest { @Test void buildsTheGenericReplyTrimmed() { assertEquals( - "/CQ DL1ABC " + MARKER + " not qrv, back at 18z", + "/CQ DL1ABC " + MARKER + " not qrv, back at 18z *a*", MessageBusManagementThread.buildAutoAnswerMessageText( "DL1ABC", VUHF, false, "144.300", true, " not qrv, back at 18z ")); } @@ -169,7 +169,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest { @Test void senderCallSignIsTrimmedButNotUpperCased() { assertEquals( - "/CQ dl1abc " + MARKER + " QRG is: 144.300", + "/CQ dl1abc " + MARKER + " QRG is: 144.300 *a*", MessageBusManagementThread.buildAutoAnswerMessageText( " dl1abc ", VUHF, true, "144.300", false, null)); } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java index cc8e25f1..26a5a6e9 100644 --- a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java @@ -104,7 +104,7 @@ class MessageBusManagementThreadAutoAnswerTest { ChatMessage reply = replies.get(0); assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX - + " QRG is: " + FIRST_QRG, + + " QRG is: " + FIRST_QRG + " *a*", reply.getMessageText()); assertEquals(MY_CALL, reply.getSender().getCallSign()); assertEquals(REMOTE_CALL, reply.getReceiver().getCallSign()); @@ -152,7 +152,7 @@ class MessageBusManagementThreadAutoAnswerTest { "another station and another category each get their own reply"); assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX - + " QRG is: " + SECOND_QRG, + + " QRG is: " + SECOND_QRG + " *a*", replies.get(2).getMessageText(), "the second-category question is answered with the second QRG"); assertSame(secondCategory, replies.get(2).getChatCategory()); @@ -232,7 +232,7 @@ class MessageBusManagementThreadAutoAnswerTest { "the rejected reply must not have started the cooldown"); assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX - + " QRG is: " + FIRST_QRG, + + " QRG is: " + FIRST_QRG + " *a*", txBus.peek().getMessageText()); } @@ -248,7 +248,7 @@ class MessageBusManagementThreadAutoAnswerTest { assertEquals(1, txBus.size()); assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX - + " not qrv, back at 18z", + + " not qrv, back at 18z *a*", txBus.peek().getMessageText(), "the configured text is trimmed"); } @@ -265,7 +265,7 @@ class MessageBusManagementThreadAutoAnswerTest { assertEquals(1, txBus.size()); assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX - + " QRG is: " + FIRST_QRG, + + " QRG is: " + FIRST_QRG + " *a*", txBus.peek().getMessageText()); } @@ -282,7 +282,7 @@ class MessageBusManagementThreadAutoAnswerTest { assertEquals(1, txBus.size(), "the blank generic reply is dropped; the QRG reply still goes out"); - assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG)); + assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG + " *a*")); } @Test @@ -295,7 +295,7 @@ class MessageBusManagementThreadAutoAnswerTest { assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION - + " by DO5AMF", + + " by DO5AMF *a*", txBus.peek().getMessageText()); } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java index b25c8214..f522bb43 100644 --- a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java @@ -134,7 +134,7 @@ class MessageBusManagementThreadChatFrameTest { assertEquals( "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION - + " by DO5AMF", + + " by DO5AMF *a*", txBus.peek().getMessageText()); verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); } diff --git a/core/src/test/java/kst4contest/controller/On4KstProtocolTest.java b/core/src/test/java/kst4contest/controller/On4KstProtocolTest.java index 98b17906..79f07c64 100644 --- a/core/src/test/java/kst4contest/controller/On4KstProtocolTest.java +++ b/core/src/test/java/kst4contest/controller/On4KstProtocolTest.java @@ -89,13 +89,32 @@ class On4KstProtocolTest { ); assertEquals( - "calling cq at 144.300", + "calling cq at 144.300 *a*", controller.resolveAndValidateBeaconText( "calling cq at MYQRG" ) ); } + @Test + void keepsThe120CharacterPayloadAllowanceWithA124CharacterFinalBeacon() { + ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml")); + ChatController controller = new ChatController(); + controller.setChatPreferences(preferences); + String payload = "x".repeat(120); + + assertDoesNotThrow(() -> controller.validateBeaconTemplate(payload)); + + String finalMessage = controller.resolveAndValidateBeaconText(payload); + assertEquals(124, finalMessage.length()); + assertEquals(payload + " *a*", finalMessage); + + assertThrows( + IllegalArgumentException.class, + () -> controller.validateBeaconTemplate("x".repeat(121)) + ); + } + @Test void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() { ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml")); @@ -136,11 +155,8 @@ class On4KstProtocolTest { assertThrows( IllegalArgumentException.class, () -> controller.validateBeaconTemplate( - "x".repeat( - ChatController.MAX_BEACON_TEXT_LENGTH - + 1 - ) + "x".repeat(121) ) ); } -} \ No newline at end of file +} diff --git a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java index 63c900e1..2fe32bac 100644 --- a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java +++ b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java @@ -4,7 +4,10 @@ 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -105,6 +108,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true); SimpleValue qrg = new SimpleValue<>(""); when(preferences.getMYQRGFirstCat()).thenReturn(qrg); + stubExternalQrgWrites(qrg); CountDownLatch firstQrg = new CountDownLatch(1); CountDownLatch secondQrg = new CountDownLatch(1); qrg.addListener(value -> { @@ -230,6 +234,29 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { assertEquals("1296.200.00", markerQrg.get()); } + /** A malformed Freq is expected external input and must be ignored without the safety net. */ + @Test + void malformedRadioInfoFrequencyIsSkippedWithoutAnUnexpectedError(@TempDir Path tempDir) throws Exception { + givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + + sendThenAwaitMarker("not-a-numberCW"); + + assertEquals("1296.200.00", markerQrg.get()); + assertFalse(statusMessages.stream() + .anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error"))); + } + + @Test + void nonPositiveRadioInfoFrequencyIsIgnoredBeforeItReachesTheController(@TempDir Path tempDir) + throws Exception { + givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + + sendThenAwaitMarker("0CW"); + + verify(controller, never()).applyExternalQrg(any(RadioSlot.class), eq("0"), eq(0L)); + assertEquals("1296.200.00", markerQrg.get()); + } + /** * Any runtime exception while processing one packet drops that packet, * reports a non-critical status and leaves the receive loop running. @@ -275,6 +302,16 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher()); when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true); when(preferences.getMYQRGFirstCat()).thenReturn(markerQrg); + stubExternalQrgWrites(markerQrg); + } + + private void stubExternalQrgWrites(SimpleValue firstQrg) { + doAnswer(invocation -> { + RadioSlot slot = invocation.getArgument(0); + String value = invocation.getArgument(1); + (slot == RadioSlot.FIRST ? firstQrg : secondQrg).set(value); + return null; + }).when(controller).applyExternalQrg(any(RadioSlot.class), anyString(), anyLong()); } /** diff --git a/core/src/test/java/kst4contest/controller/WinTestStatusSlotTest.java b/core/src/test/java/kst4contest/controller/WinTestStatusSlotTest.java index d55b5e12..949fba00 100644 --- a/core/src/test/java/kst4contest/controller/WinTestStatusSlotTest.java +++ b/core/src/test/java/kst4contest/controller/WinTestStatusSlotTest.java @@ -5,12 +5,10 @@ import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.SimpleValue; import org.junit.jupiter.api.Test; -import java.util.ArrayList; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** A Win-Test STATUS of the second station writes SECONDQRG when SO2R is on. */ @@ -18,18 +16,10 @@ class WinTestStatusSlotTest { /* STATUS layout as parseStatus reads it: token 1 station, 5 mode, 7 frequency in 100 Hz. */ private static final String STATUS_STN2 = "STATUS: \"STN2\" \"\" 0 12 0 0 4322000 0"; + private static final String STATUS_STN1 = "STATUS: \"STN1\" \"\" 0 12 0 0 1443000 0"; private final ChatController controller = mock(ChatController.class); private final ChatPreferences prefs = mock(ChatPreferences.class); - private final List first = new ArrayList<>(); - private final List second = new ArrayList<>(); - - private static SimpleValue recording(List writes) { - SimpleValue value = new SimpleValue<>(""); - value.addListener(writes::add); - return value; - } - private ReadUDPByWintestThread thread(boolean so2r) { when(controller.getChatPreferences()).thenReturn(prefs); when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher()); @@ -37,8 +27,6 @@ class WinTestStatusSlotTest { when(prefs.isTrxSynch_so2rEnabled()).thenReturn(so2r); when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()).thenReturn("STN1"); when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2()).thenReturn("STN2"); - when(prefs.getMYQRGFirstCat()).thenReturn(recording(first)); - when(prefs.getMYQRGSecondCat()).thenReturn(recording(second)); return new ReadUDPByWintestThread(controller, mock(ThreadStatusCallback.class)); } @@ -46,15 +34,21 @@ class WinTestStatusSlotTest { void withSo2rTheSecondStationWritesTheSecondQrg() { thread(true).parseStatus(STATUS_STN2); - assertEquals(1, second.size()); - assertTrue(first.isEmpty()); + verify(controller).applyExternalQrg(RadioSlot.SECOND, "432.200.00", 432_200_000L); } @Test void withoutSo2rTheSecondStationIsFilteredOutAsBefore() { thread(false).parseStatus(STATUS_STN2); - assertTrue(first.isEmpty()); - assertTrue(second.isEmpty()); + org.mockito.Mockito.verify(controller, org.mockito.Mockito.never()) + .applyExternalQrg(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void withoutSo2rTheFirstStationUpdatesOnlyTheMainSlotWithExactHz() { + thread(false).parseStatus(STATUS_STN1); + + verify(controller).applyExternalQrg(RadioSlot.FIRST, "144.300.00", 144_300_000L); } } diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java index 8699df14..0091472b 100644 --- a/core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java +++ b/core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Path; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; @@ -24,31 +25,60 @@ class ChatPreferencesBeaconStateTest { preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add); preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add); + preferences.setBcn_beaconsEnabledMainCat(true); 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, false), mainChanges); assertEquals(List.of(true), secondChanges); } @Test - void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() { + void activationIsSessionOnlyAndEveryFullWriteStoresFalse() throws Exception { ChatPreferences preferences = preferences("persistence.xml"); - preferences.setBcn_beaconsEnabledMainCat(false); + preferences.setBcn_beaconsEnabledMainCat(true); preferences.setBcn_beaconsEnabledSecondCat(true); assertTrue(preferences.writePreferencesToXmlFile()); + String xml = Files.readString(tempDir.resolve("persistence.xml")); + assertTrue(xml.contains("false")); + assertTrue(xml.contains("false")); + assertTrue(xml.contains("false")); + assertTrue(xml.contains("false")); + preferences.setBcn_beaconsEnabledMainCat(true); - preferences.setBcn_beaconsEnabledSecondCat(false); + preferences.setBcn_beaconsEnabledSecondCat(true); assertTrue(preferences.writeLayoutPreferencesToXmlFile()); ChatPreferences restored = preferences("persistence.xml"); restored.readPreferencesFromXmlFile(); assertFalse(restored.isBcn_beaconsEnabledMainCat()); - assertTrue(restored.isBcn_beaconsEnabledSecondCat()); + assertFalse(restored.isBcn_beaconsEnabledSecondCat()); + } + + @Test + void storedActivationIsIgnoredAndIntervalsBelowFiveAreNormalized() throws Exception { + Path file = tempDir.resolve("legacy.xml"); + Files.writeString(file, """ + + + true + true + 1 + 9 + + """); + + ChatPreferences restored = new ChatPreferences(file); + assertTrue(restored.readPreferencesFromXmlFile()); + + assertFalse(restored.isBcn_beaconsEnabledMainCat()); + assertFalse(restored.isBcn_beaconsEnabledSecondCat()); + assertEquals(5, restored.getBcn_beaconIntervalInMinutesMainCat()); + assertEquals(9, restored.getBcn_beaconIntervalInMinutesSecondCat()); } private ChatPreferences preferences(String fileName) { diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java index 89575b18..ecd93b0b 100644 --- a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java +++ b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java @@ -64,6 +64,10 @@ class ChatPreferencesRoundTripTest { Map.entry("setMessageHandling_autoAnswerEnabledSecondCat", "Same deliberate mirroring as the second-category auto-answer text; the " + "main-category flag wins after every read"), + Map.entry("setBcn_beaconsEnabledMainCat", + "Session-only safety switch; every XML write stores false and every read starts false"), + Map.entry("setBcn_beaconsEnabledSecondCat", + "Session-only safety switch; every XML write stores false and every read starts false"), Map.entry("setBcn_contestScoreSum", "DEAD FIELD: declared with accessors only, no caller in core or app-desktop, " + "never written nor read") diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 7ad32ef6..b0b86689 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -215,10 +215,12 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ## Autoanswer / Beacon - Automated-message loops must be prevented. -- Cooldown/minimum-interval rules must be preserved. +- Every user-visible automatic chat message generated by KST4Contest ends with the exact suffix ` *a*`: generic and QRG-specific automatic replies, `//VER` replies and both category beacons. Automatic replies retain their `[KST4C Automsg]` prefix as the loop guard. The suffix is appended unconditionally after message construction or beacon variable resolution; manual messages and internal login, keepalive and control frames are unchanged. Beacon content remains limited to 120 characters before the suffix, making 124 characters the final transmitted maximum. +- Cooldown/minimum-interval rules must be preserved. The shared beacon interval has a hard minimum of five minutes; older stored values below five are normalized while loading. - 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. +- Both category beacons share one timer, while their message templates remain profile-scoped and independent. `BeaconSessionState` is the authoritative owner of activation, per-category QRG references, deactivation reasons and send failures. Activations are never restored from XML: both start off after program startup and every profile switch, remain unchanged during an ON4KST reconnect in the same application session, and are projected into the legacy observable preference booleans for Settings/UI compatibility. Full preference writes emit both legacy/current enable tags as `false`. +- Enabling validates QRG placeholders across either template: `MYQRG` and `MYQRGSHORT` require a valid primary QRG; `SECONDQRG` requires a valid secondary QRG. A template without a QRG placeholder may be enabled without a current QRG, and the first later valid own-category QRG establishes its reference. A valid manual QRG change always rebases the corresponding active beacon. A UCXLog or Win-Test update more than 5,000 Hz away from the reference disables only that category; exactly 5,000 Hz remains allowed. Without SO2R, logger updates reach the primary category only; with SO2R each radio/category is handled independently. A missing/invalid required QRG disables an active beacon, while an invalid own QRG without a QRG placeholder only clears its reference. +- `MyBeacon` owns one structured aggregate thread-status snapshot for both categories (`off`, `1/2`, `on`, or `FAILED`). Its action disables all currently usable beacons if any is enabled, otherwise it attempts to enable each usable beacon independently, so one invalid category can remain off. The tooltip derives the state or last general deactivation reason for each category from the same snapshot. Active states use the dedicated green treatment with a 200 ms orange activity flash; `off` keeps a neutral surface with red text; `FAILED` uses a persistent red surface with white text and suppresses the activity flash. Other thread-status chips keep their existing styling. - Current implementation/tests define the exact message markers and timer details. ## Build / Verification @@ -417,10 +419,21 @@ Before implementing planned items, re-check current decisions and obtain a fresh - Historical project context is useful but may be stale; current code/tests win. - External service/API behaviour must be verified against current upstream documentation when uncertain. - Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them. +- `client_settings_window_beacon.png` was recaptured from the current Compose UI in a live application session on 2026-11-13. It shows both beacons disabled after a profile switch and the five-minute minimum interval. - `station_map_path_analysis.png` and `station_map_reset.png` predate the current header controls, callsign-only markers, shared station context menu and Worked-square overlay. The former remains useful only where the visible analysis panel itself is the subject. `station_map_spatial_filters.png` is the current general map screenshot shared by the DE/EN interface manuals and the website through `/manual/assets/`. ## Recent Significant Changes +### 2026-11-13 – Automatic chat messages marked with a suffix + +- Generic and QRG-specific automatic replies, `//VER` replies and both beacons now carry the exact ` *a*` suffix. Existing auto-reply loop protection remains based on `[KST4C Automsg]`; manual chat and ON4KST control traffic are unchanged. +- Beacon templates and resolved payloads retain the 120-character limit. The fixed suffix is added afterwards, so a transmitted beacon may contain up to 124 characters. + +### 2026-11-12 – Beacon activation and QRG safety made session-scoped + +- Beacon activation is now explicit per application session, while ordinary ON4KST reconnects preserve it. Per-category references distinguish deliberate manual QRG changes from UCXLog/Win-Test moves and disable only the affected beacon after a change greater than 5 kHz. +- The structured aggregate beacon status keeps per-category deactivation reasons and drives the dedicated green/orange/red `MyBeacon` chip without changing other thread-status chips. + ### 2026-10-17 – Repository moved to the HamRadioOnline organisation - The GitHub repository moved from `praktimarc/kst4contest` to `HamRadioOnline/kst4contest`. GitHub redirects the old repository, Pages, API and git URLs, so published links and release assets from before the move keep working. diff --git a/docs/superpowers/plans/2026-11-11-beacon-session-safety.md b/docs/superpowers/plans/2026-11-11-beacon-session-safety.md new file mode 100644 index 00000000..007fcd3b --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-beacon-session-safety.md @@ -0,0 +1,207 @@ +# Beacon Session Safety Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make beacon activation session-only, enforce a five-minute minimum, disable the correct category after an external QRG move beyond 5 kHz, and render a reason-aware traffic-colour `MyBeacon` chip. + +**Architecture:** A controller-owned `BeaconSessionState` coordinates the existing observable runtime flags, category references, inactive reasons and send failures. Manual QRG commits and UCXLog/Win-Test reports enter through distinct controller methods; a structured model snapshot crosses the existing thread-status boundary and `app-desktop` localises and styles it. + +**Tech Stack:** Java 21, Kotlin 2.2, Compose Multiplatform 1.8.x, JUnit 5, Mockito, Gradle wrapper + +**Spec:** `docs/superpowers/specs/2026-11-11-beacon-session-safety-design.md` + +## Global Constraints + +- Work directly on `main`, as explicitly authorised. +- Do not stage, commit, push, merge, tag, release or deploy; commit steps are deliberately omitted because Marc authorised none of them. +- Keep source comments and Javadoc in English; user-facing DE/EN text follows the project writing style. +- Use exactly 5 minutes and 5,000 Hz; exactly 5,000 Hz must not disable a beacon. +- Do not add a production dependency or change ON4KST/UCXLog/Win-Test framing. +- Keep the shared timer, initial ten-second check, outbound queue and second-chat eligibility unchanged. +- Do not execute UI callbacks while holding an internal beacon-state lock. +- Preserve unrelated local changes and inspect the diff before and after every task. + +## Review Focus + +- A secondary beacon using `MYQRG` must be refused or disabled when the primary QRG is invalid, even though its own reference remains the secondary QRG; Task 1 pins this cross-template case. +- An external report with no reference must establish the reference without disabling; Task 1 pins this first-report case. +- Without SO2R a Win-Test/UCXLog report must affect only the main beacon; Task 3 pins both integrations. +- An old XML file with enabled flags and a one-minute interval must load as disabled with a five-minute interval and later write `false`; Task 2 pins the migration. +- `FAILED` must stay red without an orange flash while ordinary worker chips retain current theme colours; Task 4 pins both sides. + +--- + +### Task 1: Pure beacon session policy and structured status + +**Files:** +- Create: `core/src/main/java/kst4contest/controller/BeaconFrequencyParser.java` +- Create: `core/src/main/java/kst4contest/controller/BeaconSessionState.java` +- Create: `core/src/main/java/kst4contest/model/BeaconStatusSnapshot.java` +- Create: `core/src/test/java/kst4contest/controller/BeaconFrequencyParserTest.java` +- Create: `core/src/test/java/kst4contest/controller/BeaconSessionStateTest.java` + +**Interfaces:** +- Produces: `BeaconFrequencyParser.parseHz(String): OptionalLong`. +- Produces: `BeaconSessionState.Channel { MAIN, SECOND }` and synchronized policy methods `resetSession(ChatPreferences)`, `setEnabled(Channel, boolean, ChatPreferences)`, `onManualQrgChanged(Channel, String, ChatPreferences)`, `onExternalQrgChanged(Channel, long, ChatPreferences)`, `onTemplateChanged(Channel, ChatPreferences)`, `recordSendFailure(Channel, boolean)` and `snapshot(ChatPreferences): BeaconStatusSnapshot`. +- Produces: explicit `BeaconStatusSnapshot` DTO with aggregate state `OFF`, `PARTIAL`, `ON`, `FAILED`, plus main/second category DTOs carrying availability, enablement, failure and `InactiveReason`. + +- [ ] **Step 1: Write parser tests** for `144.300`, `144,300`, `144.300.03`, `1296.123.000`, compact `144300`, compact `10368100`, blanks, malformed text, non-positive values and overflow. +- [ ] **Step 2: Run the parser test and verify RED.** + + Run: `.\gradlew.bat :core:test --tests kst4contest.controller.BeaconFrequencyParserTest --console=plain` + + Expected: compilation failure because `BeaconFrequencyParser` does not exist. + +- [ ] **Step 3: Implement `BeaconFrequencyParser`** with integer arithmetic and the QRG forms already used by the client; accept only a complete positive frequency that maps to a supported `Band`. +- [ ] **Step 4: Re-run the parser test and verify GREEN.** +- [ ] **Step 5: Write session-policy tests** for initial reasons, independent enablement, placeholder requirements in either template, partial aggregate activation, manual reference creation/rebase, invalid manual clearing, cross-template invalidation, first external reference, 5,000/5,001-Hz boundaries, per-category external changes, reason retention and send-failure clearing. +- [ ] **Step 6: Run `BeaconSessionStateTest` and verify RED** because the policy and DTO are absent. +- [ ] **Step 7: Implement the DTO and policy** so the existing observable preference booleans are projection only and no listener/callback leaves a synchronized method. +- [ ] **Step 8: Re-run both focused tests and verify GREEN.** +- [ ] **Step 9: Inspect the task diff** for duplicated state, floating-point frequency comparisons or display strings in `core`. + +### Task 2: Session-only persistence and five-minute migration + +**Files:** +- Modify: `core/src/main/java/kst4contest/model/ChatPreferences.java` +- Modify: `core/src/main/java/kst4contest/controller/ChatController.java` +- Modify: `core/src/main/resources/praktiKSTpreferences.xml` +- Modify: `core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java` +- Modify: `core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/i18n/RemainingTextsFollowTheLanguageTest.kt` + +**Interfaces:** +- Consumes: Task 1 `BeaconSessionState`. +- Produces: `ChatController.MIN_BEACON_INTERVAL_MINUTES == 5` and session reset during controller/profile construction without reset on reconnect. + +- [ ] **Step 1: Replace persistence expectations with failing tests** proving defaults are off, stored `true` is ignored, every full write stores both legacy/current enable tags as `false`, layout-only writes do not revive them, and intervals below five normalise in memory. +- [ ] **Step 2: Add interval tests** proving four is refused, five accepted, and an old stored one becomes five without changing a larger value. +- [ ] **Step 3: Run the focused core and desktop tests and verify RED.** + + Run: `.\gradlew.bat :core:test --tests kst4contest.model.ChatPreferencesBeaconStateTest --tests kst4contest.model.ChatPreferencesRoundTripTest :app-desktop:test --tests kst4contest.view.compose.tabs.BeaconTabStateTest --tests kst4contest.view.i18n.RemainingTextsFollowTheLanguageTest --console=plain` + +- [ ] **Step 4: Make enable flags session-only**: initialise both false, ignore XML enable values, always serialise `false`, retain existing element names, and reset the policy only when a new controller/profile preferences object is installed. +- [ ] **Step 5: Set and enforce the five-minute minimum** in controller, XML defaults and preference-load normalisation; retain timer start/restart semantics. +- [ ] **Step 6: Re-run the focused tests and inspect their XML summaries.** +- [ ] **Step 7: Inspect the task diff** to confirm no reconnect path resets the session and no default was silently raised to ten minutes. + +### Task 3: Explicit manual and external QRG paths + +**Files:** +- Modify: `core/src/main/java/kst4contest/controller/ChatController.java` +- Modify: `core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java` +- Modify: `core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java` +- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt` +- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt` +- Modify: `app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java` +- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt` +- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt` +- Modify: focused fake `ChatInputActions` implementations in affected tests +- Modify: `core/src/test/java/kst4contest/controller/BeaconControlTest.java` +- Modify: `core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java` +- Modify: `core/src/test/java/kst4contest/controller/WinTestStatusSlotTest.java` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt` + +**Interfaces:** +- Produces: controller methods `applyManualQrg(RadioSlot, String)`, `applyExternalQrg(RadioSlot, String, long)`, `onMainBeaconTemplateApplied()` and `onSecondBeaconTemplateApplied()`. +- Produces: a `ChatInputActions`/`ChatInputState` manual-QRG commit method used by `ChatInputRow` instead of writing preferences directly. +- Consumes: Task 1 policy and Task 2 session lifecycle. + +- [ ] **Step 1: Add failing controller tests** for individual activation refusal, valid partial chip activation, user disable reason, manual rebase, invalid manual cross-template disabling and template-apply revalidation. +- [ ] **Step 2: Add failing chat-input tests** proving both manual fields call the explicit action with the correct `RadioSlot`/category and never write preferences directly. +- [ ] **Step 3: Add failing UCXLog tests** for exact Hz conversion, first-reference behaviour, >5-kHz main-only shutdown without SO2R and second-only shutdown for RadioNr 2 with SO2R. +- [ ] **Step 4: Add failing Win-Test tests** for main/pass frequency selection, 100-Hz units, non-SO2R main-only and SO2R slot routing. +- [ ] **Step 5: Run the four focused test groups and verify RED.** +- [ ] **Step 6: Implement the controller entry points** so the preference value and policy update occur together on the existing UI-dispatch boundary and publish a fresh beacon status only after state is consistent. +- [ ] **Step 7: Route manual Compose commits through `ComposeChatInputActions`** while preserving focus/commit behaviour and existing displayed `ObservedValue` mirrors. +- [ ] **Step 8: Give both template draft properties a shared post-apply effect key** that revalidates active beacons after all writes, without changing their validation-before-stage behaviour. +- [ ] **Step 9: Route UCXLog and Win-Test through `applyExternalQrg`** only when their current QRG-sync setting already permits the update; preserve packet parsing and diagnostics. +- [ ] **Step 10: Re-run focused tests and inspect XML summaries.** +- [ ] **Step 11: Run related integration tests** for SO2R routing, settings apply and chat-input rendering. +- [ ] **Step 12: Inspect the task diff** for protocol/framing changes, toolkit calls in `core`, or source inference through listeners. + +### Task 4: Beacon task status, translated tooltips and traffic colours + +**Files:** +- Modify: `core/src/main/java/kst4contest/controller/BeaconTask.java` +- Modify: `core/src/main/java/kst4contest/controller/BeaconStatus.java` +- Modify: `core/src/main/java/kst4contest/model/ThreadStateMessage.java` +- Create: `app-desktop/src/main/kotlin/kst4contest/view/compose/BeaconStatusPresentation.kt` +- 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/i18n/strings_de.properties` +- Modify: `app-desktop/src/main/i18n/strings_en.properties` +- Modify: `core/src/test/java/kst4contest/controller/BeaconTaskTest.java` +- Modify: `core/src/test/java/kst4contest/controller/BeaconControlTest.java` +- Create: `app-desktop/src/test/kotlin/kst4contest/view/compose/BeaconStatusPresentationTest.kt` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/StatusBarScreenTest.kt` +- Modify: relevant i18n boundary tests + +**Interfaces:** +- Consumes: Task 1 `BeaconStatusSnapshot` and policy send-failure methods. +- Produces: optional structured beacon snapshot on `ThreadStateMessage`; other thread messages remain unchanged. +- Produces: `BeaconStatusPresentation` mapping snapshot plus current strings to label, tooltip and visual style. + +- [ ] **Step 1: Add failing core tests** proving a failed active category yields `FAILED`, a later successful run clears only its failure, disabled categories are not failed, and ordinary timer runs preserve inactive reasons. +- [ ] **Step 2: Add failing presentation tests** for bilingual per-category tooltips, initial/user/QRG/QRG-invalid reasons, `1/2`, and no concrete frequency in the QRG reason. +- [ ] **Step 3: Add failing colour-model tests** for the exact light/dark OFF colours, ON/PARTIAL green, 200-ms orange highlight, FAILED red/white with highlight suppression and unchanged default-worker colours. +- [ ] **Step 4: Run focused core and desktop tests and verify RED.** +- [ ] **Step 5: Attach the structured snapshot to the existing status message** without adding translated display prose to `core`. +- [ ] **Step 6: Make `BeaconTask` record per-category outcomes** while retaining text resolution, category validation and regular TX queue insertion. +- [ ] **Step 7: Implement translated presentation and visual style** only for messages carrying a beacon snapshot; keep action/provider ordering and stale-update protection intact. +- [ ] **Step 8: Implement the selected colours in `ThreadStatusButtonView`** and suppress the highlight when style is FAILED. +- [ ] **Step 9: Add aligned DE/EN i18n keys** and re-run translation-boundary tests. +- [ ] **Step 10: Re-run focused tests and inspect XML summaries.** +- [ ] **Step 11: Run the affected Compose screen tests** and inspect a rendered light/dark result if the test harness emits one. +- [ ] **Step 12: Inspect the task diff** to ensure no non-beacon chip acquired special colours or click behaviour. + +### Task 5: Targeted documentation, context and full verification + +**Files:** +- Modify: `github_docs/de-Konfiguration.md` +- Modify: `github_docs/en-Configuration.md` +- Modify: `github_docs/de-Contest-Workflow.md` +- Modify: `github_docs/en-Contest-Workflow.md` +- Modify: `github_docs/de-Makros-und-Variablen.md` +- Modify: `github_docs/en-Macros-and-Variables.md` +- Review/modify: `github_docs/de-Funktionen.md`, `github_docs/en-Features.md` +- Review/modify: `github_docs/de-Benutzeroberflaeche.md`, `github_docs/en-User-Interface.md` +- Modify: `github_docs/de-Changelog.md` +- Modify: `github_docs/en-Changelog.md` +- Modify: `website/src/features/macros.md` +- Review/modify: `website/src/features/trx-qrg-synchronisation.md` +- Modify: `docs/PROJECT_CONTEXT.md` +- Modify: `docs/superpowers/specs/2026-11-04-live-beacon-control-design.md` +- Review: `github_docs/client_settings_window_beacon.png` + +**Interfaces:** +- Consumes: completed behaviour from Tasks 1-4. +- Produces: semantically aligned DE/EN user documentation and current durable project context. + +- [ ] **Step 1: Update the targeted manual sections** with session-only activation, five-minute minimum, activation prerequisites, source-specific reference/QSY rules, per-category tooltip and traffic colours. +- [ ] **Step 2: Update both changelogs compactly** without inventing a version or release state. +- [ ] **Step 3: Update only website sections that already describe interval or TRX-QRG behaviour**; do not duplicate the manual. +- [ ] **Step 4: Reconcile the older live-beacon design and `PROJECT_CONTEXT`** so they no longer claim activation persists or the one-minute minimum remains unchanged. +- [ ] **Step 5: Inspect the existing Beacon settings screenshot** and record replacement only if a visible value is now false; do not fabricate an image. +- [ ] **Step 6: Run all focused test classes from Tasks 1-4.** +- [ ] **Step 7: Run the full build.** + + Run: `.\gradlew.bat build --console=plain` + + Expected: `BUILD SUCCESSFUL` with no failed tests. + +- [ ] **Step 8: Read every generated `*/build/test-results/**/TEST-*.xml` summary** and report test/failure/error/skipped totals rather than relying only on exit code. +- [ ] **Step 9: Count PMD and SpotBugs XML findings per module** from `` and identify findings touching changed production files. +- [ ] **Step 10: Run `git diff --check` and inspect `git status --short`.** +- [ ] **Step 11: Review the complete diff against the approved spec** for unrelated cleanup, stale documentation, accidental protocol changes and user-authored changes. +- [ ] **Step 12: Perform a manual runtime check if safely possible**: initial off state, valid/invalid category activation, manual rebase, external >5-kHz shutdown, `1/2`, OFF tooltip, orange flash and persistent FAILED red. + +## Self-Review Result + +- Every approved behaviour maps to Tasks 1-4; documentation and durable context map to Task 5. +- Exact types and controller entry points are defined once in the Interfaces blocks. +- Cross-template QRG dependencies, first external reference, non-SO2R routing, XML migration and FAILED colour priority are explicit tests. +- Plan size reflects five independently reviewable deliverables; no implementation bodies or unauthorised Git actions are included. diff --git a/docs/superpowers/plans/2026-11-13-automatic-chat-suffix.md b/docs/superpowers/plans/2026-11-13-automatic-chat-suffix.md new file mode 100644 index 00000000..28cbff0e --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-automatic-chat-suffix.md @@ -0,0 +1,96 @@ +# Automatic Chat Suffix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Append the fixed suffix ` *a*` to every active KST4Contest autoanswer and beacon while leaving manual and protocol traffic unchanged. + +**Architecture:** A package-private `AutomaticChatMessageFormatter` owns the suffix and performs unconditional appending. The active autoanswer and beacon producers call it explicitly; beacon validation keeps a 120-character payload allowance and validates the resulting 124-character maximum. + +**Tech Stack:** Java 21, Kotlin 2.2, Gradle wrapper, JUnit 5, Mockito + +**Spec:** `docs/superpowers/specs/2026-11-13-automatic-chat-suffix-design.md` + +## Global Constraints + +- The exact suffix is ` *a*`, including its leading space. +- `[KST4C Automsg]` remains on autoanswers and remains the loop-prevention marker. +- Suffix insertion is unconditional; duplicate `*a*` markers are allowed. +- Beacon payloads remain limited to 120 characters; complete suffixed beacons are limited to 124 characters. +- Manual messages and internal ON4KST frames must remain byte-for-byte unaffected by this feature. +- No new dependency, setting, persistence field, version bump, staging, commit or publication action. + +## Review Focus + +- A 120-character beacon payload must be sent as exactly 124 characters after suffix insertion. +- A 121-character beacon payload must still be rejected rather than producing a 125-character frame. +- Text already ending in `*a*` must receive a second suffix without deduplication. +- Autoanswer validation and cooldown accounting must still reject invalid text before consuming cooldown. +- The existing prefix-based ping-pong guard and all manual send paths must remain unchanged. + +--- + +### Task 1: Shared automatic-message formatter and autoanswers + +**Files:** +- Create: `core/src/main/java/kst4contest/controller/AutomaticChatMessageFormatter.java` +- Create: `core/src/test/java/kst4contest/controller/AutomaticChatMessageFormatterTest.java` +- Modify: `core/src/main/java/kst4contest/controller/MessageBusManagementThread.java` +- Modify: `core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java` +- Modify: `core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java` +- Modify: `core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java` + +**Interfaces:** +- Produces: `AutomaticChatMessageFormatter.appendSuffix(String messageText): String` +- Consumes: existing `ApplicationConstants.AUTOANSWER_PREFIX` and `On4KstProtocol.messageText(String)` + +- [x] Add failing formatter tests for exact suffix insertion and intentional duplicate suffixes. +- [x] Run `\.\gradlew.bat :core:test --tests "kst4contest.controller.AutomaticChatMessageFormatterTest"` and confirm the tests fail because the formatter is absent. +- [x] Implement the package-private formatter with `SUFFIX = " *a*"` and unconditional appending. +- [x] Update failing autoanswer and version-reply expectations so generic, QRG and `//VER` replies end in ` *a*` while retaining `[KST4C Automsg]`. +- [x] Append before final autoanswer protocol validation and in the `//VER` construction path. +- [x] Run the formatter and three focused autoanswer test classes and confirm zero failures. + +### Task 2: Beacon suffix and length boundaries + +**Files:** +- Modify: `core/src/main/java/kst4contest/controller/ChatController.java` +- Modify: `core/src/main/java/kst4contest/controller/BeaconTask.java` +- Modify: `core/src/test/java/kst4contest/controller/On4KstProtocolTest.java` +- Modify: `core/src/test/java/kst4contest/controller/BeaconTaskTest.java` + +**Interfaces:** +- Consumes: `AutomaticChatMessageFormatter.appendSuffix(String)` from Task 1 +- Produces: beacon payload validation at 120 characters and final-message validation at 124 characters + +- [x] Add failing tests asserting that each queued beacon ends in ` *a*`, a 120-character payload produces a 124-character message, and a 121-character payload is rejected. +- [x] Run `\.\gradlew.bat :core:test --tests "kst4contest.controller.On4KstProtocolTest" --tests "kst4contest.controller.BeaconTaskTest"` and confirm the new assertions fail. +- [x] Separate the 120-character payload limit from the 124-character final beacon limit in `ChatController`. +- [x] Append the suffix only after macro resolution and payload validation, then validate the final message before queueing. +- [x] Run the focused beacon tests and confirm zero failures. + +### Task 3: Targeted documentation and verification + +**Files:** +- Modify: `github_docs/de-Konfiguration.md` +- Modify: `github_docs/en-Configuration.md` +- Modify: `github_docs/de-Makros-und-Variablen.md` +- Modify: `github_docs/en-Macros-and-Variables.md` +- Modify: `github_docs/de-Funktionen.md` +- Modify: `github_docs/en-Features.md` +- Modify: `github_docs/de-Changelog.md` +- Modify: `github_docs/en-Changelog.md` +- Modify: `website/src/features/automatic-replies.md` +- Modify: `website/src/features/macros.md` +- Modify: `docs/PROJECT_CONTEXT.md` + +**Interfaces:** +- Consumes: implemented suffix and length behaviour from Tasks 1 and 2 +- Produces: aligned German/English user documentation and durable current-state context + +- [x] Update examples and behavioural text so autoanswers retain `[KST4C Automsg]` and all automated chat messages end in ` *a*`. +- [x] Document the 120-character beacon payload and 124-character transmitted-message limits in German and English. +- [x] Record the fixed suffix rule in the changelogs and `docs/PROJECT_CONTEXT.md` without changing unrelated documentation. +- [x] Run the focused core tests from Tasks 1 and 2, then `\.\gradlew.bat build`. +- [x] Read every Gradle test-result XML summary and report failures, errors and skipped tests; inspect PMD and SpotBugs XML counts produced by the build. +- [x] Run the website test/build commands defined by the current `website/package.json` if the changed website pages participate in that build. +- [x] Run `git diff --check` and review the complete scoped diff for accidental changes; perform no Git publication action. diff --git a/docs/superpowers/specs/2026-11-04-live-beacon-control-design.md b/docs/superpowers/specs/2026-11-04-live-beacon-control-design.md index 6d38b0e2..5bd94409 100644 --- a/docs/superpowers/specs/2026-11-04-live-beacon-control-design.md +++ b/docs/superpowers/specs/2026-11-04-live-beacon-control-design.md @@ -2,6 +2,10 @@ Freigegeben von Marc am 2026-11-04. +> Durch das freigegebene [Beacon Session Safety Design](2026-11-11-beacon-session-safety-design.md) +> abgelöst, soweit Aktivierungspersistenz, Mindestintervall, QRG-Sicherung und Chip-Darstellung +> betroffen sind. + ## Ziel Die beiden Beacon-Aktivierungsschalter wirken in der laufenden Sitzung sofort. Der gemeinsame @@ -11,19 +15,19 @@ gemeinsam ein- oder ausschalten. ## Verbindliches Verhalten -- Die gespeicherten Beacon-Zustände werden beim Profilstart geladen. +- Nach einem Programmstart und Profilwechsel sind beide Beacon-Zustände ausgeschaltet. +- Ein normaler ON4KST-Reconnect innerhalb derselben Programmsitzung verändert sie nicht. - 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. +- Aktivierungszustände werden nicht in die Profil-XML übernommen; ein vollständiger + Einstellungsspeichervorgang schreibt die kompatiblen Aktivierungs-Tags als `false`. - 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. +- Der Status-Chip zeigt `off`, `1/2`, `on` oder `FAILED` 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. +- Bei deaktiviertem zweiten Chat schaltet der Chip nur die Hauptkategorie. - Chip und geöffnetes Settings-Fenster zeigen denselben beobachtbaren Zustand. - Nur der Beacon-Chip wird anklickbar. Andere Thread-Status-Chips bleiben reine Anzeigen. @@ -32,15 +36,15 @@ gemeinsam ein- oder ausschalten. - 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. +- Gemeinsamer Timer und Intervall-Persistenz bleiben unverändert; das Mindestintervall beträgt + fünf Minuten. - 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`. +`BeaconSessionState` ist die maßgebliche sitzungsbezogene Quelle für Aktivierung, +QRG-Referenzen, Abschaltgründe und Fehler. Die vorhandenen beobachtbaren +`ChatPreferences`-Werte bleiben die kompatible Projektion für Settings und UI. Controller und +`BeaconTask` veröffentlichen einen strukturierten `BeaconStatusSnapshot`, aus dem Chip-Text, +Tooltip und die Beacon-spezifische Farbgebung entstehen. Details der QRG-Prüfung und +Darstellung stehen im ablösenden Design vom 2026-11-11. diff --git a/docs/superpowers/specs/2026-11-11-beacon-session-safety-design.md b/docs/superpowers/specs/2026-11-11-beacon-session-safety-design.md new file mode 100644 index 00000000..58c6f117 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-beacon-session-safety-design.md @@ -0,0 +1,128 @@ +# Beacon Session Safety Design + +Freigegeben von Marc am 2026-11-11. + +## Ziel + +Die Beacon-Funktion darf nach einem Programmstart oder Profilwechsel nicht aus einer +gespeicherten Aktivierung weiterlaufen. Jeder Beacon wird in der laufenden Sitzung bewusst +eingeschaltet, hält seine eigene Kategorie-QRG als Referenz fest und schaltet nur bei einer +automatisch von UCXLog oder Win-Test gemeldeten Abweichung von mehr als 5 kHz ab. + +Der zusammengefasste `MyBeacon`-Chip macht Aktivierung, Teilaktivierung, automatische +Abschaltung und Versandfehler ohne Umweg über das Einstellungsfenster erkennbar. + +## Verbindliches Verhalten + +### Intervall und Sitzung + +- Das kleinste zulässige gemeinsame Beacon-Intervall beträgt fünf Minuten. +- Genau fünf Minuten sind zulässig. Kleinere geladene Werte werden im Speicher auf fünf + Minuten normalisiert; die mitgelieferte Konfiguration verwendet für beide Kategorien fünf. +- Nach jedem Programmstart und Profilwechsel sind beide Beacons ausgeschaltet. +- Ein Disconnect oder Reconnect innerhalb derselben Programmsitzung verändert ihre + Aktivierung nicht. +- Die Aktivierungsflags werden nicht aus XML gelesen. Die vorhandenen XML-Elemente bleiben + für die Dateikompatibilität erhalten, werden aber immer mit `false` geschrieben. + +### Aktivierung und benötigte QRG-Werte + +- `MYQRG` und `MYQRGSHORT` benötigen eine gültige Haupt-QRG. +- `SECONDQRG` benötigt eine gültige zweite QRG. +- Diese Voraussetzung gilt unabhängig davon, in welchem der beiden Bakentexte die Variable + vorkommt. +- Fehlt ein benötigter Wert oder ist er ungültig, bleibt der betroffene Beacon aus. Sein + kategorieweiser Status nennt die fehlende oder ungültige QRG als Grund. +- Der gemeinsame Chip aktiviert beide nutzbaren Kategorien unabhängig. Ein gültiger Beacon + wird eingeschaltet, auch wenn der andere abgelehnt wird; der Chip zeigt dann `1/2`. +- Ein Text ohne QRG-Platzhalter darf ohne bekannte QRG aktiviert werden. +- Geprüft wird der bereits angewendete Text, nicht ein noch offener Settings-Entwurf. + +### Kategoriebezogene Referenz-QRG + +- Beim Einschalten übernimmt jeder Beacon die aktuell gültige QRG seiner eigenen Kategorie + als Referenz. +- Ist keine gültige Kategorie-QRG vorhanden und benötigt der Text sie nicht, wird die erste + später gültige manuelle oder externe QRG zur Referenz. +- Eine gültige manuelle QRG-Eingabe ist eine bewusste Operatorentscheidung. Sie deaktiviert + keinen Beacon und verschiebt die Referenz der zugehörigen Kategorie. +- Eine ungültige manuelle Eingabe deaktiviert jeden aktiven Beacon, dessen angewendeter Text + den betroffenen QRG-Wert benötigt. Benötigt sein Text ihn nicht, bleibt der Beacon aktiv; + nur die Referenz seiner Kategorie wird gelöscht. +- Ausschließlich QRG-Übernahmen von UCXLog `RadioInfo` und Win-Test `STATUS`, für die der + bestehende QRG-Abgleich aktiv ist, dürfen automatisch abschalten. +- Der Vergleich erfolgt in ganzzahligen Hz gegen die aktuelle Referenz. Genau 5.000 Hz sind + zulässig; erst eine größere Abweichung schaltet ab. +- Ohne Referenz setzt die erste externe QRG nur die Referenz und schaltet nicht ab. +- Ohne SO2R betrifft eine externe Änderung von `MYQRG` ausschließlich die Hauptbake. Mit + SO2R folgt Radio 1 `MYQRG` und Radio 2 `SECONDQRG`. + +### Status und Tooltip + +- Der Status wird als strukturierter Snapshot aus `core` geliefert. Übersetzte Anzeigetexte + entstehen ausschließlich in `app-desktop`. +- Der Snapshot kennt `OFF`, `PARTIAL`, `ON` und `FAILED` sowie den Zustand beider Kategorien. +- Der Tooltip zeigt je Kategorie den aktuellen Zustand oder letzten Abschalt-/Ablehnungsgrund: + noch nicht in dieser Sitzung aktiviert, durch den Benutzer ausgeschaltet, fehlende oder + ungültige Haupt-QRG, fehlende oder ungültige zweite QRG oder automatische QRG-Abweichung + von mehr als 5 kHz. +- Der QRG-Grund nennt keine konkreten Frequenzen. +- Timeraktivität überschreibt den kategorieweisen Grund nicht. +- Ein erfolgreicher späterer Lauf löscht einen früheren Versandfehler der Kategorie. + +### Verkehrsfarben des `MyBeacon`-Chips + +- `on` und `1/2`: Hintergrund `#238636`, Rand `#56D364`, weiße Schrift. +- Der bestehende 200-ms-Aktivitätsblitz verwendet Hintergrund `#FFB300`, Rand `#FFE082` + und Schrift `#1B1B1B`. +- `off`: neutraler Theme-Hintergrund mit `#D50000` im hellen beziehungsweise `#FF6B6B` im + dunklen Design. +- `FAILED`: dauerhaft `#D50000` mit Rand `#FF6B6B` und weißer Schrift. +- `FAILED` unterdrückt den orangefarbenen Aktivitätsblitz. +- Andere Status-Chips behalten ihr heutiges Theme-Verhalten. + +## Technischer Ansatz + +Eine controllerseitige `BeaconSessionState`-Komponente koordiniert den Zustand beider +Kategorien. Die beobachtbaren Flags in `ChatPreferences` bleiben kompatible UI-Projektion, +aber kein persistenter Produktzustand. Die Komponente verwaltet Referenzen, kategorieweise +Gründe und Versandfehler und ist der einzige Produktionsschreibpfad der Aktivierungsflags. + +Manuelle QRG-Eingaben und externe Logger-Meldungen verwenden getrennte Controller-Einstiege. +UCXLog und Win-Test übergeben die bereits ermittelte Kategorie, den formatierten Anzeigewert +und die genaue Frequenz in Hz innerhalb der bestehenden `UiDispatcher`-Grenze. Damit ist die +Herkunft explizit; Listener, Thread-lokale Markierungen oder erratene Ursprünge sind unnötig. + +Der vorhandene `BeaconTask` bleibt für Auflösung, Validierung und Einreihung in die reguläre +TX-Warteschlange verantwortlich. Er meldet je Kategorie Erfolg oder Fehler an den +Sitzungszustand zurück. Ein DTO im gemeinsamen Model transportiert den strukturierten Status +über den bestehenden Threadstatus-Kanal; Compose übersetzt Gründe und wählt die Chipfarben. + +## Unveränderte Grenzen + +- Beide Kategorien teilen einen Timer und besitzen getrennte Aktivierung und Texte. +- Der zweite Versand setzt weiterhin eine verfügbare zweite Chatkategorie voraus. +- Aktivieren sendet nicht sofort und startet den Timer nicht neu. +- Der erste Timerlauf nach dem Verbindungsaufbau bleibt bei ungefähr zehn Sekunden. +- Nachrichtenauflösung, Längenlimit, TX-Warteschlange und ON4KST-Framing bleiben unverändert. +- Die Chip-Texte `off`, `1/2`, `on` und `FAILED` und seine gemeinsame Klickfunktion bleiben. +- Es gibt keine neue Produktionsabhängigkeit, kein neues persistentes Schema und keine + Änderung eines externen Protokolls. + +## Berücksichtigte Ansätze + +1. **Gewählt:** controllerseitiger Sitzungszustand mit expliziten manuellen und externen + Ereignissen. Das hält Policy, Referenzen und Gründe zusammen und bleibt testbar. +2. **Verworfen:** Flags in Preferences, Referenzen in einem Guard und Gründe in `BeaconStatus`. + Der zusammengehörige Zustand wäre auf mehrere Besitzer verteilt. +3. **Verworfen:** Beobachtung der QRG-Werte mit temporärer Herkunftsmarkierung. `MutableValue` + trägt keine Quelle; die asynchrone UI-Weitergabe machte diesen Ansatz fragil. + +## Dokumentationswirkung + +Die Änderung ist benutzerseitig sichtbar, betrifft Laufzeit/Persistenz sowie die bestehende +UCXLog-/Win-Test-Integration. Geprüft und bei Bedarf angepasst werden die relevanten +deutschen und englischen Konfigurations-, Workflow-, Variablen-, Funktions- und +Benutzeroberflächenabschnitte, Changelog, Website-Texte, `docs/PROJECT_CONTEXT.md` und der +ältere Live-Beacon-Entwurf. README und verbundene Projekte sind voraussichtlich nicht +betroffen. diff --git a/docs/superpowers/specs/2026-11-13-automatic-chat-suffix-design.md b/docs/superpowers/specs/2026-11-13-automatic-chat-suffix-design.md new file mode 100644 index 00000000..e820d2df --- /dev/null +++ b/docs/superpowers/specs/2026-11-13-automatic-chat-suffix-design.md @@ -0,0 +1,27 @@ +# Automatic Chat Suffix Design + +## Intent + +Every user-visible chat message generated and sent automatically by KST4Contest must end with the fixed suffix ` *a*`. The marker makes automatic traffic recognisable without changing manual messages or ON4KST control traffic. + +## Behaviour + +- Append ` *a*` to generic private autoanswers, QRG autoanswers, `//VER` replies and both beacon categories. +- Keep the existing `[KST4C Automsg]` prefix on every autoanswer and continue using it for the reply-loop guard. +- Append the suffix unconditionally after text preparation. A configured text which already ends in `*a*` therefore receives another ` *a*`. +- Preserve the existing 120-character beacon payload allowance. The complete transmitted beacon, including the four-character suffix, may contain up to 124 characters. + +## Non-goals + +- Do not mark manually sent messages, snippets or manually triggered CQ messages. +- Do not alter login, keepalive, ON4KST command or other internal protocol frames. +- Do not change cooldowns, category routing, beacon activation, QRG safeguards, timer behaviour, persistence or settings UI. +- Do not revive or refactor the unused legacy `ScoreboardUpdateTask`. + +## Selected Approach + +Use one package-private formatter in the controller package to append the fixed suffix. Call it explicitly from the active autoanswer and beacon construction paths. This keeps the scope visible and prevents a broad writer-layer rule from modifying manual messages or raw protocol frames. + +## Validation and Documentation + +Validate autoanswers after suffix insertion. Validate beacon payloads against 120 characters, append the suffix, then validate the final text against 124 characters. Update focused tests, the German and English manuals, relevant website feature pages, changelogs and durable project context. diff --git a/github_docs/client_settings_window_beacon.png b/github_docs/client_settings_window_beacon.png index 6a124827e6356a69da5a00acf516ce8acfcd2e53..275046790a5411a77a07a201d4ad523719b704a7 100644 GIT binary patch literal 54170 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