diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 4ad58b5c..667e7491 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -1310,7 +1310,8 @@ public class MessageBusManagementThread extends Thread { ? null : sender.getFrequency().get(); - if (detectedFrequency != null && !detectedFrequency.isBlank()) { + if (detectedFrequency != null && !detectedFrequency.isBlank() + && !isAlreadyWorkedForDxClusterSpot(sender)) { /* * The DX Cluster spot must not depend on AirScout data. A known * frequency and a valid directional opportunity are sufficient. @@ -1329,6 +1330,10 @@ public class MessageBusManagementThread extends Thread { onlyForSpottingObject ); + } else if (detectedFrequency != null && !detectedFrequency.isBlank()) { + // Only reached when the worked check was the reason (issue #91). + LOGGER.fine("DX Cluster spot suppressed, station already worked on this band: " + + sender.getCallSign()); } } catch (Exception exception) { LOGGER.log(Level.WARNING, @@ -1852,6 +1857,51 @@ public class MessageBusManagementThread extends Thread { } + /** + * Decides whether an automatic DX Cluster spot must be suppressed because + * the sender has already been worked on the band of its known frequency. + * + *
Rules (issue #91):
+ *Manual spots from the map window and the settings test spot do not + * use this check.
+ * + * @param sender station that triggered the directional opportunity + * @return {@code true} when the automatic spot has to be suppressed + */ + static boolean isAlreadyWorkedForDxClusterSpot(ChatMember sender) { + if (sender == null || sender.getFrequency() == null) { + return false; + } + + FrequencyTextParser.DetectedFrequency detectedFrequency = + FrequencyTextParser.parseExplicitFrequency( + sender.getFrequency().get() + ); + + if (detectedFrequency == null) { + // Unknown band: keep the existing behaviour and allow the spot. + return false; + } + + if (sender.isWorkedOnBand(detectedFrequency.getBand())) { + return true; + } + + // Worked source without band information (Simplelogfile): global flag only. + return sender.isWorked() && !sender.hasAnyBandWorkedFlag(); + } + /** * Builds the comment transmitted with a local DX Cluster spot. * diff --git a/core/src/main/java/kst4contest/model/ChatMember.java b/core/src/main/java/kst4contest/model/ChatMember.java index 84b1d640..bd8c4f01 100644 --- a/core/src/main/java/kst4contest/model/ChatMember.java +++ b/core/src/main/java/kst4contest/model/ChatMember.java @@ -529,6 +529,76 @@ public class ChatMember { return worked; } + /** + * Returns the per-band worked flag that belongs to the given band. + * + *The flag names historically differ from the {@link Band} names: + * {@code B_1296} maps to {@link #isWorked1240()}, {@code B_2320} to + * {@link #isWorked2300()} and {@code B_5760} to {@link #isWorked5600()}. + * This is the same mapping that the worked gross-field cache and the + * external-log band mapping use. The global {@link #isWorked()} flag is + * deliberately not consulted here.
+ * + * @param band band to check, may be {@code null} + * @return {@code true} when the station is marked worked on that band; + * {@code false} for {@code null} or a band without a flag + */ + public boolean isWorkedOnBand(Band band) { + if (band == null) { + return false; + } + + switch (band) { + case B_50: + return isWorked50(); + case B_70: + return isWorked70(); + case B_144: + return isWorked144(); + case B_432: + return isWorked432(); + case B_1296: + return isWorked1240(); + case B_2320: + return isWorked2300(); + case B_3400: + return isWorked3400(); + case B_5760: + return isWorked5600(); + case B_10G: + return isWorked10G(); + case B_24G: + return isWorked24G(); + default: + return false; + } + } + + /** + * Tells whether any per-band worked flag is set. + * + *Sources without band information (for example the Simplelogfile + * interpreter) only set the global {@link #isWorked()} flag. A caller can + * use this method to distinguish such a station from one whose per-band + * state is known. The global flag itself is not part of the result.
+ * + * @return {@code true} when at least one per-band flag is set + */ + public boolean hasAnyBandWorkedFlag() { + return isWorked50() + || isWorked70() + || isWorked144() + || isWorked432() + || isWorked1240() + || isWorked2300() + || isWorked3400() + || isWorked5600() + || isWorked10G() + || isWorked24G() + || isWorked47G() + || isWorked76G(); + } + public boolean isWorked50() { return Worked50; } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterWorkedTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterWorkedTest.java new file mode 100644 index 00000000..2b20a2d6 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterWorkedTest.java @@ -0,0 +1,115 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import kst4contest.model.ChatMember; +import kst4contest.observe.SimpleValue; +import org.junit.jupiter.api.Test; + +/** + * Covers the worked-state check that suppresses automatic DX Cluster spots + * (issue #91). Manual spots do not use this predicate. + */ +class MessageBusManagementThreadDxClusterWorkedTest { + + @Test + void suppressesSpotWhenWorkedOnDetectedBand() { + ChatMember sender = senderOn("144.3"); + sender.setWorked144(true); + + assertTrue(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenWorkedOnlyOnAnotherBand() { + ChatMember sender = senderOn("432.2"); + sender.setWorked144(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void mapsBand1296ToWorked1240Flag() { + ChatMember sender = senderOn("1296.2"); + sender.setWorked1240(true); + + assertTrue(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenNothingIsWorked() { + ChatMember sender = senderOn("144.3"); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void globalWorkedFlagWithoutBandInformationSuppressesSpot() { + // Simplelogfile source: only the global flag is known, no per-band flags. + ChatMember sender = senderOn("144.3"); + sender.setWorked(true); + + assertTrue(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void globalWorkedFlagWithOtherBandInformationAllowsSpot() { + // Per-band information exists and says 432 only, so 144 is still open. + ChatMember sender = senderOn("144.3"); + sender.setWorked(true); + sender.setWorked432(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenFrequencyWrapperIsNull() { + ChatMember sender = new ChatMember(); + sender.setFrequency(null); + sender.setWorked(true); + sender.setWorked144(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenFrequencyValueIsNull() { + ChatMember sender = new ChatMember(); + sender.setFrequency(new SimpleValue<>()); + sender.setWorked(true); + sender.setWorked144(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenFrequencyIsEmpty() { + ChatMember sender = senderOn(""); + sender.setWorked(true); + sender.setWorked144(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenFrequencyIsOutsideKnownBands() { + ChatMember sender = senderOn("999.9"); + sender.setWorked(true); + sender.setWorked144(true); + + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(sender)); + } + + @Test + void allowsSpotWhenSenderIsNull() { + assertFalse(MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot(null)); + } + + private static ChatMember senderOn(String frequencyMHz) { + ChatMember sender = new ChatMember(); + sender.setCallSign("DL1ABC"); + sender.setFrequency(new SimpleValue<>(frequencyMHz)); + return sender; + } +} diff --git a/core/src/test/java/kst4contest/model/ChatMemberWorkedOnBandTest.java b/core/src/test/java/kst4contest/model/ChatMemberWorkedOnBandTest.java new file mode 100644 index 00000000..a4f87a78 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatMemberWorkedOnBandTest.java @@ -0,0 +1,174 @@ +package kst4contest.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class ChatMemberWorkedOnBandTest { + + @ParameterizedTest + @EnumSource(Band.class) + void noFlagSetMeansNotWorkedOnAnyBand(Band band) { + ChatMember member = new ChatMember(); + + assertFalse(member.isWorkedOnBand(band)); + } + + @Test + void nullBandIsNeverWorked() { + ChatMember member = new ChatMember(); + member.setWorked144(true); + + assertFalse(member.isWorkedOnBand(null)); + } + + @Test + void globalWorkedFlagDoesNotCountAsBandFlag() { + ChatMember member = new ChatMember(); + member.setWorked(true); + + for (Band band : Band.values()) { + assertFalse(member.isWorkedOnBand(band), band.name()); + } + } + + @Test + void worked50MapsToBand50() { + ChatMember member = new ChatMember(); + member.setWorked50(true); + + assertOnlyBandWorked(member, Band.B_50); + } + + @Test + void worked70MapsToBand70() { + ChatMember member = new ChatMember(); + member.setWorked70(true); + + assertOnlyBandWorked(member, Band.B_70); + } + + @Test + void worked144MapsToBand144() { + ChatMember member = new ChatMember(); + member.setWorked144(true); + + assertOnlyBandWorked(member, Band.B_144); + } + + @Test + void worked432MapsToBand432() { + ChatMember member = new ChatMember(); + member.setWorked432(true); + + assertOnlyBandWorked(member, Band.B_432); + } + + @Test + void worked1240MapsToBand1296() { + ChatMember member = new ChatMember(); + member.setWorked1240(true); + + assertOnlyBandWorked(member, Band.B_1296); + } + + @Test + void worked2300MapsToBand2320() { + ChatMember member = new ChatMember(); + member.setWorked2300(true); + + assertOnlyBandWorked(member, Band.B_2320); + } + + @Test + void worked3400MapsToBand3400() { + ChatMember member = new ChatMember(); + member.setWorked3400(true); + + assertOnlyBandWorked(member, Band.B_3400); + } + + @Test + void worked5600MapsToBand5760() { + ChatMember member = new ChatMember(); + member.setWorked5600(true); + + assertOnlyBandWorked(member, Band.B_5760); + } + + @Test + void worked10GMapsToBand10G() { + ChatMember member = new ChatMember(); + member.setWorked10G(true); + + assertOnlyBandWorked(member, Band.B_10G); + } + + @Test + void worked24GMapsToBand24G() { + ChatMember member = new ChatMember(); + member.setWorked24G(true); + + assertOnlyBandWorked(member, Band.B_24G); + } + + @Test + void hasAnyBandWorkedFlagIsFalseWithoutFlags() { + ChatMember member = new ChatMember(); + + assertFalse(member.hasAnyBandWorkedFlag()); + } + + @Test + void hasAnyBandWorkedFlagIgnoresGlobalWorkedFlag() { + ChatMember member = new ChatMember(); + member.setWorked(true); + + assertFalse(member.hasAnyBandWorkedFlag()); + } + + @Test + void hasAnyBandWorkedFlagDetectsEachBandFlag() { + assertTrue(memberWith(m -> m.setWorked50(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked70(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked144(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked432(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked1240(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked2300(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked3400(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked5600(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked10G(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked24G(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked47G(true)).hasAnyBandWorkedFlag()); + assertTrue(memberWith(m -> m.setWorked76G(true)).hasAnyBandWorkedFlag()); + } + + @Test + void hasAnyBandWorkedFlagIsFalseAfterReset() { + ChatMember member = new ChatMember(); + member.setWorked144(true); + member.setWorked76G(true); + member.resetWorkedInformationAtAllBands(); + + assertFalse(member.hasAnyBandWorkedFlag()); + } + + private static void assertOnlyBandWorked(ChatMember member, Band expected) { + for (Band band : Band.values()) { + if (band == expected) { + assertTrue(member.isWorkedOnBand(band), band.name()); + } else { + assertFalse(member.isWorkedOnBand(band), band.name()); + } + } + } + + private static ChatMember memberWith(java.util.function.Consumer