mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-11 23:42:27 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a90ae7d30
|
||
|
|
78de8b0a9c
|
||
|
|
ab28216280
|
||
|
|
456957808d
|
||
|
|
e67e2db2aa
|
||
|
|
93c1423dcd
|
||
|
|
eddcbc82e8
|
||
|
|
ab42c6ef35
|
||
|
|
0039ec98e3
|
||
|
|
294a978c3a
|
||
|
|
19b9bf0fca
|
||
|
|
5b9fe0d3c1
|
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,27 @@ object AirplaneCell {
|
||||
*/
|
||||
private const val NOTHING_COMING = "nil"
|
||||
|
||||
// ---- the order ----------------------------------------------------------
|
||||
|
||||
/** Unknown or no aeroplane sorts after every real one. */
|
||||
private const val NO_PLANE = Int.MAX_VALUE
|
||||
|
||||
/** The aeroplane the cell shows first; the parser already put the best one there. */
|
||||
private fun first(info: AirPlaneReflectionInfo?): AirPlane? = info?.risingAirplanes?.firstOrNull()
|
||||
|
||||
/**
|
||||
* Ascending: the minutes the operator reads first, then the better potential first.
|
||||
*
|
||||
* Issue #87: without a comparator the table fell back to the rendered text, which put
|
||||
* "13 (75%)" above "3 (50%)". Only the first aeroplane counts, as it is the one the
|
||||
* operator reads and the one the colour marks; the second is in the cell for context.
|
||||
* Empty and "nil" cells carry [NO_PLANE]: like qrb and qtf, an absent value gets a
|
||||
* sentinel instead of a null check, here the maximum so the cell sorts last ascending.
|
||||
*/
|
||||
val order: Comparator<AirPlaneReflectionInfo?> =
|
||||
compareBy<AirPlaneReflectionInfo?> { first(it)?.arrivingDurationMinutes ?: NO_PLANE }
|
||||
.thenByDescending { first(it)?.potential ?: 0 }
|
||||
|
||||
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
|
||||
val PURPLE_100 = Color(0xFFF98AFF)
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ object DirectedMessageColumns {
|
||||
title = "AP [minutes / pot%]",
|
||||
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
|
||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
|
||||
weight = 1.4f,
|
||||
),
|
||||
DataColumn(
|
||||
|
||||
@@ -55,6 +55,7 @@ object StationColumns {
|
||||
title = "AP [minutes / pot%]",
|
||||
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
|
||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
|
||||
weight = 1.4f,
|
||||
))
|
||||
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
|
||||
|
||||
@@ -4,6 +4,7 @@ import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
@@ -97,6 +98,70 @@ class AirplaneCellTest {
|
||||
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
|
||||
}
|
||||
|
||||
// ---- the order ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Issue #87: the column was sorted by its rendered text, so "13 (75%)" came before
|
||||
* "3 (50%)". These are the thirteen cells of the screenshot in the issue, in the order
|
||||
* they were shown; sorted, the minutes have to read as numbers.
|
||||
*/
|
||||
@Test
|
||||
fun `the minutes sort as numbers, not as text`() {
|
||||
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
|
||||
|
||||
assertEquals(
|
||||
listOf(1, 1, 3, 3, 6, 6, 8, 8, 9, 9, 13, 14, 19),
|
||||
sorted.map { it.risingAirplanes.first().arrivingDurationMinutes },
|
||||
)
|
||||
}
|
||||
|
||||
/** Same minutes: the better potential first, which is also the one the colour marks. */
|
||||
@Test
|
||||
fun `equal minutes put the better potential first`() {
|
||||
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
|
||||
|
||||
assertEquals(
|
||||
listOf("3 (75%)", "3 (50%)"),
|
||||
sorted.map { AirplaneCell.format(it) }.filter { it.startsWith("3 ") },
|
||||
"the screenshot had 3 (50%) above 3 (75%); the order is not just stable",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty cell (nothing computed) and "nil" (no aeroplane coming) both carry the
|
||||
* maximum sentinel, so ascending puts them after the last real aeroplane.
|
||||
*/
|
||||
@Test
|
||||
fun `no aeroplane sorts after the last real one`() {
|
||||
val nothingComputed: AirPlaneReflectionInfo? = null
|
||||
val nothingComing = reflectionInfo()
|
||||
val cells = listOf(nothingComputed, nothingComing) + screenshotCells()
|
||||
|
||||
val sorted = cells.sortedWith(AirplaneCell.order)
|
||||
|
||||
assertEquals(19, sorted[sorted.size - 3]?.risingAirplanes?.first()?.arrivingDurationMinutes)
|
||||
assertSame(nothingComputed, sorted[sorted.size - 2])
|
||||
assertSame(nothingComing, sorted[sorted.size - 1])
|
||||
}
|
||||
|
||||
/** Only the first aeroplane, the one the operator reads first, decides. */
|
||||
@Test
|
||||
fun `the second aeroplane of a cell does not influence the order`() {
|
||||
val nineThenOne = reflectionInfo(plane(9, 75), plane(1, 100))
|
||||
val threeThenTwenty = reflectionInfo(plane(3, 50), plane(20, 50))
|
||||
|
||||
val sorted = listOf(nineThenOne, threeThenTwenty).sortedWith(AirplaneCell.order)
|
||||
|
||||
assertSame(threeThenTwenty, sorted[0])
|
||||
assertSame(nineThenOne, sorted[1])
|
||||
}
|
||||
|
||||
/** The AP column of the issue's screenshot, top to bottom: (minutes, potential). */
|
||||
private fun screenshotCells(): List<AirPlaneReflectionInfo> = listOf(
|
||||
1 to 75, 1 to 75, 13 to 75, 14 to 50, 19 to 50, 3 to 50, 3 to 75,
|
||||
6 to 75, 6 to 75, 8 to 75, 8 to 75, 9 to 75, 9 to 75,
|
||||
).map { (minutes, potential) -> reflectionInfo(plane(minutes, potential)) }
|
||||
|
||||
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
|
||||
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -98,6 +99,33 @@ class DirectedMessageColumnsTest {
|
||||
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
|
||||
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
|
||||
* comparator sorts by it, not by its text", with the real columns.
|
||||
*/
|
||||
@Test
|
||||
fun `airscout sorts numerically`() {
|
||||
assertNotNull(columns.single { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
|
||||
|
||||
val table = DataTableState(columns = columns, rowKey = RowKeys.byReference<ChatMessage>())
|
||||
table.replaceRows(listOf(
|
||||
message(sender = memberWithAeroplane("DL13ABC", minutes = 13)),
|
||||
message(sender = memberWithAeroplane("DL9ABC", minutes = 9)),
|
||||
))
|
||||
|
||||
table.toggleSort("airscout")
|
||||
|
||||
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.sender?.callSign },
|
||||
"9 before 13 numerically; by text it would be the other way round")
|
||||
}
|
||||
|
||||
private fun memberWithAeroplane(call: String, minutes: Int): ChatMember = member(call).apply {
|
||||
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
|
||||
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the category names the chat the message arrived in`() {
|
||||
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
@@ -120,6 +123,32 @@ class StationColumnsTest {
|
||||
assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
|
||||
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
|
||||
* comparator sorts by it, not by its text", with the real columns.
|
||||
*/
|
||||
@Test
|
||||
fun `airscout sorts numerically`() {
|
||||
val columns = columns()
|
||||
assertNotNull(columns.first { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
|
||||
|
||||
val table = DataTableState(columns = columns, rowKey = RowKeys.byValue<ChatMember> { it.callSign.orEmpty() })
|
||||
table.replaceRows(listOf(stationWithAeroplane("DL13ABC", minutes = 13), stationWithAeroplane("DL9ABC", minutes = 9)))
|
||||
|
||||
table.toggleSort("airscout")
|
||||
|
||||
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.callSign },
|
||||
"9 before 13 numerically; by text it would be the other way round")
|
||||
}
|
||||
|
||||
private fun stationWithAeroplane(call: String, minutes: Int): ChatMember = ChatMember().apply {
|
||||
setCallSign(call)
|
||||
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
|
||||
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tropo and score come from outside the station`() {
|
||||
val columns = StationColumns.all(
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Rules (issue #91):</p>
|
||||
* <ul>
|
||||
* <li>No sender, no frequency or a frequency outside the supported
|
||||
* bands: the existing behaviour is kept and the spot is allowed.</li>
|
||||
* <li>The per-band worked flag of the detected band is set: suppress.</li>
|
||||
* <li>Only the global worked flag is set and no per-band flag exists
|
||||
* (Simplelogfile source without band information): suppress, because
|
||||
* the band cannot be told apart.</li>
|
||||
* <li>The global flag is set together with per-band flags for other
|
||||
* bands: allow, because the per-band information is authoritative.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Manual spots from the map window and the settings test spot do not
|
||||
* use this check.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
|
||||
@@ -529,6 +529,76 @@ public class ChatMember {
|
||||
return worked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the per-band worked flag that belongs to the given band.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
+115
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMember> setup) {
|
||||
ChatMember member = new ChatMember();
|
||||
setup.accept(member);
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
- Comments are padded or truncated to exactly 30 characters. Automatic AirScout comments retain the locator first and use the compact form `JO51HK AP 1m/100%;4m/75%`.
|
||||
- A DX callsign longer than 12 characters is rejected and logged rather than truncated.
|
||||
- Trigger conditions, QRG recognition and normalisation, login, keepalive, multi-client delivery and the local-only trust boundary remain separate from line formatting.
|
||||
- Automatic spots are suppressed for stations already worked on the band of the sender's known frequency (`MessageBusManagementThread.isAlreadyWorkedForDxClusterSpot`); when only the global Worked flag exists without any per-band flag (Simplelogfile source), the global flag counts, an unknown band keeps the previous behaviour, and manual spots from the map window and the settings test spot are not affected (#91).
|
||||
|
||||
### Logging and Worked-state persistence
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
||||
|
||||
- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter.
|
||||
|
||||
- **AP-Spalte sortiert numerisch:** Die Spalte **AP [minutes / pot%]** in der Benutzerliste und bei den gerichteten Nachrichten wurde nach ihrem Text sortiert, sodass `13 (75%)` vor `3 (50%)` stand. Jetzt sortiert sie nach den Minuten des ersten Flugzeugs und bei gleichen Minuten nach dem besseren Potenzial; leere und `nil`-Zellen stehen aufsteigend am Ende. Damit ist [Issue #87](https://github.com/praktimarc/kst4contest/issues/87) behoben.
|
||||
- **Keine automatischen Cluster-Spots für gearbeitete Stationen:** Der lokale DX-Cluster-Server erzeugte Spots aus Richtungsgelegenheiten, ohne den Worked-Status zu prüfen. Jetzt wird kein Spot mehr erzeugt, wenn die Station auf dem Band der erkannten Frequenz bereits gearbeitet ist; ohne bandbezogene Information zählt der allgemeine Worked-Status. Manuelle Spots aus der Karte und der Test-Spot in den Einstellungen bleiben unverändert. Damit ist [Issue #91](https://github.com/praktimarc/kst4contest/issues/91) behoben.
|
||||
|
||||
### Hinweise zur Aktualisierung
|
||||
|
||||
- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert.
|
||||
|
||||
@@ -43,7 +43,8 @@ Ein DX-Cluster-Spot wird nur erzeugt, wenn alle folgenden Bedingungen erfüllt s
|
||||
3. Der Absender liegt innerhalb des konfigurierten maximalen QRB zur eigenen Station.
|
||||
4. Die eigene Station liegt aus Sicht des Absenders innerhalb des angenommenen Antennenkorridors.
|
||||
5. Für den Absender ist eine verwertbare Frequenz bekannt oder in der aktuellen Nachricht erkannt worden.
|
||||
6. Der lokale DX-Cluster-Server ist aktiviert.
|
||||
6. Der Absender ist auf dem Band der erkannten Frequenz noch nicht gearbeitet. Liegt kein bandbezogener Worked-Status vor, etwa aus dem Simplelogfile, zählt der allgemeine Worked-Status.
|
||||
7. Der lokale DX-Cluster-Server ist aktiviert.
|
||||
|
||||
Treffen die Bedingungen zu, wird der Spot unmittelbar beim Verarbeiten der Nachricht erzeugt. Die parallel angezeigte grüne Richtungsmarkierung bleibt dagegen fünf Minuten sichtbar und kann durch spätere Nachrichten verlängert oder vorzeitig entfernt werden.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Konfiguration:
|
||||
|
||||
## Weitergabe als DX-Cluster-Spot
|
||||
|
||||
Seit Version 1.23 kann KST4Contest eine erkannte Richtungsgelegenheit an den DX-Cluster-Client eines Logprogramms weitergeben. Dafür muss der lokale DX-Cluster-Server aktiviert und für den Absender eine verwertbare Frequenz bekannt sein.
|
||||
Seit Version 1.23 kann KST4Contest eine erkannte Richtungsgelegenheit an den DX-Cluster-Client eines Logprogramms weitergeben. Dafür muss der lokale DX-Cluster-Server aktiviert und für den Absender eine verwertbare Frequenz bekannt sein. Stationen, die auf dem Band dieser Frequenz bereits gearbeitet sind, werden nicht automatisch gespottet.
|
||||
|
||||
Die Frequenz kann bereits aus einer früheren Nachricht stammen oder erstmals in der aktuell auslösenden Nachricht stehen. In beiden Fällen steht sie der Spot-Prüfung zur Verfügung. KST4Contest überträgt damit nicht jede im Chat gefundene QRG, sondern nur Frequenzen, die mit einer geometrisch passenden gerichteten Nachricht zusammenfallen.
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ Several operators sharing one computer can now use their own callsigns, locators
|
||||
|
||||
- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended.
|
||||
|
||||
- **AP column sorts numerically:** the **AP [minutes / pot%]** column in the user list and in the directed messages was sorted by its text, which put `13 (75%)` above `3 (50%)`. It now sorts by the minutes of the first aeroplane and, for equal minutes, by the better potential first; empty and `nil` cells go to the bottom in ascending order. This fixes [Issue #87](https://github.com/praktimarc/kst4contest/issues/87).
|
||||
- **No automatic cluster spots for worked stations:** the local DX Cluster server generated spots from directional opportunities without checking the worked state. A spot is no longer created when the station has already been worked on the band of the detected frequency; without per-band information the global worked state counts. Manual spots from the map and the test spot in the settings are unchanged. This fixes [Issue #91](https://github.com/praktimarc/kst4contest/issues/91).
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.
|
||||
|
||||
@@ -43,7 +43,8 @@ An automatic DX Cluster spot is created only when all of the following condition
|
||||
3. The sender is within the configured **Default Maximum QRB** from the local station.
|
||||
4. The local station lies inside the assumed antenna corridor as seen from the sender.
|
||||
5. A usable frequency is known for the sender or detected in the current message.
|
||||
6. The local DX Cluster server is enabled.
|
||||
6. The sender has not yet been worked on the band of the detected frequency. When no per-band worked state exists, for example from the Simplelogfile, the global worked state counts.
|
||||
7. The local DX Cluster server is enabled.
|
||||
|
||||
When these conditions are met, the spot is created while the message is processed. The green directional highlight shown in parallel remains visible for five minutes and may be extended or removed by later messages.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Configuration:
|
||||
|
||||
## Forwarding as a DX Cluster Spot
|
||||
|
||||
Since version 1.23, KST4Contest can forward a detected directional opportunity to the DX Cluster client of a logging programme. The local DX Cluster server must be enabled and a usable frequency must be known for the sender.
|
||||
Since version 1.23, KST4Contest can forward a detected directional opportunity to the DX Cluster client of a logging programme. The local DX Cluster server must be enabled and a usable frequency must be known for the sender. Stations already worked on the band of that frequency are not spotted automatically.
|
||||
|
||||
The frequency may come from an earlier message or may be detected for the first time in the message which triggers the opportunity. In both cases it is available when KST4Contest checks whether it can create a spot. The programme therefore does not forward every QRG found in the chat. Automatic spots require a geometrically matching directed message.
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[versions]
|
||||
sqliteJdbc = "3.43.2.2"
|
||||
sqliteJdbc = "3.53.4.0"
|
||||
jlayer = "1.0.1"
|
||||
junit = "5.10.1"
|
||||
mockito = "5.7.0"
|
||||
mockitoCompile = "5.17.0"
|
||||
jetbrainsAnnotations = "24.0.1"
|
||||
junit = "6.1.3"
|
||||
mockito = "5.24.0"
|
||||
mockitoCompile = "5.24.0"
|
||||
jetbrainsAnnotations = "26.1.0"
|
||||
kotlin = "2.2.0"
|
||||
composeMultiplatform = "1.8.2"
|
||||
spotbugsPlugin = "6.0.26"
|
||||
spotbugsPlugin = "6.5.12"
|
||||
spotbugs = "4.9.8"
|
||||
pmd = "7.17.0"
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
Vendored
+41
-11
@@ -26,6 +26,33 @@
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||
@rem This can be removed once we can be reasonably confident that few people
|
||||
@rem will be migrating directly to this new wrapper.
|
||||
goto afterSafetyNet
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
goto exitWithErrorLevel
|
||||
:afterSafetyNet
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
Reference in new issue
Block a user