diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index e91b280..2ec6213 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -34,7 +34,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Predicate; import java.nio.charset.StandardCharsets; - +import kst4contest.logic.FrequencyTextParser; @@ -1490,6 +1490,38 @@ private ObservableList ); } + + /** + * Uses one unambiguous explicit QRG from the current station name as the + * initial compatibility frequency. + * + *

Multiple explicit QRGs are valid band evidence, but there is no safe + * assumption which one is the current run frequency. Therefore the legacy + * frequency property is initialized only when exactly one explicit QRG exists.

+ */ + private void initializeFrequencyFromStationNameIfUnambiguous( + ChatMember member + ) { + if (member == null) { + return; + } + + List detectedFrequencies = + FrequencyTextParser.findExplicitFrequencies( + member.getName() + ); + + if (detectedFrequencies.size() != 1) { + return; + } + + member.initializeFrequencyIfEmpty( + detectedFrequencies + .get(0) + .getFrequencyMHz() + ); + } + /** * Adds or replaces an active chat member in the worker-thread model and mirrors * that change to the JavaFX list. This is the only supported path for ON4KST @@ -1501,6 +1533,8 @@ private ObservableList return; } + initializeFrequencyFromStationNameIfUnambiguous(member); + activeChatMembersByCallAndCategory.put(key, member); runOnFxThread(() -> { @@ -1683,6 +1717,9 @@ private ObservableList } activeMember.setName(updatedMember.getName()); + initializeFrequencyFromStationNameIfUnambiguous( + activeMember + ); activeMember.setQra(updatedMember.getQra()); activeMember.setState(updatedMember.getState()); activeMember.setLastActivity(updatedMember.getLastActivity()); diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index c6ffaac..204afbf 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -18,6 +18,7 @@ import kst4contest.ApplicationConstants; import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.Location; import kst4contest.model.*; +import kst4contest.logic.FrequencyTextParser; /** * @@ -58,7 +59,7 @@ public class MessageBusManagementThread extends Thread { * would be converted into plausible but incorrect frequencies. */ private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile( - "(? bestTimestamp) { - finalDetectedFrequency = candidateFrequency; - finalDetectedBand = candidateBand; - bestTimestamp = info.timestampEpoch; + + finalDetectedFrequency = + detectedFrequency.getFrequencyMHz(); + + finalDetectedBand = + candidateBand; + + bestTimestamp = + info.timestampEpoch; + } } catch (NumberFormatException ignored) { // Try the next known band. @@ -357,27 +370,40 @@ public class MessageBusManagementThread extends Thread { try { String reconstructed = - fallbackBand.getPrefix() + "." + foundRaw; - double candidateFrequency = Double.parseDouble( - normalizeFrequencyString(reconstructed) - ); + fallbackBand.getPrefix() + + "." + + foundRaw; - if (fallbackBand.isPlausible(candidateFrequency)) { - finalDetectedFrequency = candidateFrequency; - finalDetectedBand = fallbackBand; + FrequencyTextParser.DetectedFrequency detectedFrequency = + FrequencyTextParser.parseExplicitFrequency( + reconstructed + ); + + if (detectedFrequency != null + && detectedFrequency.getBand() == fallbackBand) { + + finalDetectedFrequency = + detectedFrequency.getFrequencyMHz(); + + finalDetectedBand = + fallbackBand; } } catch (NumberFormatException ignored) { // The matched value cannot be converted into a frequency. } } } else { - try { - finalDetectedFrequency = Double.parseDouble( - normalizeFrequencyString(foundRaw) - ); - finalDetectedBand = Band.fromFrequency(finalDetectedFrequency); - } catch (NumberFormatException ignored) { - // Continue with the next possible match in the message. + FrequencyTextParser.DetectedFrequency detectedFrequency = + FrequencyTextParser.parseExplicitFrequency( + foundRaw + ); + + if (detectedFrequency != null) { + finalDetectedFrequency = + detectedFrequency.getFrequencyMHz(); + + finalDetectedBand = + detectedFrequency.getBand(); } } @@ -462,22 +488,22 @@ public class MessageBusManagementThread extends Thread { * Example: "144.210.10" -> "144.21010" * Example: "144.210" -> "144.210" */ - private String normalizeFrequencyString(String rawInput) { - // Input is already guaranteed to have only dots as separators (commas replaced earlier) - - int firstDotIndex = rawInput.indexOf("."); - - if (firstDotIndex != -1) { - // Check if there are more dots after the first one - String decimalPart = rawInput.substring(firstDotIndex + 1); - if (decimalPart.contains(".")) { - // Remove all subsequent dots to make it a valid double - decimalPart = decimalPart.replace(".", ""); - return rawInput.substring(0, firstDotIndex) + "." + decimalPart; - } - } - return rawInput; - } +// private String normalizeFrequencyString(String rawInput) { +// // Input is already guaranteed to have only dots as separators (commas replaced earlier) +// +// int firstDotIndex = rawInput.indexOf("."); +// +// if (firstDotIndex != -1) { +// // Check if there are more dots after the first one +// String decimalPart = rawInput.substring(firstDotIndex + 1); +// if (decimalPart.contains(".")) { +// // Remove all subsequent dots to make it a valid double +// decimalPart = decimalPart.replace(".", ""); +// return rawInput.substring(0, firstDotIndex) + "." + decimalPart; +// } +// } +// return rawInput; +// } /** diff --git a/src/main/java/kst4contest/logic/BandOpportunityResolver.java b/src/main/java/kst4contest/logic/BandOpportunityResolver.java index 8620718..6f01c31 100644 --- a/src/main/java/kst4contest/logic/BandOpportunityResolver.java +++ b/src/main/java/kst4contest/logic/BandOpportunityResolver.java @@ -113,12 +113,32 @@ public final class BandOpportunityResolver { return detectedBands; } + /* + * Explicit band descriptions: + * 2m, 70cm, 23cm, 432, 1296, ... + */ for (Map.Entry entry : STATION_NAME_BAND_PATTERNS.entrySet()) { if (entry.getValue().matcher(stationName).find()) { detectedBands.add(entry.getKey()); } } + /* + * Explicit full QRGs in name field: + * 432.357 -> 432 MHz + * 1296.210 -> 1296 MHz + * etc. + */ + for (FrequencyTextParser.DetectedFrequency detectedFrequency + : FrequencyTextParser.findExplicitFrequencies( + stationName + )) { + + detectedBands.add( + detectedFrequency.getBand() + ); + } + return detectedBands; } diff --git a/src/main/java/kst4contest/logic/FrequencyTextParser.java b/src/main/java/kst4contest/logic/FrequencyTextParser.java new file mode 100644 index 0000000..1a2f8ba --- /dev/null +++ b/src/main/java/kst4contest/logic/FrequencyTextParser.java @@ -0,0 +1,191 @@ +package kst4contest.logic; + +import kst4contest.model.Band; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Common parser for explicit amateur-radio frequencies embedded in text. + * + *

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.

+ */ +public final class FrequencyTextParser { + + /* + * Examples: + * 50.150 + * 144.300 + * 432,357 + * 10368.100 + * 144.300.03 + * + * At least two digits are required before the decimal separator. This + * intentionally prevents "1.2" from being interpreted as a frequency. + */ + private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile( + "(? findExplicitFrequencies(String text) { + if (text == null || text.isBlank()) { + return List.of(); + } + + Matcher matcher = EXPLICIT_FREQUENCY_PATTERN.matcher(text); + + Map uniqueFrequencies = + new LinkedHashMap<>(); + + while (matcher.find()) { + DetectedFrequency detected = + parseExplicitFrequency(matcher.group(1)); + + if (detected == null) { + continue; + } + + String uniqueKey = + detected.getBand().name() + + "|" + + Double.toString( + detected.getFrequencyMHz() + ); + + uniqueFrequencies.putIfAbsent( + uniqueKey, + detected + ); + } + + return List.copyOf( + new ArrayList<>(uniqueFrequencies.values()) + ); + } + + /** + * Parses one complete frequency. + * + * @return detected band/frequency or {@code null} when the value is invalid + * or outside the supported amateur bands + */ + public static DetectedFrequency parseExplicitFrequency( + String rawFrequency + ) { + if (rawFrequency == null || rawFrequency.isBlank()) { + return null; + } + + String normalized = + normalizeFrequencyString( + rawFrequency + .trim() + .replace(',', '.') + ); + + try { + double frequencyMHz = + Double.parseDouble(normalized); + + if (!Double.isFinite(frequencyMHz)) { + return null; + } + + Band band = + Band.fromFrequency(frequencyMHz); + + if (band == null) { + return null; + } + + return new DetectedFrequency( + band, + frequencyMHz, + rawFrequency.trim() + ); + + } catch (NumberFormatException ignored) { + return null; + } + } + + /** + * Normalizes KST-style frequency strings with an optional second decimal + * separator. + * + * Examples: + * 144.300.03 -> 144.30003 + * 144.300 -> 144.300 + */ + private static String normalizeFrequencyString( + String rawInput + ) { + int firstDotIndex = rawInput.indexOf('.'); + + if (firstDotIndex < 0) { + return rawInput; + } + + String decimalPart = + rawInput.substring(firstDotIndex + 1); + + if (!decimalPart.contains(".")) { + return rawInput; + } + + decimalPart = + decimalPart.replace(".", ""); + + return rawInput.substring(0, firstDotIndex + 1) + + decimalPart; + } + + /** + * Immutable result of one explicit-frequency detection. + */ + public static final class DetectedFrequency { + + private final Band band; + private final double frequencyMHz; + private final String sourceText; + + private DetectedFrequency( + Band band, + double frequencyMHz, + String sourceText + ) { + this.band = band; + this.frequencyMHz = frequencyMHz; + this.sourceText = sourceText; + } + + public Band getBand() { + return band; + } + + public double getFrequencyMHz() { + return frequencyMHz; + } + + public String getSourceText() { + return sourceText; + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java b/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java index 0bbe4f5..e82edb4 100644 --- a/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java +++ b/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java @@ -26,6 +26,7 @@ public final class PropagationFrequencyResolver { /** Explains why a frequency was selected. */ public enum Source { CURRENT_QRG, + STATION_NAME_QRG, STATION_NAME, DUAL_CATEGORY_FALLBACK, CHAT_CATEGORY @@ -90,6 +91,20 @@ public final class PropagationFrequencyResolver { ); } + FrequencyCandidate stationNameQrg = + findUniqueStationNameQrg( + supportedVariants, + usableBands + ); + + if (stationNameQrg != null) { + return new Resolution( + stationNameQrg.band, + stationNameQrg.frequencyMHz, + Source.STATION_NAME_QRG + ); + } + EnumSet nameBands = EnumSet.noneOf(Band.class); for (ChatMember variant : supportedVariants) { nameBands.addAll( @@ -172,6 +187,73 @@ public final class PropagationFrequencyResolver { return latest; } + + /** + * Resolves one exact station-name QRG only when the evidence is unambiguous. + * + *

The same QRG repeated in several category variants counts only once. + * If different explicit QRGs are advertised, no run frequency is guessed and + * the caller continues with normal band-name/category resolution.

+ */ + private static FrequencyCandidate findUniqueStationNameQrg( + List variants, + EnumSet usableBands + ) { + java.util.LinkedHashMap uniqueCandidates = + new java.util.LinkedHashMap<>(); + + for (ChatMember variant : variants) { + if (variant == null) { + continue; + } + + for (FrequencyTextParser.DetectedFrequency detectedFrequency + : FrequencyTextParser.findExplicitFrequencies( + variant.getName() + )) { + + Band band = + detectedFrequency.getBand(); + + if (!usableBands.contains(band)) { + continue; + } + + double frequencyMHz = + detectedFrequency.getFrequencyMHz(); + + String key = + band.name() + + "|" + + Double.toString(frequencyMHz); + + uniqueCandidates.putIfAbsent( + key, + new FrequencyCandidate( + band, + frequencyMHz, + Long.MIN_VALUE + ) + ); + + /* + * More than one different exact QRG means that we cannot safely + * identify one run frequency. + */ + if (uniqueCandidates.size() > 1) { + return null; + } + } + } + + return uniqueCandidates.size() == 1 + ? uniqueCandidates.values() + .iterator() + .next() + : null; + } + + private static boolean isSupportedVariant(ChatMember member) { if (member == null || member.getChatCategory() == null) { return false; diff --git a/src/main/java/kst4contest/model/ChatMember.java b/src/main/java/kst4contest/model/ChatMember.java index 84f1363..c2686a2 100644 --- a/src/main/java/kst4contest/model/ChatMember.java +++ b/src/main/java/kst4contest/model/ChatMember.java @@ -518,6 +518,7 @@ public class ChatMember { this.frequency = frequency; } + public long getActivityTimeLastInEpoch() { return activityTimeLastInEpoch; } @@ -595,6 +596,40 @@ public class ChatMember { } + /** + * Initializes the compatibility frequency only when no frequency is known yet. + * + *

This is used for an explicit QRG contained in the ON4KST station name. + * A subsequently detected chat QRG may overwrite this initial value, but a + * station-name QRG never replaces an already known frequency.

+ * + * @param frequencyMhz explicit station-name frequency + * @return true when the frequency was initialized + */ + public boolean initializeFrequencyIfEmpty(double frequencyMhz) { + if (!Double.isFinite(frequencyMhz) + || frequencyMhz <= 0.0) { + return false; + } + + if (frequency == null) { + frequency = new SimpleStringProperty(); + } + + String currentFrequency = frequency.get(); + + if (currentFrequency != null + && !currentFrequency.isBlank()) { + return false; + } + + frequency.set( + Double.toString(frequencyMhz) + ); + + return true; + } + /** * Sets all worked information of this object to false. Scope: GUI, Reset Button * for worked info, called by appcontroller diff --git a/src/main/java/kst4contest/test/FrequencyTextParserTest.java b/src/main/java/kst4contest/test/FrequencyTextParserTest.java new file mode 100644 index 0000000..7880bec --- /dev/null +++ b/src/main/java/kst4contest/test/FrequencyTextParserTest.java @@ -0,0 +1,123 @@ +package kst4contest.test; + +import kst4contest.logic.FrequencyTextParser; +import kst4contest.model.Band; +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 FrequencyTextParserTest { + + @Test + void detectsExplicitStationNameFrequencies() { + List detected = + FrequencyTextParser.findExplicitFrequencies( + "Phil 432.357" + ); + + assertEquals(1, detected.size()); + assertEquals( + Band.B_432, + detected.get(0).getBand() + ); + assertEquals( + 432.357, + detected.get(0).getFrequencyMHz(), + 0.000_001 + ); + } + + @Test + void detectsCommaAndMicrowaveFrequencies() { + List detected = + FrequencyTextParser.findExplicitFrequencies( + "QRV 1296,210 / 10368.100" + ); + + assertEquals(2, detected.size()); + + assertEquals( + Band.B_1296, + detected.get(0).getBand() + ); + + assertEquals( + Band.B_10G, + detected.get(1).getBand() + ); + } + + @Test + void detectsFiftyAndSeventyMhzFrequencies() { + assertEquals( + Band.B_50, + FrequencyTextParser + .findExplicitFrequencies("50.150") + .get(0) + .getBand() + ); + + assertEquals( + Band.B_70, + FrequencyTextParser + .findExplicitFrequencies("70.200") + .get(0) + .getBand() + ); + } + + @Test + void ignoresRelativeAndAmbiguousValues() { + assertTrue( + FrequencyTextParser + .findExplicitFrequencies( + "Mike .180" + ) + .isEmpty() + ); + + assertTrue( + FrequencyTextParser + .findExplicitFrequencies( + "Mike 180" + ) + .isEmpty() + ); + + assertTrue( + FrequencyTextParser + .findExplicitFrequencies( + "David 1.2" + ) + .isEmpty() + ); + + assertTrue( + FrequencyTextParser + .findExplicitFrequencies( + "RST 599" + ) + .isEmpty() + ); + } + + @Test + void normalizesSubKhzNotation() { + FrequencyTextParser.DetectedFrequency detected = + FrequencyTextParser + .findExplicitFrequencies( + "144.300.03" + ) + .get(0); + + assertEquals(Band.B_144, detected.getBand()); + assertEquals( + 144.30003, + detected.getFrequencyMHz(), + 0.000_001 + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java index eef0592..8595615 100644 --- a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java @@ -15,6 +15,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import kst4contest.logic.FrequencyTextParser; /** * Builds immutable map snapshots from the currently visible chat members. @@ -204,6 +205,92 @@ public final class MapCallsignRawSnapshotBuilder { )); } } + + } + + /* + * A QRG explicitly contained in the current station name does not expire. + * + * Recent dynamic QRG evidence has priority. Therefore station-name QRGs fill + * only bands for which no recent dynamic frequency is available. + * + * Different explicit QRGs for the same band are considered ambiguous and are + * not reduced to one arbitrary run frequency. + */ + Map> + stationNameFrequenciesByBand = + new EnumMap<>(Band.class); + + for (ChatMember variant : variants) { + if (variant == null) { + continue; + } + + for (FrequencyTextParser.DetectedFrequency detectedFrequency + : FrequencyTextParser.findExplicitFrequencies( + variant.getName() + )) { + + Band band = + detectedFrequency.getBand(); + + if (availableBands == null + || !availableBands.contains(band)) { + continue; + } + + stationNameFrequenciesByBand + .computeIfAbsent( + band, + ignored -> new LinkedHashMap<>() + ) + .putIfAbsent( + Double.toString( + detectedFrequency.getFrequencyMHz() + ), + detectedFrequency + ); + } + } + + for (Map.Entry< + Band, + Map> + entry : stationNameFrequenciesByBand.entrySet()) { + + Band band = entry.getKey(); + + /* + * A recent QRG detected from chat always wins. + */ + if (latestByBand.containsKey(band)) { + continue; + } + + /* + * Do not guess when several different QRGs were published for the + * same band. + */ + if (entry.getValue().size() != 1) { + continue; + } + + FrequencyTextParser.DetectedFrequency detectedFrequency = + entry.getValue() + .values() + .iterator() + .next(); + + latestByBand.put( + band, + new FrequencyCandidate( + band, + formatFrequency( + detectedFrequency.getFrequencyMHz() + ), + Long.MIN_VALUE + ) + ); } LinkedHashMap ordered = new LinkedHashMap<>(); diff --git a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java index 869a3d0..4343a20 100644 --- a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java +++ b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java @@ -14,6 +14,39 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class BandOpportunityResolverTest { + @Test + void explicitStationNameFrequencyProvidesBandEvidence() { + ChatMember station = new ChatMember(); + station.setName("Phil 432.357"); + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve( + List.of(station), + System.currentTimeMillis() + ); + + assertEquals( + EnumSet.of(Band.B_432), + resolution.getOfferedBands() + ); + } + + @Test + void relativeStationNameFrequencyDoesNotProvideBandEvidence() { + ChatMember station = new ChatMember(); + station.setName("Mike .180"); + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve( + List.of(station), + System.currentTimeMillis() + ); + + assertTrue( + resolution.getOfferedBands().isEmpty() + ); + } + @Test void resolvesCommonShorthandBandsFromStationName() { EnumSet bands = BandOpportunityResolver.detectBandsFromStationName( diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java index ef81478..5425dad 100644 --- a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java +++ b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java @@ -15,6 +15,70 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class MapCallsignRawSnapshotBuilderTest { + @Test + void explicitStationNameQrgIsShownInMapSnapshot() { + ChatMember station = + buildStation( + "G0JSB", + "Phil 432.357", + "IO91AA", + 1_000L + ); + + MapCallsignRawSnapshot snapshot = + new MapCallsignRawSnapshotBuilder() + .buildSnapshots( + List.of(station), + null, + EnumSet.of(Band.B_432) + ) + .get(0); + + assertEquals( + "432", + snapshot.bandSummary() + ); + + assertEquals( + "432.357", + snapshot + .lastKnownFrequenciesByBand() + .get("432") + ); + } + + @Test + void recentDynamicQrgWinsOverStationNameQrgInMap() { + ChatMember station = + buildStation( + "G0JSB", + "Phil 432.357", + "IO91AA", + 1_000L + ); + + station.addKnownFrequency( + Band.B_432, + 432.335 + ); + + MapCallsignRawSnapshot snapshot = + new MapCallsignRawSnapshotBuilder() + .buildSnapshots( + List.of(station), + null, + EnumSet.of(Band.B_432) + ) + .get(0); + + assertEquals( + "432.335", + snapshot + .lastKnownFrequenciesByBand() + .get("432") + ); + } + @Test void marksSnapshotWhenNameAdvertisesSelectedBand() { ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L); diff --git a/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java b/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java index 5e3b43e..80608bc 100644 --- a/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java +++ b/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java @@ -16,6 +16,113 @@ class PropagationFrequencyResolverTest { private static final long NOW = 10_000_000L; + + @Test + void exactStationNameQrgWinsOverBandAndCategoryFallback() { + ChatMember station = + station( + ChatCategory.VUHF, + "Phil 432.357" + ); + + PropagationFrequencyResolver.Resolution resolution = + resolve( + List.of(station), + EnumSet.of( + Band.B_144, + Band.B_432 + ) + ); + + assertEquals( + Band.B_432, + resolution.getBand() + ); + + assertEquals( + 432.357, + resolution.getAnalysisFrequencyMHz(), + 0.000_001 + ); + + assertEquals( + PropagationFrequencyResolver.Source.STATION_NAME_QRG, + resolution.getSource() + ); + } + + @Test + void recentChatQrgWinsOverExactStationNameQrg() { + ChatMember station = + station( + ChatCategory.VUHF, + "Phil 432.357" + ); + + addCurrentQrg( + station, + Band.B_432, + 432.335, + NOW - 1_000L + ); + + PropagationFrequencyResolver.Resolution resolution = + resolve( + List.of(station), + EnumSet.of(Band.B_432) + ); + + assertEquals( + 432.335, + resolution.getAnalysisFrequencyMHz(), + 0.000_001 + ); + + assertEquals( + PropagationFrequencyResolver.Source.CURRENT_QRG, + resolution.getSource() + ); + } + + @Test + void multipleStationNameQrgsAreNotTreatedAsOneRunFrequency() { + ChatMember station = + station( + ChatCategory.EMEJT65, + "QRV 432.357 1296.210" + ); + + PropagationFrequencyResolver.Resolution resolution = + resolve( + List.of(station), + EnumSet.of( + Band.B_432, + Band.B_1296 + ) + ); + + /* + * Both bands are known, but no exact run QRG is guessed. + * Existing station-name band selection therefore chooses the + * lowest usable advertised band. + */ + assertEquals( + Band.B_432, + resolution.getBand() + ); + + assertEquals( + 432.0, + resolution.getAnalysisFrequencyMHz(), + 0.000_001 + ); + + assertEquals( + PropagationFrequencyResolver.Source.STATION_NAME, + resolution.getSource() + ); + } + @Test void currentQrgWinsOverNameAndCategoryFallback() { ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");