fix: complete Compose UI workflow corrections

This commit is contained in:
Marc Froehlich committed 2026-10-24 17:33:57 +02:00
1 parent 971fb35109
commit 4be6c178f8
57 files changed
+3084 -465

No files matched your search

@@ -1648,6 +1648,7 @@ private SimpleRoster<String>
* lst_chatMemberList is this very field.
*/
private final SimpleRoster<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations
private final SimpleRoster<RecentChatJoin> recentChatJoins = new SimpleRoster<>();
private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>();
private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>();
@@ -1803,6 +1804,20 @@ private SimpleRoster<String>
* user-enter events.
*/
public void addOrUpdateActiveChatMember(ChatMember member) {
addOrUpdateActiveChatMember(member, System.currentTimeMillis());
}
/**
* Adds or replaces an active member and records a join only for a previously absent
* full callsign/category identity.
*
* @param member member received in a live user-enter frame
* @param joinedAtEpochMillis wall-clock time of that frame
*/
public void addOrUpdateActiveChatMember(
ChatMember member,
long joinedAtEpochMillis
) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return;
@@ -1810,7 +1825,7 @@ private SimpleRoster<String>
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
ChatMember previous = activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> chatMemberList.mutate(list -> {
int existingIndex = findChatMemberIndexInList(list, key);
@@ -1820,6 +1835,18 @@ private SimpleRoster<String>
list.add(member);
}
}));
if (previous == null) {
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> {
boolean alreadyRecorded = joins.stream().anyMatch(join ->
join.matches(member.getCallSign(), categoryNumber));
if (!alreadyRecorded) {
joins.add(0, new RecentChatJoin(
member.getCallSign(), categoryNumber, joinedAtEpochMillis));
}
});
}
}
/**
@@ -1834,6 +1861,9 @@ private SimpleRoster<String>
}
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.matches(member.getCallSign(), categoryNumber)));
runOnFxThread(() -> chatMemberList.mutate(list ->
list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))));
return removedMember != null;
@@ -1845,6 +1875,7 @@ private SimpleRoster<String>
*/
public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear();
recentChatJoins.clear();
runOnFxThread(chatMemberList::clear);
}
@@ -1867,6 +1898,8 @@ private SimpleRoster<String>
}
int categoryNumber = category.getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.getCategoryNumber() == categoryNumber));
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
Map<String, ChatMember> workedDataFromDatabase =
@@ -1992,8 +2025,8 @@ private SimpleRoster<String>
/**
* Applies a QSO received from an external logger to all active callsign
* variants. Active member objects back JavaFX views, so their state is changed
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated
* variants. Active member objects back UI views, so their state is changed
* only through the configured UI dispatcher. The band-upgrade hint is evaluated
* afterwards and therefore sees the new Worked state.
*
* @param loggedQso validated external QSO
@@ -2199,6 +2232,16 @@ private SimpleRoster<String>
target.setQrv10G(sourceMember.isQrv10G());
}
if (dbHandler != null) {
try {
dbHandler.updateNotQRVInfoOnChatMember(sourceMember);
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist NOT-QRV state for " + rawCall,
exception);
}
}
if (scoreService != null) {
scoreService.requestRecompute("NOT-QRV state changed");
}
@@ -2743,6 +2786,96 @@ private SimpleRoster<String>
return chatMemberList;
}
/**
* Applies and persists a validated QSO through the same path for external
* logger packets and explicit UI Worked actions.
*
* @param loggedQso validated QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso) {
return loggedQso != null
&& recordLoggedQso(loggedQso, loggedQso.toWorkedChatMember());
}
/**
* Applies and persists a QSO while retaining the supplied database/backup row.
* Logger listeners use this overload so later backup serialization observes the
* same normalization as database persistence.
*
* @param loggedQso validated QSO
* @param workedCall mutable row derived from the QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso, ChatMember workedCall) {
if (loggedQso == null || workedCall == null || dbHandler == null) {
return false;
}
LoggedQsoBand loggedBand = loggedQso.getBand();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& loggedQso.getLocator() != null) {
registerWorkedGrossField(
loggedBand.getProjectBand(),
loggedQso.getLocator(),
workedCall,
loggedQso.getSource());
}
applyExternalLoggedQso(loggedQso);
try {
boolean isInChat = dbHandler.updateWkdInfoOnChatMember(workedCall);
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
dbHandler.storeChatMember(workedCall);
}
return true;
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist Worked state for " + loggedQso.getCallSign(),
exception);
return false;
}
}
/**
* Marks one station and band as worked with contactinfo-equivalent semantics.
* Worked contacts are additive; invoking this method for an already worked band
* is intentionally idempotent.
*
* @param member station selected by the UI
* @param band band to mark as worked
* @return true when a valid contact was accepted and persisted
*/
public boolean markStationWorkedOnBand(ChatMember member, Band band) {
if (member == null || band == null) {
return false;
}
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
if (loggedBand == null) {
return false;
}
String callSign = member.getCallSign() != null
? member.getCallSign()
: member.getCallSignRaw();
return ExternalLoggedQso.create(callSign, loggedBand, member.getQra(), "MANUAL")
.map(this::recordLoggedQso)
.orElse(false);
}
/** Live joins only; initial and resynchronized user lists never enter this roster. */
public ObservableRoster<RecentChatJoin> getRecentChatJoins() {
return recentChatJoins;
}
public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList;
}
@@ -92,6 +92,27 @@ public enum LoggedQsoBand {
};
}
/**
* Resolves a project band for UI actions that must follow the same Worked
* processing as an external logger contact.
*
* @param band project band
* @return normalized logged-QSO band, or {@code null} for a missing band
*/
public static LoggedQsoBand fromProjectBand(Band band) {
if (band == null) {
return null;
}
for (LoggedQsoBand loggedBand : values()) {
if (loggedBand.projectBand == band) {
return loggedBand;
}
}
return null;
}
/**
* Returns the common Band value used by worked-grid persistence.
*
@@ -1145,7 +1145,7 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBUS: User detected");
if (!client.getChatPreferences().getStn_loginCallSign().equals(splittedMessageLine[2])) { //own call ignore
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(splittedMessageLine[2])) { //own call ignore
ChatMember newMember = new ChatMember();
@@ -1164,7 +1164,8 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.addOrUpdateActiveChatMember(newMember);
this.client.addOrUpdateActiveChatMember(
newMember, millisClock.getAsLong());
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
@@ -774,30 +774,7 @@ public class ReadUDPByWintestThread extends Thread {
}
ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
@@ -3,7 +3,6 @@ package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Arrays;
import javax.xml.XMLConstants;
@@ -366,118 +365,11 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
}
{
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
appendToUdpBackupFile(this.client.getChatPreferences()
@@ -594,18 +486,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
}
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
@@ -146,6 +146,22 @@ public final class WorkedGrossFieldCache {
return false;
}
/**
* Returns an immutable snapshot of all gross fields worked on at least one
* band. Map rendering uses the snapshot without holding this cache's monitor.
*
* @return immutable any-band gross-field snapshot
*/
public synchronized Set<String> snapshotWorkedGrossFieldsAny() {
Set<String> snapshot = new HashSet<>();
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null) {
snapshot.addAll(workedGrossFields);
}
}
return Set.copyOf(snapshot);
}
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
@@ -199,4 +215,4 @@ public final class WorkedGrossFieldCache {
String normalized = grossField.trim().toUpperCase(Locale.ROOT);
return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
}
}
}
@@ -0,0 +1,48 @@
package kst4contest.model;
import java.util.Locale;
import java.util.Objects;
/**
* One live ON4KST join event.
*
* <p>The full callsign and category form the identity. The timestamp belongs to the
* first event while that identity remains active; ordinary user updates must not renew it.</p>
*/
public final class RecentChatJoin {
private final String callSign;
private final int categoryNumber;
private final long joinedAtEpochMillis;
public RecentChatJoin(
String callSign,
int categoryNumber,
long joinedAtEpochMillis
) {
this.callSign = Objects.requireNonNull(callSign, "callSign")
.trim()
.toUpperCase(Locale.ROOT);
this.categoryNumber = categoryNumber;
this.joinedAtEpochMillis = joinedAtEpochMillis;
}
public String getCallSign() {
return callSign;
}
public int getCategoryNumber() {
return categoryNumber;
}
public long getJoinedAtEpochMillis() {
return joinedAtEpochMillis;
}
/** Returns whether the event belongs to the same complete chat identity. */
public boolean matches(String otherCallSign, int otherCategoryNumber) {
return otherCallSign != null
&& categoryNumber == otherCategoryNumber
&& callSign.equalsIgnoreCase(otherCallSign.trim());
}
}
@@ -60,10 +60,30 @@ public record MapCallsignRawSnapshot(
}
public String markerLabel() {
String baseLabel = bandSummary.isBlank()
? displayCallSign
: displayCallSign + " (" + bandSummary + ")";
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
return displayCallSign;
}
/**
* Returns the details removed from the visible marker label for its hover text.
*
* @return multiline callsign, locator, band and frequency summary
*/
public String tooltipText() {
StringJoiner joiner = new StringJoiner("\n");
joiner.add(displayCallSign);
if (!locator6.isBlank()) {
joiner.add("Locator: " + locator6);
}
if (!bandSummary.isBlank()) {
joiner.add("Bands: " + bandSummary);
}
if (offersSelectedBand) {
joiner.add("B+");
}
if (!lastKnownFrequenciesByBand.isEmpty()) {
joiner.add(detailFrequencyText());
}
return joiner.toString();
}
public String detailFrequencyText() {
@@ -77,4 +97,4 @@ public record MapCallsignRawSnapshot(
}
return joiner.toString();
}
}
}
@@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip {
public static String build(MapMarkerCluster cluster) {
List<String> callsigns = new ArrayList<>();
for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
String label = marker.markerLabel();
String label = marker.tooltipText().replace('\n', ' ');
if (label != null && !label.isBlank()) {
callsigns.add(label.trim());
}
@@ -0,0 +1,40 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChatControllerNotQrvPersistenceTest {
@Test
void propagatedNotQrvStateIsPersistedForTheSharedRawCall() throws SQLException {
DBController database = mock(DBController.class);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2);
ChatMember second = member("9A0BB-70", 3);
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(second);
selected.setQrv432(false);
controller.propagateNotQrvStateToActiveMembers(selected);
assertFalse(second.isQrv432());
verify(database).updateNotQRVInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw()) && !member.isQrv432()));
}
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
return member;
}
}
@@ -0,0 +1,72 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.RecentChatJoin;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatControllerRecentJoinTest {
@Test
void liveJoinIsRecordedOnceWithoutRenewingTheTimestampOnDuplicateUpdates() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 9_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(1, joins.size());
assertEquals("HB9SJV", joins.get(0).getCallSign());
assertEquals(2, joins.get(0).getCategoryNumber());
assertEquals(1_000L, joins.get(0).getJoinedAtEpochMillis());
}
@Test
void theSameCallsignInTwoCategoriesRemainsTwoJoinIdentities() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("DL1KDA", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("DL1KDA", 3), 2_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(2, joins.size());
assertEquals(List.of(3, 2), joins.stream().map(RecentChatJoin::getCategoryNumber).toList());
}
@Test
void leavingRemovesTheJoinAndARealRejoinStartsAFreshTimestamp() {
ChatController controller = new ChatController();
ChatMember member = member("OE5SOS", 2);
controller.addOrUpdateActiveChatMember(member, 1_000L);
controller.removeActiveChatMember(member);
assertTrue(controller.getRecentChatJoins().isEmpty());
controller.addOrUpdateActiveChatMember(member("OE5SOS", 2), 7_000L);
assertEquals(7_000L,
controller.getRecentChatJoins().snapshot().get(0).getJoinedAtEpochMillis());
}
@Test
void clearingTheActiveRosterAlsoClearsTransientJoinEvents() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("PA2M", 2), 1_000L);
controller.clearActiveChatMembers();
assertTrue(controller.getRecentChatJoins().isEmpty());
}
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,66 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ChatControllerWorkedActionTest {
@Test
void manualWorkedActionUsesContactInfoPersistenceAndUpdatesEveryActiveVariant()
throws SQLException {
DBController database = mock(DBController.class);
when(database.updateWkdInfoOnChatMember(argThat(member -> member != null)))
.thenReturn(true);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2, "JN75AA");
ChatMember secondCategory = member("9A0BB-70", 3, "JN75AA");
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(secondCategory);
assertTrue(controller.markStationWorkedOnBand(selected, Band.B_432));
assertTrue(selected.isWorked());
assertTrue(selected.isWorked432());
assertTrue(secondCategory.isWorked());
assertTrue(secondCategory.isWorked432());
assertTrue(controller.getWorkedGrossFieldCache()
.isGrossFieldWorked(Band.B_432, "JN75"));
verify(database).updateWkdInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw())
&& member.isWorked()
&& member.isWorked432()
&& "JN75AA".equals(member.getQra())));
verify(database).upsertWorkedGrossField(
Band.B_432, "JN75AA", "9A0BB", "MANUAL");
}
@Test
void manualWorkedActionRejectsMissingStationOrBandWithoutChangingState() {
ChatController controller = new ChatController();
ChatMember member = member("DL1ABC", 2, "JO50AA");
assertFalse(controller.markStationWorkedOnBand(null, Band.B_144));
assertFalse(controller.markStationWorkedOnBand(member, null));
assertFalse(member.isWorked());
}
private static ChatMember member(String callSign, int category, String locator) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
member.setQra(locator);
return member;
}
}
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.stream.Stream;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -37,6 +38,15 @@ class ExternalLoggedQsoTest {
assertNull(LoggedQsoBand.fromWinTestBandId("99"));
}
@Test
void resolvesEveryProjectBandForManualWorkedActions() {
for (Band band : Band.values()) {
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
assertEquals(band, loggedBand.getProjectBand());
}
}
@Test
void missingCallsignIsRejectedBeforeAnyStateIsBuilt() {
assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
@@ -41,7 +41,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processUCXUDPMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
@@ -55,7 +55,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processWinTestMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
}
@@ -63,6 +63,7 @@ class MessageBusManagementThreadChatFrameTest {
private static final String RECEIVER_LOCATOR = "JO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
private static final long FIXED_NOW_MILLIS = 1_700_000_123_456L;
@TempDir
Path tempDir;
@@ -102,6 +103,10 @@ class MessageBusManagementThreadChatFrameTest {
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer()).thenReturn(dxClusterServer);
DBController database = mock(DBController.class);
when(controller.getDbHandler()).thenReturn(database);
when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(any(ChatMember.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
@@ -181,6 +186,60 @@ class MessageBusManagementThreadChatFrameTest {
assertFalse(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void liveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA5|2|HB9SJV|Contest team|JN47AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("HB9SJV", member.getValue().getCallSign());
assertEquals(ChatCategory.VUHF,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void alternateLiveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA2|3|OE5SOS|Microwave team|JN78AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("OE5SOS", member.getValue().getCallSign());
assertEquals(ChatCategory.MICROWAVE,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void initialUserListAndItsTerminatorDoNotCreateLiveJoinEvents()
throws InterruptedException {
runThrough(
message("UA0|2|HB9SJV|Contest team|JN47AA|3|"),
message("UE|2|1|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
verify(controller).stageInitialOn4KstChatMember(
org.mockito.ArgumentMatchers.eq(7L), any(ChatMember.class));
verify(controller).onOn4KstInitialUserListCompleted(
org.mockito.ArgumentMatchers.eq(7L), any(ChatCategory.class));
}
@Test
@Timeout(10)
void ownCallIsExcludedFromLiveJoinEventsIgnoringCase()
throws InterruptedException {
runThrough(message("UA5|2|dn9apw|Own station|JO50LL|3|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
}
// --- sent by me ---------------------------------------------------------
@Test
@@ -417,7 +476,8 @@ class MessageBusManagementThreadChatFrameTest {
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
ignored -> true,
() -> FIXED_NOW_MILLIS);
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
@@ -25,7 +25,6 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
@@ -145,12 +144,12 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
}
/**
* A UCXLog QSO packet marks the station worked, registers the gross field
* and stores an unknown station in the database and the UDP backup file.
* A UCXLog QSO packet delegates the complete contact to the shared controller
* action and writes the derived row to the UDP backup file.
* Bytes before the XML declaration are transport framing and are dropped.
*/
@Test
void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception {
void contactInfoPacketUsesSharedWorkedActionAndWritesBackup(@TempDir Path tempDir) throws Exception {
Path backupFile = tempDir.resolve("udp-backup.txt");
DBController database = givenQsoProcessingEnabled(backupFile);
when(database.updateWkdInfoOnChatMember(any())).thenReturn(false);
@@ -164,24 +163,13 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "</contactinfo>");
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).applyExternalLoggedQso(qso.capture());
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals("OK1ABC", qso.getValue().getCallSign());
assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand());
assertEquals("JO70FD", qso.getValue().getLocator());
assertEquals("UCXLOG", qso.getValue().getSource());
verify(controller).registerWorkedGrossField(
eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG"));
ArgumentCaptor<ChatMember> stored = ArgumentCaptor.forClass(ChatMember.class);
verify(database).storeChatMember(stored.capture());
assertEquals("OK1ABC", stored.getValue().getCallSign());
assertEquals("unknown", stored.getValue().getName());
assertEquals("JO70FD", stored.getValue().getQra());
assertTrue(stored.getValue().isWorked());
assertTrue(stored.getValue().isWorked144());
String backup = Files.readString(backupFile, StandardCharsets.UTF_8);
assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup);
assertTrue(backup.startsWith("\nOK1ABC;null;JO70FD;"), backup);
}
/**
@@ -198,10 +186,10 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "<contactreplace><app>DXLog</app><band>432</band><call>SP2XYZ</call>"
+ "<rcvnr>042JO93AB</rcvnr></contactreplace>");
verify(controller).registerWorkedGrossField(
eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG"));
verify(database).updateWkdInfoOnChatMember(any());
verify(database, never()).storeChatMember(any());
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals(LoggedQsoBand.BAND_432, qso.getValue().getBand());
assertEquals("JO93AB", qso.getValue().getLocator());
}
@Test
@@ -211,7 +199,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(database, never()).updateWkdInfoOnChatMember(any());
}
@@ -229,8 +217,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call>"
+ "<gridsquare>JO70FD</gridsquare></contactinfo>");
verify(controller).applyExternalLoggedQso(any());
verify(database).storeChatMember(any());
verify(controller).recordLoggedQso(any(), any());
}
/** A RadioInfo without Freq used to end the listener with a NullPointerException. */
@@ -250,7 +237,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -297,7 +284,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
statusCallbackThrows = true;
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -0,0 +1,25 @@
package kst4contest.controller;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WorkedGrossFieldCacheTest {
@Test
void anyBandSnapshotContainsUniqueNormalizedGrossFieldsAndIsImmutable() {
WorkedGrossFieldCache cache = new WorkedGrossFieldCache();
cache.addWorked(Band.B_144, "jo51aa");
cache.addWorked(Band.B_432, "JO51BB");
cache.addWorked(Band.B_1296, "JN49AA");
Set<String> snapshot = cache.snapshotWorkedGrossFieldsAny();
assertEquals(Set.of("JO51", "JN49"), snapshot);
assertThrows(UnsupportedOperationException.class, () -> snapshot.add("JO50"));
}
}
@@ -106,6 +106,8 @@ class ChatPreferencesLayoutPersistenceTest {
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
preferences.setGUIstationMapClusteringEnabled(false);
preferences.setGUIselectedCallSignSplitPane_dividerposition(
new double[] {0.68});
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
@@ -124,6 +126,8 @@ class ChatPreferencesLayoutPersistenceTest {
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
assertFalse(restored.isGUIstationMapClusteringEnabled());
assertArrayEquals(new double[] {0.68},
restored.getGUIselectedCallSignSplitPane_dividerposition(), 0.0001);
assertEquals(123.75,
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
}
@@ -0,0 +1,37 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotLabelTest {
@Test
void visibleMarkerLabelContainsOnlyTheCallsign() {
MapCallsignRawSnapshot snapshot = snapshot();
assertEquals("DM5M", snapshot.markerLabel());
}
@Test
void tooltipRetainsLocatorBandsUpgradeAndFrequencyDetails() {
String tooltip = snapshot().tooltipText();
assertTrue(tooltip.contains("DM5M"));
assertTrue(tooltip.contains("JO51AA"));
assertTrue(tooltip.contains("144, 432"));
assertTrue(tooltip.contains("B+"));
assertTrue(tooltip.contains("144: 144.310"));
}
private MapCallsignRawSnapshot snapshot() {
return new MapCallsignRawSnapshot(
"DM5M", "DM5M", "JO51AA", 51.0, 10.0,
"144, 432", Map.of("144", "144.310"), true,
false, false, false, 100.0, 180.0, 0, 0L
);
}
}
@@ -51,9 +51,10 @@ class MapMarkerClusterTooltipTest {
List<String> lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n"));
assertEquals("3 stations", lines.get(0));
assertEquals("DL000AA", lines.get(1));
assertEquals("DL001AA", lines.get(2));
assertEquals("DL002AA", lines.get(3));
assertTrue(lines.get(1).startsWith("DL000AA"));
assertTrue(lines.get(2).startsWith("DL001AA"));
assertTrue(lines.get(3).startsWith("DL002AA"));
assertTrue(lines.get(1).contains("JO50JP"));
}
/** A hundred callsigns is a box taller than the window. */