4 Commits
23 changed files with 2534 additions and 154 deletions
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
View File
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.Predicate;
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
* that change to the JavaFX list. This is the only supported path for ON4KST
@@ -1501,6 +1533,8 @@ private ObservableList<String>
return;
}
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> {
@@ -1683,6 +1717,9 @@ private ObservableList<String>
}
activeMember.setName(updatedMember.getName());
initializeFrequencyFromStationNameIfUnambiguous(
activeMember
);
activeMember.setQra(updatedMember.getQra());
activeMember.setState(updatedMember.getState());
activeMember.setLastActivity(updatedMember.getLastActivity());
@@ -18,6 +18,7 @@ import kst4contest.ApplicationConstants;
import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.model.*;
import kst4contest.logic.FrequencyTextParser;
/**
*
@@ -58,7 +59,7 @@ public class MessageBusManagementThread extends Thread {
* would be converted into plausible but incorrect frequencies.
*/
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
"(?<![\\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])"
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
);
@@ -319,16 +320,28 @@ public class MessageBusManagementThread extends Thread {
try {
String reconstructed =
candidateBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
candidateBand.getPrefix()
+ "."
+ foundRaw;
if (candidateBand.isPlausible(candidateFrequency)
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
reconstructed
);
if (detectedFrequency != null
&& detectedFrequency.getBand() == candidateBand
&& info.timestampEpoch > bestTimestamp) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = candidateBand;
bestTimestamp = info.timestampEpoch;
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
candidateBand;
bestTimestamp =
info.timestampEpoch;
}
} catch (NumberFormatException ignored) {
// Try the next known band.
@@ -357,27 +370,40 @@ public class MessageBusManagementThread extends Thread {
try {
String reconstructed =
fallbackBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
fallbackBand.getPrefix()
+ "."
+ foundRaw;
if (fallbackBand.isPlausible(candidateFrequency)) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = fallbackBand;
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
reconstructed
);
if (detectedFrequency != null
&& detectedFrequency.getBand() == fallbackBand) {
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
fallbackBand;
}
} catch (NumberFormatException ignored) {
// The matched value cannot be converted into a frequency.
}
}
} else {
try {
finalDetectedFrequency = Double.parseDouble(
normalizeFrequencyString(foundRaw)
);
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
} catch (NumberFormatException ignored) {
// Continue with the next possible match in the message.
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
foundRaw
);
if (detectedFrequency != null) {
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
detectedFrequency.getBand();
}
}
@@ -462,22 +488,22 @@ public class MessageBusManagementThread extends Thread {
* Example: "144.210.10" -> "144.21010"
* Example: "144.210" -> "144.210"
*/
private String normalizeFrequencyString(String rawInput) {
// Input is already guaranteed to have only dots as separators (commas replaced earlier)
int firstDotIndex = rawInput.indexOf(".");
if (firstDotIndex != -1) {
// Check if there are more dots after the first one
String decimalPart = rawInput.substring(firstDotIndex + 1);
if (decimalPart.contains(".")) {
// Remove all subsequent dots to make it a valid double
decimalPart = decimalPart.replace(".", "");
return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
}
}
return rawInput;
}
// private String normalizeFrequencyString(String rawInput) {
// // Input is already guaranteed to have only dots as separators (commas replaced earlier)
//
// int firstDotIndex = rawInput.indexOf(".");
//
// if (firstDotIndex != -1) {
// // Check if there are more dots after the first one
// String decimalPart = rawInput.substring(firstDotIndex + 1);
// if (decimalPart.contains(".")) {
// // Remove all subsequent dots to make it a valid double
// decimalPart = decimalPart.replace(".", "");
// return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
// }
// }
// return rawInput;
// }
/**
@@ -113,12 +113,32 @@ public final class BandOpportunityResolver {
return detectedBands;
}
/*
* Explicit band descriptions:
* 2m, 70cm, 23cm, 432, 1296, ...
*/
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
if (entry.getValue().matcher(stationName).find()) {
detectedBands.add(entry.getKey());
}
}
/*
* Explicit full QRGs in name field:
* 432.357 -> 432 MHz
* 1296.210 -> 1296 MHz
* etc.
*/
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
stationName
)) {
detectedBands.add(
detectedFrequency.getBand()
);
}
return detectedBands;
}
@@ -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. */
public enum Source {
CURRENT_QRG,
STATION_NAME_QRG,
STATION_NAME,
DUAL_CATEGORY_FALLBACK,
CHAT_CATEGORY
@@ -90,6 +91,20 @@ public final class PropagationFrequencyResolver {
);
}
FrequencyCandidate stationNameQrg =
findUniqueStationNameQrg(
supportedVariants,
usableBands
);
if (stationNameQrg != null) {
return new Resolution(
stationNameQrg.band,
stationNameQrg.frequencyMHz,
Source.STATION_NAME_QRG
);
}
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
for (ChatMember variant : supportedVariants) {
nameBands.addAll(
@@ -172,6 +187,73 @@ public final class PropagationFrequencyResolver {
return latest;
}
/**
* Resolves one exact station-name QRG only when the evidence is unambiguous.
*
* <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) {
if (member == null || member.getChatCategory() == null) {
return false;
@@ -518,6 +518,7 @@ public class ChatMember {
this.frequency = frequency;
}
public long getActivityTimeLastInEpoch() {
return activityTimeLastInEpoch;
}
@@ -595,6 +596,40 @@ public class ChatMember {
}
/**
* Initializes the compatibility frequency only when no frequency is known yet.
*
* <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
* 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.Locale;
import java.util.Map;
import kst4contest.logic.FrequencyTextParser;
/**
* Builds immutable map snapshots from the currently visible chat members.
@@ -204,6 +205,92 @@ public final class MapCallsignRawSnapshotBuilder {
));
}
}
}
/*
* A QRG explicitly contained in the current station name does not expire.
*
* Recent dynamic QRG evidence has priority. Therefore station-name QRGs fill
* only bands for which no recent dynamic frequency is available.
*
* Different explicit QRGs for the same band are considered ambiguous and are
* not reduced to one arbitrary run frequency.
*/
Map<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<>();
@@ -79,6 +79,8 @@ public final class StationMapBridge {
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
stationMapView.setOnResetView(this::handleMapReset);
chatController.getLst_chatMemberSortedFilteredList().addListener(
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
);
@@ -98,6 +100,35 @@ public final class StationMapBridge {
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() {
stationMapView.showWindow();
requestImmediateRefresh();
@@ -87,14 +87,15 @@ public final class StationMapView {
private final Button pathAnalysisVisibilityButton = new Button();
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 Label detailPathFromLocatorValue = new Label("-");
@@ -210,6 +211,11 @@ public final class StationMapView {
this.onTriggerClusterSpot = onTriggerClusterSpot;
}
public void setOnResetView(Runnable onResetView) {
this.onResetView = onResetView;
}
public void showWindow() {
applyThemeFromPreferences();
@@ -282,10 +288,6 @@ public final class StationMapView {
stage.setTitle("Station Map");
detailFrequenciesArea.setEditable(false);
detailFrequenciesArea.setWrapText(true);
detailFrequenciesArea.setPrefRowCount(4);
detailPathEndpointsValue.setWrapText(true);
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
@@ -299,12 +301,23 @@ public final class StationMapView {
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
triggerClusterSpotButton.setDisable(true);
triggerClusterSpotButton.setVisible(false);
triggerClusterSpotButton.setManaged(false);
triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE);
triggerClusterSpotButton.setOnAction(event -> {
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
onTriggerClusterSpot.accept(detailCallsignRaw);
}
});
resetViewButton.setMinWidth(Region.USE_PREF_SIZE);
resetViewButton.setOnAction(event -> {
if (onResetView != null) {
onResetView.run();
}
});
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
pathAnalysisVisibilityButton.setOnAction(event ->
@@ -377,7 +390,7 @@ public final class StationMapView {
);
pathAnalysisSection = createPathAnalysisSection();
detailPane = new VBox(10, createSelectedStationSection(), pathAnalysisSection);
detailPane = new VBox(10, pathAnalysisSection);
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.
*/
private HBox createMapHeader() {
// The status may be shortened before the show/hide control is ever clipped.
statusLabel.setMinWidth(0);
statusLabel.setMaxWidth(Double.MAX_VALUE);
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
statusLabel.setTooltip(statusTooltip);
HBox header = new HBox(
10,
statusLabel,
triggerClusterSpotButton,
resetViewButton,
pathAnalysisHiddenHintLabel,
pathAnalysisVisibilityButton
);
header.setAlignment(Pos.CENTER_LEFT);
header.setPadding(new Insets(8));
HBox.setHgrow(statusLabel, Priority.ALWAYS);
return header;
}
@@ -523,6 +541,7 @@ public final class StationMapView {
pathAnalysisSection.setVisible(visible);
pathAnalysisSection.setManaged(visible);
updateDetailPanePresence(visible);
pathAnalysisHiddenHintLabel.setVisible(!visible);
pathAnalysisHiddenHintLabel.setManaged(!visible);
@@ -567,45 +586,6 @@ public final class StationMapView {
: 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
@@ -950,58 +930,107 @@ public final class StationMapView {
private void updateStatusLabel() {
StringBuilder text = new StringBuilder();
text.append("Showing ").append(lastSnapshots.size()).append(" visible stations");
text.append("Showing ")
.append(lastSnapshots.size())
.append(" visible stations");
if (filteredViewActive) {
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) {
if (selectedSnapshot == null) {
clearSelectedStationPanel();
detailCallsignRaw = null;
triggerClusterSpotButton.setDisable(true);
triggerClusterSpotButton.setVisible(false);
triggerClusterSpotButton.setManaged(false);
clearPathAnalysisPanel();
return;
}
updateSelectedStationPanel(selectedSnapshot);
if (selectedSnapshot == null) {
updatePathAnalysisPanel(PathAnalysisResult.waitingForSelection(homeLocator6));
} else {
updatePathAnalysisPanel(lastPathAnalysisResult);
}
}
private void clearSelectedStationPanel() {
detailCallsignRaw = null;
detailCallsignValue.setText("-");
detailLocatorValue.setText("-");
detailQrbValue.setText("-");
detailQtfValue.setText("-");
detailBandsValue.setText("-");
detailFrequenciesArea.setText("-");
detailAirplanesValue.setText("-");
triggerClusterSpotButton.setDisable(true);
}
private void updateSelectedStationPanel(MapCallsignRawSnapshot selectedSnapshot) {
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);
triggerClusterSpotButton.setVisible(true);
triggerClusterSpotButton.setManaged(true);
updatePathAnalysisPanel(lastPathAnalysisResult);
}
private void updateDetailPanePresence(boolean visible) {
if (mainSplitPane == null || detailScrollPane == null) {
return;
}
if (visible) {
if (!mainSplitPane.getItems().contains(detailScrollPane)) {
mainSplitPane.getItems().add(detailScrollPane);
Platform.runLater(() ->
mainSplitPane.setDividerPositions(lastDetailDividerPosition)
);
}
} else {
if (!mainSplitPane.getDividers().isEmpty()) {
lastDetailDividerPosition =
mainSplitPane.getDividers().get(0).getPosition();
}
mainSplitPane.getItems().remove(detailScrollPane);
}
}
private void clearPathAnalysisPanel() {
@@ -1331,13 +1360,13 @@ public final class StationMapView {
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
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;");
detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
} else {
rootPane.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;");
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 {
@Test
void explicitStationNameFrequencyProvidesBandEvidence() {
ChatMember station = new ChatMember();
station.setName("Phil 432.357");
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(
List.of(station),
System.currentTimeMillis()
);
assertEquals(
EnumSet.of(Band.B_432),
resolution.getOfferedBands()
);
}
@Test
void relativeStationNameFrequencyDoesNotProvideBandEvidence() {
ChatMember station = new ChatMember();
station.setName("Mike .180");
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(
List.of(station),
System.currentTimeMillis()
);
assertTrue(
resolution.getOfferedBands().isEmpty()
);
}
@Test
void resolvesCommonShorthandBandsFromStationName() {
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
@@ -15,6 +15,70 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotBuilderTest {
@Test
void explicitStationNameQrgIsShownInMapSnapshot() {
ChatMember station =
buildStation(
"G0JSB",
"Phil 432.357",
"IO91AA",
1_000L
);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_432)
)
.get(0);
assertEquals(
"432",
snapshot.bandSummary()
);
assertEquals(
"432.357",
snapshot
.lastKnownFrequenciesByBand()
.get("432")
);
}
@Test
void recentDynamicQrgWinsOverStationNameQrgInMap() {
ChatMember station =
buildStation(
"G0JSB",
"Phil 432.357",
"IO91AA",
1_000L
);
station.addKnownFrequency(
Band.B_432,
432.335
);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_432)
)
.get(0);
assertEquals(
"432.335",
snapshot
.lastKnownFrequenciesByBand()
.get("432")
);
}
@Test
void marksSnapshotWhenNameAdvertisesSelectedBand() {
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
@@ -16,6 +16,113 @@ class PropagationFrequencyResolverTest {
private static final long NOW = 10_000_000L;
@Test
void exactStationNameQrgWinsOverBandAndCategoryFallback() {
ChatMember station =
station(
ChatCategory.VUHF,
"Phil 432.357"
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(
Band.B_144,
Band.B_432
)
);
assertEquals(
Band.B_432,
resolution.getBand()
);
assertEquals(
432.357,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.STATION_NAME_QRG,
resolution.getSource()
);
}
@Test
void recentChatQrgWinsOverExactStationNameQrg() {
ChatMember station =
station(
ChatCategory.VUHF,
"Phil 432.357"
);
addCurrentQrg(
station,
Band.B_432,
432.335,
NOW - 1_000L
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(Band.B_432)
);
assertEquals(
432.335,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.CURRENT_QRG,
resolution.getSource()
);
}
@Test
void multipleStationNameQrgsAreNotTreatedAsOneRunFrequency() {
ChatMember station =
station(
ChatCategory.EMEJT65,
"QRV 432.357 1296.210"
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(
Band.B_432,
Band.B_1296
)
);
/*
* Both bands are known, but no exact run QRG is guessed.
* Existing station-name band selection therefore chooses the
* lowest usable advertised band.
*/
assertEquals(
Band.B_432,
resolution.getBand()
);
assertEquals(
432.0,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.STATION_NAME,
resolution.getSource()
);
}
@Test
void currentQrgWinsOverNameAndCategoryFallback() {
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
+5 -1
View File
@@ -2382,4 +2382,8 @@ OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432fa
OZ6TY;Henning;JO55XE;StringProperty [value: 144.196]; 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
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
+3 -1
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"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": {
"@11ty/eleventy": "^3.0.0"
+280
View File
@@ -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
};
+143 -20
View File
@@ -5,16 +5,93 @@ const REPO = "praktimarc/kst4contest";
const API = `https://api.github.com/repos/${REPO}`;
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) {
const headers = { Accept: "application/vnd.github+json" };
const headers = {
Accept: "application/vnd.github+json"
};
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) {
throw new Error(`GitHub API ${urlPath} failed: ${res.status}`);
let lastError = null;
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();
}
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);
}
}
return res.json();
throw lastError
|| new Error(`GitHub API ${urlPath} failed`);
}
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
@@ -108,25 +185,64 @@ function loadLegacySections() {
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
// version-history.xml for releases that predate GitHub Releases.
module.exports = async function () {
const fallback = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
try {
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
.map((r) => ({
tagName: r.tag_name,
publishedAt: r.published_at || "",
name: r.name || r.tag_name,
body: r.body || "",
isPrerelease: r.prerelease
.map((release) => ({
tagName: release.tag_name,
publishedAt: release.published_at || "",
name:
release.name
|| 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 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 = [];
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
@@ -203,7 +319,14 @@ module.exports = async function () {
parts.push("</praktiKST>");
return parts.join("\n");
} catch (err) {
console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`);
return fallback;
throw new Error(
"[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 }
);
}
};
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08110b">
<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.' }}">
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
+93
View File
@@ -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;
}
}
);