Fix compact QRG parsing and initial worked-state restore - parse digit-only full frequencies across supported bands - restore persisted Worked state before publishing initial user lists - add regression tests and update documentation Fixes #85

This commit is contained in:
Marc Froehlich
2026-09-02 19:17:52 +02:00
parent 6b29ffe3ba
commit 6b0d699a98
13 changed files with 325 additions and 40 deletions
@@ -1848,7 +1848,7 @@ private ObservableList<String>
* assumption which one is the current run frequency. Therefore the legacy
* frequency property is initialized only when exactly one explicit QRG exists.</p>
*/
private void initializeFrequencyFromStationNameIfUnambiguous(
/* package */ void initializeFrequencyFromStationNameIfUnambiguous(
ChatMember member
) {
if (member == null) {
@@ -1942,6 +1942,8 @@ private ObservableList<String>
int categoryNumber = category.getCategoryNumber();
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
Map<String, ChatMember> workedDataFromDatabase =
loadWorkedStateForInitialUserList(safeMembers);
for (ChatMember member : safeMembers) {
initializeFrequencyFromStationNameIfUnambiguous(member);
}
@@ -1966,10 +1968,48 @@ private ObservableList<String>
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
lst_chatMemberList.addAll(safeMembers);
if (workedDataFromDatabase != null) {
getLst_DBBasedWkdCallSignList().setAll(
workedDataFromDatabase.values());
}
fireUserListUpdate("Complete ON4KST user list received");
});
}
/**
* Loads one database snapshot for a completed initial ON4KST user list and
* applies it before those members are published to the active model or UI.
* Every callsign variant receives the state stored for its normalized base
* callsign.
*
* @param initialMembers completed members of one chat category
* @return loaded snapshot, or {@code null} when the database read failed
*/
/* package */ Map<String, ChatMember> loadWorkedStateForInitialUserList(
Collection<ChatMember> initialMembers
) {
if (dbHandler == null) {
LOGGER.warning(
"Cannot load Worked state for initial ON4KST user list: "
+ "database is not initialized");
return null;
}
try {
Map<String, ChatMember> workedDataFromDatabase =
dbHandler.fetchChatMemberWkdDataFromDB();
applyWorkedAndQrvStateFromDatabase(
initialMembers, workedDataFromDatabase);
return workedDataFromDatabase;
} catch (SQLException | RuntimeException exception) {
LOGGER.log(
Level.WARNING,
"Could not load Worked state for completed initial ON4KST user list",
exception);
return null;
}
}
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
@@ -3854,15 +3894,6 @@ private ObservableList<String>
}
new Timer().schedule(new TimerTask() {
@Override
public void run() {
Thread.currentThread().setName("fetchWorkedFromDBTimer");
refreshWorkedStateAndDatabaseListFromDatabase();
}
}, 10000);
// new Timer().schedule(new TimerTask() {
// HashMap<String, ChatMember> getWorkedDataFromDb;
//
@@ -3966,23 +3997,34 @@ private ObservableList<String>
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> {
helper_applyWorkedAndQrvStateFromDatabase(finalWorkedDataFromDatabase);
applyWorkedAndQrvStateFromDatabase(
activeChatMembersByCallAndCategory.values(),
finalWorkedDataFromDatabase);
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
fireUserListUpdate("Worked database state refreshed");
});
}
/**
* Applies the worked and not-QRV state from the database snapshot to all active
* chatmember objects that are currently visible in the live chat list.
* Applies the worked and not-QRV state from a database snapshot to chat members.
* Database rows are keyed by normalized base callsign, so all active category
* and suffix variants receive the same persisted state.
*
* @param chatMembers members that should receive persisted state
* @param workedDataFromDatabase map keyed by normalized raw callsign
*/
private void helper_applyWorkedAndQrvStateFromDatabase(HashMap<String, ChatMember> workedDataFromDatabase) {
/* package */ static void applyWorkedAndQrvStateFromDatabase(
Collection<ChatMember> chatMembers,
Map<String, ChatMember> workedDataFromDatabase
) {
if (chatMembers == null || workedDataFromDatabase == null) {
return;
}
for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) {
ChatMember activeChatMember = (ChatMember) iterator.next();
for (ChatMember activeChatMember : chatMembers) {
if (activeChatMember == null) {
continue;
}
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
if (storedChatMemberState == null) {
@@ -57,7 +57,7 @@ public class MessageBusManagementThread extends Thread {
/*
* Frequency formats handled by the smart parser:
*
* Group 1: full frequencies, for example 144.210 or 10368.100
* Group 1: full frequencies, for example 144.210, 144210 or 10368100
* Group 2: relative frequencies with a separator, for example .210 or ,210
* Group 3: bare three-digit values, for example 210
*
@@ -66,7 +66,7 @@ public class MessageBusManagementThread extends Thread {
* would be converted into plausible but incorrect frequencies.
*/
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
"(?<![A-Z0-9])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})(?![A-Z0-9])"
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
);
@@ -279,7 +279,7 @@ public class MessageBusManagementThread extends Thread {
* @param message message whose text is inspected
* @param prefs preferences containing the global fallback band
*/
private void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
/* package */ void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
if (message == null || message.getMessageText() == null) {
return;
}
@@ -13,9 +13,10 @@ import java.util.regex.Pattern;
* Common parser for explicit amateur-radio frequencies embedded in text.
*
* <p>This parser deliberately handles only complete frequencies such as
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
* bare values such as "210" require additional message context and remain the
* responsibility of the chat-message parser.</p>
* 144.300, 432.357, 10368.100 or their compact digit-only forms. Relative
* forms such as ".210" or ambiguous bare values such as "210" require
* additional message context and remain the responsibility of the
* chat-message parser.</p>
*/
public final class FrequencyTextParser {
@@ -26,14 +27,18 @@ public final class FrequencyTextParser {
* 432,357
* 10368.100
* 144.300.03
* 144300
* 10368100
*
* At least two digits are required before the decimal separator. This
* intentionally prevents "1.2" from being interpreted as a frequency.
* At least two digits are required before a decimal separator. Compact
* values need at least five digits because their final three digits form
* the kHz part. This intentionally prevents "1.2" and bare values such as
* "210" from being interpreted as complete frequencies.
*/
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
"(?<![A-Z0-9])"
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
+ "(?!\\d)",
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
+ "(?![A-Z0-9])",
Pattern.CASE_INSENSITIVE
);
@@ -93,12 +98,12 @@ public final class FrequencyTextParser {
return null;
}
String normalized =
normalizeFrequencyString(
rawFrequency
.trim()
.replace(',', '.')
);
final String trimmedFrequency = rawFrequency.trim();
final String normalized = trimmedFrequency.matches("\\d{5,8}")
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
+ "."
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
: normalizeFrequencyString(trimmedFrequency.replace(',', '.'));
try {
double frequencyMHz =
@@ -188,4 +193,4 @@ public final class FrequencyTextParser {
return sourceText;
}
}
}
}
@@ -0,0 +1,85 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ChatControllerInitialWorkedStateTest {
@Test
void loadsEachCompletedInitialListOnceAndAppliesStateToEveryVariant()
throws SQLException {
DBController database = mock(DBController.class);
ChatMember stored = member("9A0BB", 2);
stored.setWorked(true);
stored.setWorked144(true);
stored.setWorked10G(true);
stored.setQrv432(false);
HashMap<String, ChatMember> databaseSnapshot = new HashMap<>();
databaseSnapshot.put(stored.getCallSignRaw(), stored);
when(database.fetchChatMemberWkdDataFromDB())
.thenReturn(databaseSnapshot);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember mainVariant = member("9A0BB-2", 2);
ChatMember secondVariant = member("9A0BB-70", 3);
ChatMember reconnectMainVariant = member("9A0BB-144", 2);
ChatMember reconnectSecondVariant = member("9A0BB-432", 3);
controller.loadWorkedStateForInitialUserList(List.of(mainVariant));
controller.loadWorkedStateForInitialUserList(List.of(secondVariant));
controller.loadWorkedStateForInitialUserList(
List.of(reconnectMainVariant));
controller.loadWorkedStateForInitialUserList(
List.of(reconnectSecondVariant));
verify(database, times(4)).fetchChatMemberWkdDataFromDB();
for (ChatMember variant : List.of(
mainVariant,
secondVariant,
reconnectMainVariant,
reconnectSecondVariant
)) {
assertTrue(variant.isWorked());
assertTrue(variant.isWorked144());
assertTrue(variant.isWorked10G());
assertFalse(variant.isQrv432());
}
}
@Test
void keepsAmbiguousStationNameFromReplacingCompatibilityFrequency() {
ChatController controller = new ChatController();
ChatMember member = member("DL1ABC", 2);
member.setName("144307 and 432100");
controller.initializeFrequencyFromStationNameIfUnambiguous(member);
assertTrue(
member.getFrequency() == null
|| member.getFrequency().get() == null
|| member.getFrequency().get().isBlank()
);
}
private static ChatMember member(String callSign, int categoryNumber) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(categoryNumber));
return member;
}
}
@@ -0,0 +1,52 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.concurrent.LinkedBlockingQueue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MessageBusManagementThreadFrequencyTest {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void detectsCompactMicrowaveFrequencyInPublicAndDirectedMessages(
boolean directedMessage
) {
ChatController controller = mock(ChatController.class);
ThreadStatusCallback callback = mock(ThreadStatusCallback.class);
MessageBusManagementThread messageBus =
new MessageBusManagementThread(
controller,
callback,
1L,
new LinkedBlockingQueue<>(),
ignored -> true
);
ChatMember sender = member("DL1ABC");
ChatMessage message = new ChatMessage();
message.setSender(sender);
message.setReceiver(member(directedMessage ? "DL2XYZ" : "ALL"));
message.setMessageText("pse try 10368100");
messageBus.smartFrequencyExtraction(message, null);
verify(controller).applyDetectedFrequencyToActiveMembers(
sender,
Band.B_10G,
10368.100
);
}
private static ChatMember member(String callSign) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
return member;
}
}
@@ -0,0 +1,86 @@
package kst4contest.test;
import kst4contest.logic.FrequencyTextParser;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FrequencyTextParserRegressionTest {
@ParameterizedTest
@MethodSource("compactFrequenciesAcrossSupportedBands")
void detectsCompactFrequenciesAcrossSupportedBands(
String compactFrequency,
Band expectedBand,
double expectedFrequencyMHz
) {
FrequencyTextParser.DetectedFrequency detected =
FrequencyTextParser.findExplicitFrequencies(
"QRV " + compactFrequency
).get(0);
assertEquals(expectedBand, detected.getBand());
assertEquals(
expectedFrequencyMHz,
detected.getFrequencyMHz(),
0.000_001
);
}
@Test
void detectsReferenceFrequencyInStationName() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"Operator 144307"
);
assertEquals(1, detected.size());
assertEquals(Band.B_144, detected.get(0).getBand());
assertEquals(
144.307,
detected.get(0).getFrequencyMHz(),
0.000_001
);
}
@Test
void rejectsCompactValuesOutsideSupportedBandRanges() {
assertTrue(
FrequencyTextParser.findExplicitFrequencies(
"146100 434100 99999"
).isEmpty()
);
}
@Test
void keepsBareThreeDigitValuesOutOfCompleteFrequencyDetection() {
assertTrue(
FrequencyTextParser.findExplicitFrequencies(
"210 599 144"
).isEmpty()
);
}
private static Stream<Arguments> compactFrequenciesAcrossSupportedBands() {
return Stream.of(
Arguments.of("50278", Band.B_50, 50.278),
Arguments.of("70200", Band.B_70, 70.200),
Arguments.of("145500", Band.B_144, 145.500),
Arguments.of("432100", Band.B_432, 432.100),
Arguments.of("1296100", Band.B_1296, 1296.100),
Arguments.of("2320100", Band.B_2320, 2320.100),
Arguments.of("3400100", Band.B_3400, 3400.100),
Arguments.of("5760100", Band.B_5760, 5760.100),
Arguments.of("10368100", Band.B_10G, 10368.100),
Arguments.of("24048100", Band.B_24G, 24048.100)
);
}
}