mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 02:57:43 +02:00
Compare commits
4
Commits
9cccdb73e9
...
6966edbfff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6966edbfff | ||
|
|
42d4b72dd6 | ||
|
|
15f585c938 | ||
|
|
663f724c98 |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
+1013
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1490,6 +1490,38 @@ private ObservableList<String>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses one unambiguous explicit QRG from the current station name as the
|
||||||
|
* initial compatibility frequency.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
private void initializeFrequencyFromStationNameIfUnambiguous(
|
||||||
|
ChatMember member
|
||||||
|
) {
|
||||||
|
if (member == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FrequencyTextParser.DetectedFrequency> 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
|
* 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
|
* that change to the JavaFX list. This is the only supported path for ON4KST
|
||||||
@@ -1501,6 +1533,8 @@ private ObservableList<String>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||||
|
|
||||||
activeChatMembersByCallAndCategory.put(key, member);
|
activeChatMembersByCallAndCategory.put(key, member);
|
||||||
|
|
||||||
runOnFxThread(() -> {
|
runOnFxThread(() -> {
|
||||||
@@ -1683,6 +1717,9 @@ private ObservableList<String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeMember.setName(updatedMember.getName());
|
activeMember.setName(updatedMember.getName());
|
||||||
|
initializeFrequencyFromStationNameIfUnambiguous(
|
||||||
|
activeMember
|
||||||
|
);
|
||||||
activeMember.setQra(updatedMember.getQra());
|
activeMember.setQra(updatedMember.getQra());
|
||||||
activeMember.setState(updatedMember.getState());
|
activeMember.setState(updatedMember.getState());
|
||||||
activeMember.setLastActivity(updatedMember.getLastActivity());
|
activeMember.setLastActivity(updatedMember.getLastActivity());
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kst4contest.ApplicationConstants;
|
|||||||
import kst4contest.locatorUtils.DirectionUtils;
|
import kst4contest.locatorUtils.DirectionUtils;
|
||||||
import kst4contest.locatorUtils.Location;
|
import kst4contest.locatorUtils.Location;
|
||||||
import kst4contest.model.*;
|
import kst4contest.model.*;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -58,7 +59,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* would be converted into plausible but incorrect frequencies.
|
* would be converted into plausible but incorrect frequencies.
|
||||||
*/
|
*/
|
||||||
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
||||||
"(?<![\\d])(\\d{3,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||||
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||||
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
||||||
);
|
);
|
||||||
@@ -319,16 +320,28 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String reconstructed =
|
String reconstructed =
|
||||||
candidateBand.getPrefix() + "." + foundRaw;
|
candidateBand.getPrefix()
|
||||||
double candidateFrequency = Double.parseDouble(
|
+ "."
|
||||||
normalizeFrequencyString(reconstructed)
|
+ foundRaw;
|
||||||
|
|
||||||
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
|
reconstructed
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidateBand.isPlausible(candidateFrequency)
|
if (detectedFrequency != null
|
||||||
|
&& detectedFrequency.getBand() == candidateBand
|
||||||
&& info.timestampEpoch > bestTimestamp) {
|
&& info.timestampEpoch > bestTimestamp) {
|
||||||
finalDetectedFrequency = candidateFrequency;
|
|
||||||
finalDetectedBand = candidateBand;
|
finalDetectedFrequency =
|
||||||
bestTimestamp = info.timestampEpoch;
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
candidateBand;
|
||||||
|
|
||||||
|
bestTimestamp =
|
||||||
|
info.timestampEpoch;
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
// Try the next known band.
|
// Try the next known band.
|
||||||
@@ -357,27 +370,40 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String reconstructed =
|
String reconstructed =
|
||||||
fallbackBand.getPrefix() + "." + foundRaw;
|
fallbackBand.getPrefix()
|
||||||
double candidateFrequency = Double.parseDouble(
|
+ "."
|
||||||
normalizeFrequencyString(reconstructed)
|
+ foundRaw;
|
||||||
|
|
||||||
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
|
reconstructed
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fallbackBand.isPlausible(candidateFrequency)) {
|
if (detectedFrequency != null
|
||||||
finalDetectedFrequency = candidateFrequency;
|
&& detectedFrequency.getBand() == fallbackBand) {
|
||||||
finalDetectedBand = fallbackBand;
|
|
||||||
|
finalDetectedFrequency =
|
||||||
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
fallbackBand;
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
// The matched value cannot be converted into a frequency.
|
// The matched value cannot be converted into a frequency.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
finalDetectedFrequency = Double.parseDouble(
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
normalizeFrequencyString(foundRaw)
|
foundRaw
|
||||||
);
|
);
|
||||||
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
|
|
||||||
} catch (NumberFormatException ignored) {
|
if (detectedFrequency != null) {
|
||||||
// Continue with the next possible match in the message.
|
finalDetectedFrequency =
|
||||||
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
detectedFrequency.getBand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,22 +488,22 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* Example: "144.210.10" -> "144.21010"
|
* Example: "144.210.10" -> "144.21010"
|
||||||
* Example: "144.210" -> "144.210"
|
* Example: "144.210" -> "144.210"
|
||||||
*/
|
*/
|
||||||
private String normalizeFrequencyString(String rawInput) {
|
// private String normalizeFrequencyString(String rawInput) {
|
||||||
// Input is already guaranteed to have only dots as separators (commas replaced earlier)
|
// // Input is already guaranteed to have only dots as separators (commas replaced earlier)
|
||||||
|
//
|
||||||
int firstDotIndex = rawInput.indexOf(".");
|
// int firstDotIndex = rawInput.indexOf(".");
|
||||||
|
//
|
||||||
if (firstDotIndex != -1) {
|
// if (firstDotIndex != -1) {
|
||||||
// Check if there are more dots after the first one
|
// // Check if there are more dots after the first one
|
||||||
String decimalPart = rawInput.substring(firstDotIndex + 1);
|
// String decimalPart = rawInput.substring(firstDotIndex + 1);
|
||||||
if (decimalPart.contains(".")) {
|
// if (decimalPart.contains(".")) {
|
||||||
// Remove all subsequent dots to make it a valid double
|
// // Remove all subsequent dots to make it a valid double
|
||||||
decimalPart = decimalPart.replace(".", "");
|
// decimalPart = decimalPart.replace(".", "");
|
||||||
return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
|
// return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return rawInput;
|
// return rawInput;
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -113,12 +113,32 @@ public final class BandOpportunityResolver {
|
|||||||
return detectedBands;
|
return detectedBands;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Explicit band descriptions:
|
||||||
|
* 2m, 70cm, 23cm, 432, 1296, ...
|
||||||
|
*/
|
||||||
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
|
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
|
||||||
if (entry.getValue().matcher(stationName).find()) {
|
if (entry.getValue().matcher(stationName).find()) {
|
||||||
detectedBands.add(entry.getKey());
|
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;
|
return detectedBands;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>This parser deliberately handles only complete frequencies such as
|
||||||
|
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
|
||||||
|
* bare values such as "210" require additional message context and remain the
|
||||||
|
* responsibility of the chat-message parser.</p>
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
"(?<![A-Z0-9])"
|
||||||
|
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
|
||||||
|
+ "(?!\\d)",
|
||||||
|
Pattern.CASE_INSENSITIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
private FrequencyTextParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all distinct explicit frequencies that fall into one of the bands
|
||||||
|
* supported by {@link Band}.
|
||||||
|
*/
|
||||||
|
public static List<DetectedFrequency> findExplicitFrequencies(String text) {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = EXPLICIT_FREQUENCY_PATTERN.matcher(text);
|
||||||
|
|
||||||
|
Map<String, DetectedFrequency> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public final class PropagationFrequencyResolver {
|
|||||||
/** Explains why a frequency was selected. */
|
/** Explains why a frequency was selected. */
|
||||||
public enum Source {
|
public enum Source {
|
||||||
CURRENT_QRG,
|
CURRENT_QRG,
|
||||||
|
STATION_NAME_QRG,
|
||||||
STATION_NAME,
|
STATION_NAME,
|
||||||
DUAL_CATEGORY_FALLBACK,
|
DUAL_CATEGORY_FALLBACK,
|
||||||
CHAT_CATEGORY
|
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<Band> nameBands = EnumSet.noneOf(Band.class);
|
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
|
||||||
for (ChatMember variant : supportedVariants) {
|
for (ChatMember variant : supportedVariants) {
|
||||||
nameBands.addAll(
|
nameBands.addAll(
|
||||||
@@ -172,6 +187,73 @@ public final class PropagationFrequencyResolver {
|
|||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one exact station-name QRG only when the evidence is unambiguous.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
private static FrequencyCandidate findUniqueStationNameQrg(
|
||||||
|
List<ChatMember> variants,
|
||||||
|
EnumSet<Band> usableBands
|
||||||
|
) {
|
||||||
|
java.util.LinkedHashMap<String, FrequencyCandidate> 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) {
|
private static boolean isSupportedVariant(ChatMember member) {
|
||||||
if (member == null || member.getChatCategory() == null) {
|
if (member == null || member.getChatCategory() == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ public class ChatMember {
|
|||||||
this.frequency = frequency;
|
this.frequency = frequency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public long getActivityTimeLastInEpoch() {
|
public long getActivityTimeLastInEpoch() {
|
||||||
return activityTimeLastInEpoch;
|
return activityTimeLastInEpoch;
|
||||||
}
|
}
|
||||||
@@ -595,6 +596,40 @@ public class ChatMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the compatibility frequency only when no frequency is known yet.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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
|
* Sets all worked information of this object to false. Scope: GUI, Reset Button
|
||||||
* for worked info, called by appcontroller
|
* for worked info, called by appcontroller
|
||||||
|
|||||||
@@ -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<FrequencyTextParser.DetectedFrequency> 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<FrequencyTextParser.DetectedFrequency> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds immutable map snapshots from the currently visible chat members.
|
* 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<Band, Map<String, FrequencyTextParser.DetectedFrequency>>
|
||||||
|
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<String, FrequencyTextParser.DetectedFrequency>>
|
||||||
|
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<String, String> ordered = new LinkedHashMap<>();
|
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ public final class StationMapBridge {
|
|||||||
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
||||||
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
||||||
|
|
||||||
|
stationMapView.setOnResetView(this::handleMapReset);
|
||||||
|
|
||||||
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
||||||
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
||||||
);
|
);
|
||||||
@@ -98,6 +100,35 @@ public final class StationMapBridge {
|
|||||||
requestImmediateRefresh();
|
requestImmediateRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMapReset() {
|
||||||
|
Runnable resetAction = () -> {
|
||||||
|
/*
|
||||||
|
* Ignore an analysis result that may still arrive for the
|
||||||
|
* previously selected station.
|
||||||
|
*/
|
||||||
|
pathAnalysisGeneration.incrementAndGet();
|
||||||
|
lastPathAnalysisRequestSignature = "";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Clear the application's central station selection.
|
||||||
|
*/
|
||||||
|
chatController.getScoreService().setSelectedChatMember(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keep the main station table synchronized with the central selection.
|
||||||
|
*/
|
||||||
|
chatMemberTable.getSelectionModel().clearSelection();
|
||||||
|
|
||||||
|
requestImmediateRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Platform.isFxApplicationThread()) {
|
||||||
|
resetAction.run();
|
||||||
|
} else {
|
||||||
|
Platform.runLater(resetAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void showWindow() {
|
public void showWindow() {
|
||||||
stationMapView.showWindow();
|
stationMapView.showWindow();
|
||||||
requestImmediateRefresh();
|
requestImmediateRefresh();
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ public final class StationMapView {
|
|||||||
private final Button pathAnalysisVisibilityButton = new Button();
|
private final Button pathAnalysisVisibilityButton = new Button();
|
||||||
private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip();
|
private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip();
|
||||||
|
|
||||||
|
private final Button resetViewButton = new Button("Reset view");
|
||||||
|
private final Tooltip statusTooltip = new Tooltip();
|
||||||
|
|
||||||
|
private Runnable onResetView;
|
||||||
|
|
||||||
|
private double lastDetailDividerPosition = 0.65;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private final Label detailCallsignValue = new Label("-");
|
|
||||||
private final Label detailLocatorValue = new Label("-");
|
|
||||||
private final Label detailQrbValue = new Label("-");
|
|
||||||
private final Label detailQtfValue = new Label("-");
|
|
||||||
private final Label detailBandsValue = new Label("-");
|
|
||||||
private final TextArea detailFrequenciesArea = new TextArea();
|
|
||||||
private final Label detailAirplanesValue = new Label("-");
|
|
||||||
private final Button triggerClusterSpotButton = new Button("Trigger cluster spot");
|
private final Button triggerClusterSpotButton = new Button("Trigger cluster spot");
|
||||||
|
|
||||||
private final Label detailPathFromLocatorValue = new Label("-");
|
private final Label detailPathFromLocatorValue = new Label("-");
|
||||||
@@ -210,6 +211,11 @@ public final class StationMapView {
|
|||||||
this.onTriggerClusterSpot = onTriggerClusterSpot;
|
this.onTriggerClusterSpot = onTriggerClusterSpot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setOnResetView(Runnable onResetView) {
|
||||||
|
this.onResetView = onResetView;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void showWindow() {
|
public void showWindow() {
|
||||||
|
|
||||||
applyThemeFromPreferences();
|
applyThemeFromPreferences();
|
||||||
@@ -282,10 +288,6 @@ public final class StationMapView {
|
|||||||
|
|
||||||
stage.setTitle("Station Map");
|
stage.setTitle("Station Map");
|
||||||
|
|
||||||
detailFrequenciesArea.setEditable(false);
|
|
||||||
detailFrequenciesArea.setWrapText(true);
|
|
||||||
detailFrequenciesArea.setPrefRowCount(4);
|
|
||||||
|
|
||||||
detailPathEndpointsValue.setWrapText(true);
|
detailPathEndpointsValue.setWrapText(true);
|
||||||
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
|
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
|
||||||
@@ -299,12 +301,23 @@ public final class StationMapView {
|
|||||||
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
|
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
|
||||||
triggerClusterSpotButton.setDisable(true);
|
triggerClusterSpotButton.setDisable(true);
|
||||||
|
triggerClusterSpotButton.setVisible(false);
|
||||||
|
triggerClusterSpotButton.setManaged(false);
|
||||||
|
triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
|
||||||
triggerClusterSpotButton.setOnAction(event -> {
|
triggerClusterSpotButton.setOnAction(event -> {
|
||||||
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
|
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
|
||||||
onTriggerClusterSpot.accept(detailCallsignRaw);
|
onTriggerClusterSpot.accept(detailCallsignRaw);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resetViewButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
resetViewButton.setOnAction(event -> {
|
||||||
|
if (onResetView != null) {
|
||||||
|
onResetView.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
||||||
pathAnalysisVisibilityButton.setOnAction(event ->
|
pathAnalysisVisibilityButton.setOnAction(event ->
|
||||||
@@ -377,7 +390,7 @@ public final class StationMapView {
|
|||||||
);
|
);
|
||||||
|
|
||||||
pathAnalysisSection = createPathAnalysisSection();
|
pathAnalysisSection = createPathAnalysisSection();
|
||||||
detailPane = new VBox(10, createSelectedStationSection(), pathAnalysisSection);
|
detailPane = new VBox(10, pathAnalysisSection);
|
||||||
|
|
||||||
detailPane.setPadding(new Insets(10));
|
detailPane.setPadding(new Insets(10));
|
||||||
|
|
||||||
@@ -489,20 +502,25 @@ public final class StationMapView {
|
|||||||
* must always have an obvious way to restore a previously hidden analysis.
|
* must always have an obvious way to restore a previously hidden analysis.
|
||||||
*/
|
*/
|
||||||
private HBox createMapHeader() {
|
private HBox createMapHeader() {
|
||||||
// The status may be shortened before the show/hide control is ever clipped.
|
|
||||||
statusLabel.setMinWidth(0);
|
statusLabel.setMinWidth(0);
|
||||||
statusLabel.setMaxWidth(Double.MAX_VALUE);
|
statusLabel.setMaxWidth(Double.MAX_VALUE);
|
||||||
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
|
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
|
||||||
|
statusLabel.setTooltip(statusTooltip);
|
||||||
|
|
||||||
HBox header = new HBox(
|
HBox header = new HBox(
|
||||||
10,
|
10,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
triggerClusterSpotButton,
|
||||||
|
resetViewButton,
|
||||||
pathAnalysisHiddenHintLabel,
|
pathAnalysisHiddenHintLabel,
|
||||||
pathAnalysisVisibilityButton
|
pathAnalysisVisibilityButton
|
||||||
);
|
);
|
||||||
|
|
||||||
header.setAlignment(Pos.CENTER_LEFT);
|
header.setAlignment(Pos.CENTER_LEFT);
|
||||||
header.setPadding(new Insets(8));
|
header.setPadding(new Insets(8));
|
||||||
|
|
||||||
HBox.setHgrow(statusLabel, Priority.ALWAYS);
|
HBox.setHgrow(statusLabel, Priority.ALWAYS);
|
||||||
|
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +541,7 @@ public final class StationMapView {
|
|||||||
|
|
||||||
pathAnalysisSection.setVisible(visible);
|
pathAnalysisSection.setVisible(visible);
|
||||||
pathAnalysisSection.setManaged(visible);
|
pathAnalysisSection.setManaged(visible);
|
||||||
|
updateDetailPanePresence(visible);
|
||||||
|
|
||||||
pathAnalysisHiddenHintLabel.setVisible(!visible);
|
pathAnalysisHiddenHintLabel.setVisible(!visible);
|
||||||
pathAnalysisHiddenHintLabel.setManaged(!visible);
|
pathAnalysisHiddenHintLabel.setManaged(!visible);
|
||||||
@@ -567,45 +586,6 @@ public final class StationMapView {
|
|||||||
: MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS;
|
: MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private VBox createSelectedStationSection() {
|
|
||||||
GridPane detailGrid = new GridPane();
|
|
||||||
detailGrid.setHgap(8);
|
|
||||||
detailGrid.setVgap(6);
|
|
||||||
|
|
||||||
configureCompactGrid(detailGrid);
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Station:"), 0, row);
|
|
||||||
detailGrid.add(detailCallsignValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Locator:"), 0, row);
|
|
||||||
detailGrid.add(detailLocatorValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Path:"), 0, row);
|
|
||||||
Label compactPathValue = new Label();
|
|
||||||
compactPathValue.textProperty().bind(
|
|
||||||
detailQrbValue.textProperty()
|
|
||||||
.concat(" / ")
|
|
||||||
.concat(detailQtfValue.textProperty())
|
|
||||||
);
|
|
||||||
detailGrid.add(compactPathValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Bands:"), 0, row);
|
|
||||||
detailGrid.add(detailBandsValue, 1, row++);
|
|
||||||
|
|
||||||
// Frequencies are useful, but they consume vertical space. Keep them compact.
|
|
||||||
detailFrequenciesArea.setPrefRowCount(2);
|
|
||||||
detailGrid.add(new Label("QRG:"), 0, row);
|
|
||||||
detailGrid.add(detailFrequenciesArea, 1, row++);
|
|
||||||
|
|
||||||
return new VBox(8,
|
|
||||||
new Label("Selected station"),
|
|
||||||
new Separator(Orientation.HORIZONTAL),
|
|
||||||
detailGrid,
|
|
||||||
triggerClusterSpotButton
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ensures that the labels remains visible
|
* ensures that the labels remains visible
|
||||||
@@ -950,58 +930,107 @@ public final class StationMapView {
|
|||||||
|
|
||||||
private void updateStatusLabel() {
|
private void updateStatusLabel() {
|
||||||
StringBuilder text = new StringBuilder();
|
StringBuilder text = new StringBuilder();
|
||||||
text.append("Showing ").append(lastSnapshots.size()).append(" visible stations");
|
|
||||||
|
text.append("Showing ")
|
||||||
|
.append(lastSnapshots.size())
|
||||||
|
.append(" visible stations");
|
||||||
|
|
||||||
if (filteredViewActive) {
|
if (filteredViewActive) {
|
||||||
text.append(" | filtered view active");
|
text.append(" | filtered view active");
|
||||||
}
|
}
|
||||||
|
|
||||||
statusLabel.setText(text.toString());
|
MapCallsignRawSnapshot selectedSnapshot = lastSelectedSnapshot;
|
||||||
|
|
||||||
|
if (selectedSnapshot != null) {
|
||||||
|
text.append(" | Selected: ")
|
||||||
|
.append(selectedSnapshot.displayCallSign());
|
||||||
|
|
||||||
|
if (!selectedSnapshot.locator6().isBlank()) {
|
||||||
|
text.append(" | ")
|
||||||
|
.append(selectedSnapshot.locator6());
|
||||||
|
}
|
||||||
|
|
||||||
|
text.append(" | ")
|
||||||
|
.append(String.format(
|
||||||
|
Locale.US,
|
||||||
|
"%.0f km / %.0f°",
|
||||||
|
selectedSnapshot.qrbKm(),
|
||||||
|
selectedSnapshot.qtfDeg()
|
||||||
|
));
|
||||||
|
|
||||||
|
String bandText = selectedSnapshot.bandSummary().isBlank()
|
||||||
|
? "-"
|
||||||
|
: selectedSnapshot.bandSummary();
|
||||||
|
|
||||||
|
if (selectedSnapshot.offersSelectedBand()) {
|
||||||
|
bandText += " B+";
|
||||||
|
}
|
||||||
|
|
||||||
|
text.append(" | Bands: ")
|
||||||
|
.append(bandText);
|
||||||
|
|
||||||
|
String frequencies = selectedSnapshot.detailFrequencyText();
|
||||||
|
|
||||||
|
if (frequencies != null && !frequencies.isBlank()) {
|
||||||
|
frequencies = frequencies
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', ' ')
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
text.append(" | QRG: ")
|
||||||
|
.append(frequencies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String statusText = text.toString();
|
||||||
|
|
||||||
|
statusLabel.setText(statusText);
|
||||||
|
statusTooltip.setText(statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
||||||
if (selectedSnapshot == null) {
|
if (selectedSnapshot == null) {
|
||||||
clearSelectedStationPanel();
|
detailCallsignRaw = null;
|
||||||
|
|
||||||
|
triggerClusterSpotButton.setDisable(true);
|
||||||
|
triggerClusterSpotButton.setVisible(false);
|
||||||
|
triggerClusterSpotButton.setManaged(false);
|
||||||
|
|
||||||
clearPathAnalysisPanel();
|
clearPathAnalysisPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedStationPanel(selectedSnapshot);
|
detailCallsignRaw = selectedSnapshot.callSignRaw();
|
||||||
|
|
||||||
|
triggerClusterSpotButton.setDisable(false);
|
||||||
|
triggerClusterSpotButton.setVisible(true);
|
||||||
|
triggerClusterSpotButton.setManaged(true);
|
||||||
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
updatePathAnalysisPanel(PathAnalysisResult.waitingForSelection(homeLocator6));
|
|
||||||
} else {
|
|
||||||
updatePathAnalysisPanel(lastPathAnalysisResult);
|
updatePathAnalysisPanel(lastPathAnalysisResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateDetailPanePresence(boolean visible) {
|
||||||
|
if (mainSplitPane == null || detailScrollPane == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearSelectedStationPanel() {
|
if (visible) {
|
||||||
detailCallsignRaw = null;
|
if (!mainSplitPane.getItems().contains(detailScrollPane)) {
|
||||||
detailCallsignValue.setText("-");
|
mainSplitPane.getItems().add(detailScrollPane);
|
||||||
detailLocatorValue.setText("-");
|
|
||||||
detailQrbValue.setText("-");
|
Platform.runLater(() ->
|
||||||
detailQtfValue.setText("-");
|
mainSplitPane.setDividerPositions(lastDetailDividerPosition)
|
||||||
detailBandsValue.setText("-");
|
);
|
||||||
detailFrequenciesArea.setText("-");
|
}
|
||||||
detailAirplanesValue.setText("-");
|
} else {
|
||||||
triggerClusterSpotButton.setDisable(true);
|
if (!mainSplitPane.getDividers().isEmpty()) {
|
||||||
|
lastDetailDividerPosition =
|
||||||
|
mainSplitPane.getDividers().get(0).getPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateSelectedStationPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
mainSplitPane.getItems().remove(detailScrollPane);
|
||||||
detailCallsignRaw = selectedSnapshot.callSignRaw();
|
|
||||||
detailCallsignValue.setText(selectedSnapshot.displayCallSign());
|
|
||||||
detailLocatorValue.setText(selectedSnapshot.locator6());
|
|
||||||
detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm()));
|
|
||||||
detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg()));
|
|
||||||
String bandText = selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary();
|
|
||||||
if (selectedSnapshot.offersSelectedBand()) {
|
|
||||||
bandText += " B+";
|
|
||||||
}
|
}
|
||||||
detailBandsValue.setText(bandText);
|
|
||||||
detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText());
|
|
||||||
detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes()));
|
|
||||||
triggerClusterSpotButton.setDisable(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearPathAnalysisPanel() {
|
private void clearPathAnalysisPanel() {
|
||||||
@@ -1331,13 +1360,13 @@ public final class StationMapView {
|
|||||||
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
|
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
|
||||||
detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;");
|
detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;");
|
||||||
statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
||||||
detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
|
// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
|
||||||
} else {
|
} else {
|
||||||
rootPane.setStyle("-fx-background-color: #f2f2f2;");
|
rootPane.setStyle("-fx-background-color: #f2f2f2;");
|
||||||
mainSplitPane.setStyle("-fx-background-color: #f2f2f2;");
|
mainSplitPane.setStyle("-fx-background-color: #f2f2f2;");
|
||||||
detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;");
|
detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;");
|
||||||
statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
||||||
detailFrequenciesArea.setStyle("");
|
// detailFrequenciesArea.setStyle("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,39 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class BandOpportunityResolverTest {
|
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
|
@Test
|
||||||
void resolvesCommonShorthandBandsFromStationName() {
|
void resolvesCommonShorthandBandsFromStationName() {
|
||||||
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
|
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
|
||||||
|
|||||||
@@ -15,6 +15,70 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class MapCallsignRawSnapshotBuilderTest {
|
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
|
@Test
|
||||||
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
||||||
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
||||||
|
|||||||
@@ -16,6 +16,113 @@ class PropagationFrequencyResolverTest {
|
|||||||
|
|
||||||
private static final long NOW = 10_000_000L;
|
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
|
@Test
|
||||||
void currentQrgWinsOverNameAndCategoryFallback() {
|
void currentQrgWinsOverNameAndCategoryFallback() {
|
||||||
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
|
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
|
||||||
|
|||||||
@@ -2383,3 +2383,7 @@ OZ6TY;Henning;JO55XE;StringProperty [value: 144.196]; wkd true; wkd144 true; wkd
|
|||||||
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
DH1NFJ;Jochen;JO50QL;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
DH1NFJ;Jochen;JO50QL;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||||
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
SM7EYW;Torleif 432,205;JO65NK;StringProperty [value: 144.205]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
DJ8MS;Tor_70cm;JO54UC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
DK0MM;Jens/Alex;JN49IU;StringProperty [value: 432.305]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "eleventy --serve",
|
"start": "eleventy --serve",
|
||||||
"build": "eleventy"
|
"build": "eleventy && npm run validate:version-info",
|
||||||
|
"validate:version-info": "node scripts/validate-version-info.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@11ty/eleventy": "^3.0.0"
|
"@11ty/eleventy": "^3.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DEFAULT_FILE = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"_site",
|
||||||
|
"kst4ContestVersionInfo.xml"
|
||||||
|
);
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(`[versionInfo validation] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseVersion(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^v/, "")
|
||||||
|
.split("-")[0]
|
||||||
|
.split("+")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(xml, tagName) {
|
||||||
|
const match = xml.match(
|
||||||
|
new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWellFormedStructure(xml) {
|
||||||
|
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
|
||||||
|
fail("the XML contains an unescaped ampersand");
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutCommentsAndDeclaration = xml
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(/<\?[\s\S]*?\?>/g, "");
|
||||||
|
|
||||||
|
const tagPattern =
|
||||||
|
/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;
|
||||||
|
|
||||||
|
const stack = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while (
|
||||||
|
(match = tagPattern.exec(withoutCommentsAndDeclaration)) !== null
|
||||||
|
) {
|
||||||
|
const fullTag = match[0];
|
||||||
|
const tagName = match[1];
|
||||||
|
|
||||||
|
if (fullTag.startsWith("</")) {
|
||||||
|
const openTag = stack.pop();
|
||||||
|
|
||||||
|
if (openTag !== tagName) {
|
||||||
|
fail(
|
||||||
|
`closing tag </${tagName}> does not match `
|
||||||
|
+ `<${openTag || "none"}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!fullTag.endsWith("/>")) {
|
||||||
|
stack.push(tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
fail(`unclosed tag <${stack[stack.length - 1]}>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMarkup =
|
||||||
|
withoutCommentsAndDeclaration.replace(tagPattern, "");
|
||||||
|
|
||||||
|
// A plain ">" is legal character data and occurs in historical
|
||||||
|
// changelog notation such as "->". A remaining "<" cannot be legal
|
||||||
|
// after all valid tags have been removed.
|
||||||
|
if (/</.test(remainingMarkup)) {
|
||||||
|
fail("the XML contains malformed markup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContainsTag(block, tagName) {
|
||||||
|
const pattern =
|
||||||
|
new RegExp(`<${tagName}>[\\s\\S]*?<\\/${tagName}>`);
|
||||||
|
|
||||||
|
if (!pattern.test(block)) {
|
||||||
|
fail(`required element <${tagName}> is missing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVersionInfo(xml, expectedStableVersion = "") {
|
||||||
|
if (!xml || Buffer.byteLength(xml, "utf8") < 500) {
|
||||||
|
fail("the generated file is empty or implausibly small");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertWellFormedStructure(xml);
|
||||||
|
|
||||||
|
const completeDocumentPattern =
|
||||||
|
/^<\?xml[^>]*>\s*<praktiKST>[\s\S]*<\/praktiKST>\s*$/;
|
||||||
|
|
||||||
|
if (!completeDocumentPattern.test(xml)) {
|
||||||
|
fail(
|
||||||
|
"the document does not contain one complete "
|
||||||
|
+ "<praktiKST> root element"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersionBlock = extractTag(xml, "latestVersion");
|
||||||
|
|
||||||
|
if (latestVersionBlock === null) {
|
||||||
|
fail("<latestVersion> is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tagName of [
|
||||||
|
"versionNumber",
|
||||||
|
"semanticVersion",
|
||||||
|
"adminMessage",
|
||||||
|
"majorChanges",
|
||||||
|
"latestVersionPathOnWebserver"
|
||||||
|
]) {
|
||||||
|
assertContainsTag(latestVersionBlock, tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyVersion =
|
||||||
|
extractTag(latestVersionBlock, "versionNumber");
|
||||||
|
|
||||||
|
const semanticVersion =
|
||||||
|
extractTag(latestVersionBlock, "semanticVersion");
|
||||||
|
|
||||||
|
const releaseUrl =
|
||||||
|
extractTag(
|
||||||
|
latestVersionBlock,
|
||||||
|
"latestVersionPathOnWebserver"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!legacyVersion
|
||||||
|
|| !/^\d+(?:\.\d+)?$/.test(legacyVersion)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
"<versionNumber> is not a valid legacy numeric version"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!semanticVersion
|
||||||
|
|| !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion)
|
||||||
|
) {
|
||||||
|
fail("<semanticVersion> is not a valid Stable version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!releaseUrl
|
||||||
|
|| !releaseUrl.startsWith(
|
||||||
|
"https://github.com/praktimarc/"
|
||||||
|
+ "kst4contest/releases/tag/"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
"<latestVersionPathOnWebserver> is not "
|
||||||
|
+ "a KST4Contest release URL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeLogs = [
|
||||||
|
...xml.matchAll(
|
||||||
|
/<changeLog>([\s\S]*?)<\/changeLog>/g
|
||||||
|
)
|
||||||
|
].map((match) => match[1]);
|
||||||
|
|
||||||
|
if (changeLogs.length === 0) {
|
||||||
|
fail(
|
||||||
|
"the document does not contain any "
|
||||||
|
+ "<changeLog> entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of changeLogs) {
|
||||||
|
for (const tagName of [
|
||||||
|
"changedVersionNumber",
|
||||||
|
"date",
|
||||||
|
"description",
|
||||||
|
"added",
|
||||||
|
"changed",
|
||||||
|
"fixed",
|
||||||
|
"removed"
|
||||||
|
]) {
|
||||||
|
assertContainsTag(entry, tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = normaliseVersion(expectedStableVersion);
|
||||||
|
|
||||||
|
if (expected) {
|
||||||
|
if (semanticVersion !== expected) {
|
||||||
|
fail(
|
||||||
|
`latest Stable version ${semanticVersion} `
|
||||||
|
+ `does not match expected release ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseEntryExists = changeLogs.some(
|
||||||
|
(entry) =>
|
||||||
|
extractTag(
|
||||||
|
entry,
|
||||||
|
"changedVersionNumber"
|
||||||
|
) === expected
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!releaseEntryExists) {
|
||||||
|
fail(
|
||||||
|
"the changelog does not contain the expected "
|
||||||
|
+ `Stable release ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
semanticVersion,
|
||||||
|
changeLogEntries: changeLogs.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const result = {
|
||||||
|
file: DEFAULT_FILE,
|
||||||
|
expectedStableVersion:
|
||||||
|
process.env.EXPECTED_STABLE_VERSION || ""
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index++) {
|
||||||
|
if (
|
||||||
|
argv[index] === "--file"
|
||||||
|
&& argv[index + 1]
|
||||||
|
) {
|
||||||
|
result.file = path.resolve(argv[++index]);
|
||||||
|
} else if (
|
||||||
|
argv[index] === "--expected-stable"
|
||||||
|
&& argv[index + 1]
|
||||||
|
) {
|
||||||
|
result.expectedStableVersion = argv[++index];
|
||||||
|
} else {
|
||||||
|
fail(
|
||||||
|
`unknown or incomplete argument: ${argv[index]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
const options =
|
||||||
|
parseArguments(process.argv.slice(2));
|
||||||
|
|
||||||
|
const xml =
|
||||||
|
fs.readFileSync(options.file, "utf8");
|
||||||
|
|
||||||
|
const result =
|
||||||
|
validateVersionInfo(
|
||||||
|
xml,
|
||||||
|
options.expectedStableVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[versionInfo validation] OK: `
|
||||||
|
+ `Stable ${result.semanticVersion}, `
|
||||||
|
+ `${result.changeLogEntries} changelog entries, `
|
||||||
|
+ options.file
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normaliseVersion,
|
||||||
|
validateVersionInfo
|
||||||
|
};
|
||||||
@@ -5,18 +5,95 @@ const REPO = "praktimarc/kst4contest";
|
|||||||
const API = `https://api.github.com/repos/${REPO}`;
|
const API = `https://api.github.com/repos/${REPO}`;
|
||||||
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
|
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
|
||||||
|
|
||||||
|
const GITHUB_API_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
function wait(milliseconds) {
|
||||||
|
return new Promise(
|
||||||
|
(resolve) => setTimeout(resolve, milliseconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function githubGet(urlPath) {
|
async function githubGet(urlPath) {
|
||||||
const headers = { Accept: "application/vnd.github+json" };
|
const headers = {
|
||||||
|
Accept: "application/vnd.github+json"
|
||||||
|
};
|
||||||
|
|
||||||
if (process.env.GITHUB_TOKEN) {
|
if (process.env.GITHUB_TOKEN) {
|
||||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
headers.Authorization =
|
||||||
|
`Bearer ${process.env.GITHUB_TOKEN}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${API}${urlPath}`, { headers });
|
|
||||||
if (!res.ok) {
|
let lastError = null;
|
||||||
throw new Error(`GitHub API ${urlPath} failed: ${res.status}`);
|
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
attempt <= GITHUB_API_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API}${urlPath}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(15000)
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rateLimitRemaining =
|
||||||
|
res.headers.get("x-ratelimit-remaining");
|
||||||
|
|
||||||
|
const rateLimitReset =
|
||||||
|
res.headers.get("x-ratelimit-reset");
|
||||||
|
|
||||||
|
const rateLimitInfo =
|
||||||
|
rateLimitRemaining === null
|
||||||
|
? ""
|
||||||
|
: `, rate limit remaining `
|
||||||
|
+ rateLimitRemaining
|
||||||
|
+ (
|
||||||
|
rateLimitReset
|
||||||
|
? `, reset ${rateLimitReset}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
lastError = new Error(
|
||||||
|
`GitHub API ${urlPath} failed with `
|
||||||
|
+ `HTTP ${res.status}${rateLimitInfo}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authentication and permission errors do not
|
||||||
|
// become valid by retrying.
|
||||||
|
if (
|
||||||
|
![408, 429].includes(res.status)
|
||||||
|
&& res.status < 500
|
||||||
|
) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
|
||||||
|
// Do not hide an invalid or expired token behind
|
||||||
|
// repeated requests.
|
||||||
|
if (
|
||||||
|
/HTTP (401|403|404)/.test(err.message)
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < GITHUB_API_ATTEMPTS) {
|
||||||
|
await wait(attempt * 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
|| new Error(`GitHub API ${urlPath} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
|
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
|
||||||
// compares it against ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER,
|
// compares it against ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER,
|
||||||
// which encodes patch releases by appending the patch digit(s) instead of a
|
// which encodes patch releases by appending the patch digit(s) instead of a
|
||||||
@@ -108,25 +185,64 @@ function loadLegacySections() {
|
|||||||
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
|
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
|
||||||
// version-history.xml for releases that predate GitHub Releases.
|
// version-history.xml for releases that predate GitHub Releases.
|
||||||
module.exports = async function () {
|
module.exports = async function () {
|
||||||
const fallback = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historyEntries = loadHistoryEntries();
|
const historyEntries = loadHistoryEntries();
|
||||||
|
|
||||||
const rawReleases = await githubGet("/releases?per_page=100");
|
const rawReleases =
|
||||||
|
await githubGet("/releases?per_page=100");
|
||||||
|
|
||||||
|
if (!Array.isArray(rawReleases)) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub releases response was not an array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const releases = rawReleases
|
const releases = rawReleases
|
||||||
.map((r) => ({
|
.map((release) => ({
|
||||||
tagName: r.tag_name,
|
tagName: release.tag_name,
|
||||||
publishedAt: r.published_at || "",
|
publishedAt: release.published_at || "",
|
||||||
name: r.name || r.tag_name,
|
name:
|
||||||
body: r.body || "",
|
release.name
|
||||||
isPrerelease: r.prerelease
|
|| release.tag_name,
|
||||||
|
body: release.body || "",
|
||||||
|
isPrerelease: release.prerelease,
|
||||||
|
isDraft: release.draft
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1));
|
.sort(
|
||||||
|
(first, second) =>
|
||||||
|
first.publishedAt
|
||||||
|
< second.publishedAt
|
||||||
|
? 1
|
||||||
|
: -1
|
||||||
|
);
|
||||||
|
|
||||||
|
const stableReleases = releases.filter(
|
||||||
|
(release) =>
|
||||||
|
!release.isPrerelease
|
||||||
|
&& !release.isDraft
|
||||||
|
);
|
||||||
|
|
||||||
const stableReleases = releases.filter((r) => !r.isPrerelease);
|
|
||||||
const stable = stableReleases[0];
|
const stable = stableReleases[0];
|
||||||
const ghVersions = new Set(stableReleases.map((r) => toAppVersionNumber(r.tagName)));
|
|
||||||
|
if (
|
||||||
|
!stable
|
||||||
|
|| !stable.tagName
|
||||||
|
|| !stable.publishedAt
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub did not return "
|
||||||
|
+ "a published Stable release"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghVersions = new Set(
|
||||||
|
stableReleases.map(
|
||||||
|
(release) =>
|
||||||
|
toAppVersionNumber(
|
||||||
|
release.tagName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
|
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
|
||||||
@@ -203,7 +319,14 @@ module.exports = async function () {
|
|||||||
parts.push("</praktiKST>");
|
parts.push("</praktiKST>");
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`);
|
throw new Error(
|
||||||
return fallback;
|
"[versionInfo] Could not generate "
|
||||||
|
+ "a complete update feed. "
|
||||||
|
+ "The website build has been aborted "
|
||||||
|
+ "so that the existing production feed "
|
||||||
|
+ "remains untouched: "
|
||||||
|
+ err.message,
|
||||||
|
{ cause: err }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#08110b">
|
<meta name="theme-color" content="#08110b">
|
||||||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/assets/favicon.ico" sizes="any">
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||||||
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
|
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
|
||||||
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
|
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const generateVersionInfo =
|
||||||
|
require("../src/_data/versionInfo");
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateVersionInfo
|
||||||
|
} = require("../scripts/validate-version-info");
|
||||||
|
|
||||||
|
const VALID_XML =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<praktiKST>
|
||||||
|
<latestVersion>
|
||||||
|
<versionNumber>1.411</versionNumber>
|
||||||
|
<semanticVersion>1.41.1</semanticVersion>
|
||||||
|
<adminMessage></adminMessage>
|
||||||
|
<majorChanges>Hotfix</majorChanges>
|
||||||
|
<latestVersionPathOnWebserver>https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1</latestVersionPathOnWebserver>
|
||||||
|
</latestVersion>
|
||||||
|
<needUpdateSinceLastVersion>
|
||||||
|
<filename>nothing</filename>
|
||||||
|
</needUpdateSinceLastVersion>
|
||||||
|
<changeLog>
|
||||||
|
<changedVersionNumber>1.41.1</changedVersionNumber>
|
||||||
|
<date>2026-07-08</date>
|
||||||
|
<description>Hotfix</description>
|
||||||
|
<added></added>
|
||||||
|
<changed></changed>
|
||||||
|
<fixed>Text input handling</fixed>
|
||||||
|
<removed></removed>
|
||||||
|
</changeLog>
|
||||||
|
</praktiKST>`;
|
||||||
|
|
||||||
|
test("accepts a complete update feed", () => {
|
||||||
|
const result =
|
||||||
|
validateVersionInfo(VALID_XML, "v1.41.1");
|
||||||
|
|
||||||
|
assert.equal(result.semanticVersion, "1.41.1");
|
||||||
|
assert.equal(result.changeLogEntries, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects the former empty fallback", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateVersionInfo(
|
||||||
|
'<?xml version="1.0" '
|
||||||
|
+ 'encoding="UTF-8"?>'
|
||||||
|
+ "<praktiKST></praktiKST>"
|
||||||
|
),
|
||||||
|
/empty or implausibly small/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rejects a feed which does not contain "
|
||||||
|
+ "the expected Stable release",
|
||||||
|
() => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateVersionInfo(
|
||||||
|
VALID_XML,
|
||||||
|
"v1.42.0"
|
||||||
|
),
|
||||||
|
/does not match expected release/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"aborts generation when GitHub rejects "
|
||||||
|
+ "the API request",
|
||||||
|
async () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
get: () => null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
generateVersionInfo(),
|
||||||
|
/website build has been aborted.*HTTP 401/i
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user