Optimize station map refresh performance

This commit is contained in:
Marc Froehlich committed 2026-10-03 22:50:43 +02:00
1 parent df70cb1943
commit 3e8457eede
12 files changed
+600 -56

No files matched your search

+4
View File
@@ -150,6 +150,10 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Map reset clears the selected target without changing zoom unless explicitly redesigned.
- **Group nearby stations** re-renders only the existing station-marker layer from JavaScript `stationData`. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
- Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
- A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next `showWindow()` performs one current full refresh before normal visible-window updates resume.
- Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged.
- Panning updates viewport and grid state through `notifyViewport()` without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer.
- Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts.
- Station selection preserves the established `/cq callsign` prefill behaviour.
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
@@ -2198,12 +2198,6 @@ public class MessageBusManagementThread extends Thread {
// e.printStackTrace();
// }
if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) {
LOGGER.log(Level.FINE, "ON4KST RX: {0}",
messageTextRaw.getMessageText());
}
try {
processRXMessage23001(messageTextRaw);
} catch (IOException e) {
@@ -27,7 +27,6 @@ public class Utils4KST {
public String time_generateCurrenthhmmZTimeStringForClusterMessage() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
// System.out.println("Utils generated current time " + currentTimeInUtc + " --> " + currentTimeInUtc.format(DateTimeFormatter.ofPattern("HHmm"))+"Z");
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("HHmm"))+"Z";
}
@@ -22,7 +22,6 @@ public class DirectionUtils {
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
System.out.println("too far, " + distanceFromMeToLocSender + " km");
return false;
}
@@ -795,8 +795,8 @@ public final class MapHtmlResources {
/**
* Re-renders station markers for the current zoom and viewport.
*
* This is called when stations arrive from Java and after zoom/move
* events, because screen-grid clusters depend on the current viewport.
* This is called when stations arrive from Java, clustering changes and
* after zoom events. Panning keeps the existing screen-grid clusters.
*/
function renderStationMarkers() {
if (!map || !stationLayer) {
@@ -941,7 +941,6 @@ public final class MapHtmlResources {
});
map.on('moveend', function () {
renderStationMarkers();
notifyViewport();
});
@@ -19,6 +19,8 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.Band;
import java.util.function.Predicate;
@@ -34,6 +36,9 @@ import java.util.function.Predicate;
*/
public final class StationMapBridge {
/** Application logger for map refresh diagnostics. */
private static final Logger LOGGER =
Logger.getLogger(StationMapBridge.class.getName());
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
@@ -48,6 +53,9 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
/** Tracks one deferred refresh while the map window is hidden. */
private final StationMapPerformanceSupport.RefreshState refreshState =
new StationMapPerformanceSupport.RefreshState();
private String lastPathAnalysisRequestSignature = "";
@@ -131,10 +139,14 @@ public final class StationMapBridge {
public void showWindow() {
stationMapView.showWindow();
if (refreshState.consumeDirtyOnShow()) {
requestImmediateRefresh();
}
}
public void hideWindow() {
refreshCoalescer.stop();
refreshState.markDirty();
stationMapView.hideWindow();
}
@@ -184,19 +196,35 @@ public final class StationMapBridge {
}
private void scheduleRefresh() {
if (Platform.isFxApplicationThread()) {
final Runnable scheduleAction = () -> {
if (!stationMapView.isShowing()) {
refreshCoalescer.stop();
refreshState.markDirty();
return;
}
refreshCoalescer.playFromStart();
};
if (Platform.isFxApplicationThread()) {
scheduleAction.run();
} else {
Platform.runLater(() -> refreshCoalescer.playFromStart());
Platform.runLater(scheduleAction);
}
}
private void refreshNow() {
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
if (!refreshState.shouldRefreshNow(stationMapView.isShowing())) {
refreshCoalescer.stop();
return;
}
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
final long refreshStart = System.nanoTime();
final long snapshotStart = refreshStart;
final List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
final ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
final EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
final List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
visibleChatMembers,
selectedChatMember,
selectedBands
@@ -204,17 +232,19 @@ public final class StationMapBridge {
MapCallsignRawSnapshot selectedSnapshot = null;
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
final String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
selectedSnapshot = snapshots.stream()
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
.findFirst()
.orElse(null);
}
final long snapshotNanos = System.nanoTime() - snapshotStart;
boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
final boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
ChatPreferences preferences = chatController.getChatPreferences();
final ChatPreferences preferences = chatController.getChatPreferences();
final long renderStart = System.nanoTime();
stationMapView.refreshMap(
snapshots,
selectedSnapshot,
@@ -224,8 +254,22 @@ public final class StationMapBridge {
preferences.getStn_maxQRBDefault(),
filteredViewActive
);
final long renderNanos = System.nanoTime() - renderStart;
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
final long totalNanos = System.nanoTime() - refreshStart;
if (preferences.isMessageHandling_debugModeToFileEnabled()
&& StationMapPerformanceSupport.isSlowRefresh(totalNanos)
&& LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE,
StationMapPerformanceSupport.formatSlowRefreshMessage(
totalNanos,
snapshotNanos,
renderNanos,
visibleChatMembers.size(),
snapshots.size()));
}
}
/**
@@ -300,8 +344,6 @@ public final class StationMapBridge {
}
private void handleMapCallsignSelection(String callSignRaw) {
System.out.println("########################### map selected callsign " + callSignRaw);
ChatMember resolved = resolveBestChatMember(callSignRaw);
if (resolved == null) {
return;
@@ -0,0 +1,96 @@
package kst4contest.view.map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/** Small JavaFX-independent state helpers for station-map refresh decisions. */
final class StationMapPerformanceSupport {
/** Minimum duration for a slow-refresh diagnostic. */
private static final long SLOW_NANOS =
TimeUnit.MILLISECONDS.toNanos(100L);
private StationMapPerformanceSupport() {
}
/* default */ static void runDebugIfEnabled(
final boolean enabled,
final Runnable operation
) {
Objects.requireNonNull(operation, "operation");
if (enabled) {
operation.run();
}
}
/* default */ static boolean isSlowRefresh(final long totalNanos) {
return totalNanos >= SLOW_NANOS;
}
/* default */ static String formatSlowRefreshMessage(
final long totalNanos,
final long snapshotNanos,
final long renderNanos,
final int visibleCount,
final int stationCount
) {
return "Station map refresh slow: total="
+ TimeUnit.NANOSECONDS.toMillis(totalNanos)
+ " ms, snapshots="
+ TimeUnit.NANOSECONDS.toMillis(snapshotNanos)
+ " ms, render="
+ TimeUnit.NANOSECONDS.toMillis(renderNanos)
+ " ms, visibleMembers="
+ visibleCount
+ ", stations="
+ stationCount;
}
/** Tracks deferred refresh work while the map window is hidden. */
/* default */ static final class RefreshState {
/** Whether current application state still needs to reach the map. */
private boolean dirty = true;
/* default */ boolean shouldRefreshNow(final boolean visible) {
dirty = !visible;
return visible;
}
/* default */ void markDirty() {
dirty = true;
}
/* default */ boolean consumeDirtyOnShow() {
final boolean refreshRequired = dirty;
dirty = false;
return refreshRequired;
}
}
/** Complete set of inputs that can change the visible grid rendering. */
/* default */ record GridRenderInput(
double southLatitude,
double westLongitude,
double northLatitude,
double eastLongitude,
int zoom,
double viewportWidth,
double viewportHeight
) {
}
/** Rejects duplicate grid inputs before cell construction starts. */
/* default */ static final class GridRenderState {
/** Inputs used for the last planned grid rendering. */
private GridRenderInput lastInput;
/* default */ boolean shouldRender(final GridRenderInput input) {
Objects.requireNonNull(input, "input");
final boolean renderRequired = !input.equals(lastInput);
if (renderRequired) {
lastInput = input;
}
return renderRequired;
}
}
}
@@ -25,6 +25,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -44,6 +46,10 @@ import javafx.scene.layout.HBox;
*/
public final class StationMapView {
/** Application logger for map failures that must remain visible. */
private static final Logger LOGGER =
Logger.getLogger(StationMapView.class.getName());
/**
* Enables verbose map debug output.
*
@@ -130,6 +136,9 @@ public final class StationMapView {
private String lastRenderedBeamJson = "";
private String lastRenderedConnectionJson = "";
private String lastRenderedGridJson = "";
/** Rejects unchanged viewport inputs before expensive grid construction. */
private final StationMapPerformanceSupport.GridRenderState gridRenderState =
new StationMapPerformanceSupport.GridRenderState();
/**
* Prevent periodic refreshes from panning the map back to the selected station.
@@ -195,7 +204,8 @@ public final class StationMapView {
try {
tileProxyServer = new TileProxyServer();
} catch (IOException e) {
System.err.println("[StationMap] tile proxy failed to start: " + e.getMessage());
LOGGER.log(Level.WARNING,
"Station map tile proxy failed to start", e);
}
initializeUi();
initializeWebView();
@@ -362,8 +372,14 @@ public final class StationMapView {
webView.setFocusTraversable(true);
webView.setPickOnBounds(true);
webView.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> logWebViewMouseEvent("MOUSE_PRESSED", event));
webView.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> logWebViewMouseEvent("MOUSE_RELEASED", event));
if (MAP_DEBUG_LOGGING) {
webView.addEventHandler(
MouseEvent.MOUSE_PRESSED,
event -> logWebViewMouseEvent("MOUSE_PRESSED", event));
webView.addEventHandler(
MouseEvent.MOUSE_RELEASED,
event -> logWebViewMouseEvent("MOUSE_RELEASED", event));
}
webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event));
webView.addEventHandler(ScrollEvent.SCROLL, event -> {
@@ -374,8 +390,10 @@ public final class StationMapView {
+ " deltaY=" + event.getDeltaY());
}
InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY());
if (MAP_DEBUG_LOGGING) {
final InteractiveTarget target = inspectInteractiveTarget(
event.getX(),
event.getY());
System.out.println("[StationMap FX] inspect scroll -> " + target);
}
@@ -784,19 +802,21 @@ public final class StationMapView {
try {
Object result = webEngine.executeScript("window.kstMapApi.getViewportState();");
if (result == null) {
System.err.println("[StationMap FX] getViewportState returned null");
LOGGER.warning("Station map getViewportState returned null");
return;
}
String raw = result.toString();
if (raw.isBlank()) {
System.err.println("[StationMap FX] getViewportState returned blank");
LOGGER.warning("Station map getViewportState returned blank");
return;
}
String[] parts = raw.split("\\|");
if (parts.length != 5) {
System.err.println("[StationMap FX] getViewportState unexpected format: " + raw);
LOGGER.log(Level.WARNING,
"Station map getViewportState returned unexpected format: {0}",
raw);
return;
}
@@ -806,15 +826,19 @@ public final class StationMapView {
viewportEastLon = Double.parseDouble(parts[3]);
viewportZoom = (int) Math.round(Double.parseDouble(parts[4]));
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] pulled viewport south=" + viewportSouthLat
+ " west=" + viewportWestLon
+ " north=" + viewportNorthLat
+ " east=" + viewportEastLon
+ " zoom=" + viewportZoom);
}
renderGridIfViewportKnown();
} catch (Exception exception) {
System.err.println("[StationMap FX] pullViewportFromJsAndRedrawGrid failed: " + exception.getMessage());
LOGGER.log(Level.WARNING,
"Station map viewport pull and grid redraw failed",
exception);
}
}
@@ -854,28 +878,37 @@ public final class StationMapView {
private void handleWebViewClick(MouseEvent event) {
private void handleWebViewClick(final MouseEvent event) {
logWebViewMouseEvent("MOUSE_CLICKED", event);
InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY());
final InteractiveTarget target = inspectInteractiveTarget(
event.getX(), event.getY());
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] inspect click -> " + target);
}
switch (target.kind()) {
case STATION -> {
if (target.callSignRaw() != null && !target.callSignRaw().isBlank() && onCallsignRawSelected != null) {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] selecting station " + target.callSignRaw());
}
onCallsignRawSelected.accept(target.callSignRaw());
event.consume();
}
}
case ZOOM_IN -> {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] zoom in click routed by JavaFX");
}
executeMapScriptSafely("window.kstMapApi.zoomIn();");
requestViewportPullFromJs();
event.consume();
}
case ZOOM_OUT -> {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] zoom out click routed by JavaFX");
}
executeMapScriptSafely("window.kstMapApi.zoomOut();");
requestViewportPullFromJs();
event.consume();
@@ -887,18 +920,27 @@ public final class StationMapView {
}
}
private void logWebViewMouseEvent(String type, MouseEvent event) {
private void logWebViewMouseEvent(
final String type,
final MouseEvent event
) {
StationMapPerformanceSupport.runDebugIfEnabled(
MAP_DEBUG_LOGGING,
() -> {
System.out.println("[StationMap FX] " + type
+ " x=" + (int) event.getX()
+ " y=" + (int) event.getY()
+ " target=" + event.getTarget().getClass().getSimpleName()
+ " button=" + event.getButton());
probeDomElementAt(event.getX(), event.getY());
});
}
private void probeDomElementAt(double x, double y) {
if (!mapReady) {
private void probeDomElementAt(
final double xCoordinate,
final double yCoordinate
) {
if (!MAP_DEBUG_LOGGING || !mapReady) {
return;
}
@@ -921,13 +963,15 @@ public final class StationMapView {
}
return "tag=" + el.tagName + " class=" + cls + " id=" + id + " text=" + text;
})();
""", x, y);
""", xCoordinate, yCoordinate);
try {
Object result = webEngine.executeScript(script);
System.out.println("[StationMap FX] elementFromPoint -> " + result);
} catch (Exception exception) {
System.err.println("[StationMap FX] elementFromPoint failed: " + exception.getMessage());
LOGGER.log(Level.WARNING,
"Station map elementFromPoint probe failed",
exception);
}
}
@@ -963,7 +1007,9 @@ public final class StationMapView {
default -> new InteractiveTarget(InteractiveKind.NONE, "", tag, cssClass, text);
};
} catch (Exception exception) {
System.err.println("[StationMap FX] inspectInteractiveTarget failed: " + exception.getMessage());
LOGGER.log(Level.WARNING,
"Station map interactive-target inspection failed",
exception);
return InteractiveTarget.none();
}
}
@@ -1185,7 +1231,6 @@ public final class StationMapView {
renderStations();
renderBeam();
renderConnectionLine();
renderGridIfViewportKnown();
focusSelectedStationOnlyWhenSelectionChanged();
}
@@ -1269,10 +1314,23 @@ public final class StationMapView {
return;
}
double viewportWidthPx = Math.max(1.0, webView.getWidth());
double viewportHeightPx = Math.max(1.0, webView.getHeight());
final double viewportWidthPx = Math.max(1.0, webView.getWidth());
final double viewportHeightPx = Math.max(1.0, webView.getHeight());
MaidenheadGridRenderPlanner.GridRenderPlan renderPlan = MaidenheadGridRenderPlanner.createPlan(
final StationMapPerformanceSupport.GridRenderInput renderInput =
new StationMapPerformanceSupport.GridRenderInput(
viewportSouthLat,
viewportWestLon,
viewportNorthLat,
viewportEastLon,
viewportZoom,
viewportWidthPx,
viewportHeightPx);
if (!gridRenderState.shouldRender(renderInput)) {
return;
}
final MaidenheadGridRenderPlanner.GridRenderPlan renderPlan = MaidenheadGridRenderPlanner.createPlan(
viewportZoom,
viewportSouthLat,
viewportWestLon,
@@ -1282,7 +1340,7 @@ public final class StationMapView {
viewportHeightPx
);
List<MaidenheadGridUtils.GridCell> visibleGridCells = MaidenheadGridUtils.buildVisibleCells(
final List<MaidenheadGridUtils.GridCell> visibleGridCells = MaidenheadGridUtils.buildVisibleCells(
viewportSouthLat,
viewportWestLon,
viewportNorthLat,
@@ -1290,13 +1348,15 @@ public final class StationMapView {
renderPlan.precision()
);
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap] renderGridIfViewportKnown zoom=" + viewportZoom
+ " precision=" + renderPlan.precision().locatorLength()
+ " labelStride=" + renderPlan.labelColumnStride() + "x" + renderPlan.labelRowStride()
+ " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx())
+ " cells=" + visibleGridCells.size());
}
String gridJson = toGridJson(visibleGridCells, renderPlan);
final String gridJson = toGridJson(visibleGridCells, renderPlan);
if (gridJson.equals(lastRenderedGridJson)) {
return;
}
@@ -1393,8 +1453,9 @@ public final class StationMapView {
try {
webEngine.executeScript(script);
} catch (Exception exception) {
System.err.println("[StationMap] executeScript failed: " + exception.getMessage());
exception.printStackTrace();
LOGGER.log(Level.WARNING,
"Station map JavaScript execution failed",
exception);
}
}
@@ -1596,17 +1657,19 @@ public final class StationMapView {
public final class JavaMapBridge {
public void onMapReady() {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap JS] onMapReady");
}
mapReady = true;
renderAll();
requestMapInvalidateSize();
}
public void onViewportChanged(double southLat,
double westLon,
double northLat,
double eastLon,
double zoom) {
public void onViewportChanged(final double southLat,
final double westLon,
final double northLat,
final double eastLon,
final double zoom) {
viewportSouthLat = southLat;
viewportWestLon = westLon;
@@ -1614,7 +1677,9 @@ public final class StationMapView {
viewportEastLon = eastLon;
viewportZoom = (int) Math.round(zoom);
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap JS] onViewportChanged zoom=" + viewportZoom);
}
if (Platform.isFxApplicationThread()) {
renderGridIfViewportKnown();
@@ -1623,19 +1688,25 @@ public final class StationMapView {
}
}
public void onCallsignRawClicked(String callSignRaw) {
public void onCallsignRawClicked(final String callSignRaw) {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap JS] onCallsignRawClicked " + callSignRaw);
}
if (onCallsignRawSelected != null) {
onCallsignRawSelected.accept(callSignRaw);
}
}
public void onJsLog(String message) {
public void onJsLog(final String message) {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap JS] " + message);
}
}
public void onJsError(String message) {
System.err.println("[StationMap JS ERROR] " + message);
public void onJsError(final String message) {
LOGGER.log(Level.WARNING,
"Station map JavaScript error: {0}",
message);
}
}
@@ -0,0 +1,102 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.mock;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMessage;
class MessageBusRawRxLoggingTest {
@Test
@Timeout(5)
void rawInboundFrameIsNotDuplicatedInDiagnosticLogging()
throws InterruptedException {
Logger logger = Logger.getLogger(
MessageBusManagementThread.class.getName());
Level previousLevel = logger.getLevel();
boolean previousUseParentHandlers = logger.getUseParentHandlers();
RecordingHandler handler = new RecordingHandler();
logger.setLevel(Level.FINE);
logger.setUseParentHandlers(false);
logger.addHandler(handler);
try {
LinkedBlockingQueue<ChatMessage> queue =
new LinkedBlockingQueue<>();
MessageBusManagementThread thread =
new MessageBusManagementThread(
mock(ChatController.class),
mock(ThreadStatusCallback.class),
41L,
queue,
ignored -> true);
queue.add(message("ZZ|sensitive raw payload|"));
queue.add(poisonPill());
thread.start();
thread.join();
assertFalse(handler.messages().stream()
.anyMatch(message -> message.startsWith("ON4KST RX:")));
} finally {
logger.removeHandler(handler);
logger.setUseParentHandlers(previousUseParentHandlers);
logger.setLevel(previousLevel);
}
}
private ChatMessage message(final String text) {
ChatMessage message = new ChatMessage();
message.setMessageText(text);
return message;
}
private ChatMessage poisonPill() {
ChatMessage message = message(
ApplicationConstants.DISCONNECT_RDR_POISONPILL);
message.setMessageSenderName(
ApplicationConstants.DISCONNECT_RDR_POISONPILL);
return message;
}
private static final class RecordingHandler extends Handler {
private final List<String> messages = new ArrayList<>();
private RecordingHandler() {
setLevel(Level.ALL);
}
@Override
public void publish(final LogRecord record) {
messages.add(record.getMessage());
}
@Override
public void flush() {
// No buffered output.
}
@Override
public void close() {
// No external resource.
}
private List<String> messages() {
return List.copyOf(messages);
}
}
}
@@ -0,0 +1,37 @@
package kst4contest.locatorUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class DirectionUtilsConsoleOutputTest {
@Test
void outOfRangeCheckDoesNotWriteToStandardOutput() {
PrintStream originalOutput = System.out;
ByteArrayOutputStream capturedOutput = new ByteArrayOutputStream();
try (PrintStream testOutput = new PrintStream(
capturedOutput,
true,
StandardCharsets.UTF_8)) {
System.setOut(testOutput);
assertFalse(DirectionUtils.isInAngleAndRange(
"JN49FL",
"JO43XM",
"JO30SA",
1.0,
50.0));
} finally {
System.setOut(originalOutput);
}
assertEquals("", capturedOutput.toString(StandardCharsets.UTF_8));
}
}
@@ -8,6 +8,66 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class MapHtmlResourcesContractTest {
@Test
void moveEndUpdatesViewportWithoutRebuildingStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "moveend");
assertTrue(handler.contains("notifyViewport();"));
assertFalse(handler.contains("renderStationMarkers();"));
}
@Test
void zoomEndStillRebuildsStationMarkersBeforeUpdatingViewport() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "zoomend");
int markerRender = handler.indexOf("renderStationMarkers();");
int viewportNotification = handler.indexOf("notifyViewport();");
assertTrue(markerRender >= 0);
assertTrue(viewportNotification > markerRender);
}
@Test
void stationDataChangesStillRebuildStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
int setterStart = html.indexOf("function setStations(stationsJson)");
int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart);
String setterBody = html.substring(setterStart, nextFunction);
assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);"));
assertTrue(setterBody.contains("renderStationMarkers();"));
}
@Test
void viewportNotificationForwardsBoundsAndZoomToJava() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()");
assertTrue(functionBody.contains("const bounds = map.getBounds();"));
assertTrue(functionBody.contains("const zoom = map.getZoom();"));
assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged("));
assertTrue(functionBody.contains("bounds.getSouth(),"));
assertTrue(functionBody.contains("bounds.getWest(),"));
assertTrue(functionBody.contains("bounds.getNorth(),"));
assertTrue(functionBody.contains("bounds.getEast(),"));
}
@Test
void stationHitTestingKeepsRawCallsignSelectionContract() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)");
assertTrue(functionBody.contains("el.closest('.station-marker-root')"));
assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')"));
assertTrue(functionBody.contains("return 'station|' + callSignRaw"));
}
@Test
void stationClusteringCanBeToggledWithoutReplacingStationData() {
String html = MapHtmlResources.createStationMapHtml(12345);
@@ -28,4 +88,23 @@ class MapHtmlResourcesContractTest {
assertFalse(setterBody.contains("stationData ="));
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
}
private static String eventHandler(String html, String eventName) {
String signature = "map.on('" + eventName + "', function () {";
int handlerStart = html.indexOf(signature);
int handlerEnd = html.indexOf("});", handlerStart);
assertTrue(handlerStart >= 0);
assertTrue(handlerEnd > handlerStart);
return html.substring(handlerStart, handlerEnd);
}
private static String functionBody(String html, String signature, String nextSignature) {
int functionStart = html.indexOf(signature);
int functionEnd = html.indexOf(nextSignature, functionStart);
assertTrue(functionStart >= 0);
assertTrue(functionEnd > functionStart);
return html.substring(functionStart, functionEnd);
}
}
@@ -0,0 +1,122 @@
package kst4contest.view.map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
class StationMapPerformanceSupportTest {
@Test
void hiddenRefreshIsDeferredUntilTheNextShowOnly() {
StationMapPerformanceSupport.RefreshState state =
new StationMapPerformanceSupport.RefreshState();
assertFalse(state.shouldRefreshNow(false));
assertTrue(state.consumeDirtyOnShow());
assertFalse(state.consumeDirtyOnShow());
}
@Test
void visibleRefreshCanRunImmediately() {
StationMapPerformanceSupport.RefreshState state =
new StationMapPerformanceSupport.RefreshState();
assertTrue(state.shouldRefreshNow(true));
assertFalse(state.consumeDirtyOnShow());
}
@Test
void unchangedGridInputsAreRejectedBeforeRendering() {
StationMapPerformanceSupport.GridRenderState state =
new StationMapPerformanceSupport.GridRenderState();
StationMapPerformanceSupport.GridRenderInput input = gridInput();
assertTrue(state.shouldRender(input));
assertFalse(state.shouldRender(input));
}
@Test
void everyGridInputCanTriggerANewRender() {
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.1, 6.0, 55.0, 16.0, 7, 1024.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.1, 55.0, 16.0, 7, 1024.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.0, 55.1, 16.0, 7, 1024.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.0, 55.0, 16.1, 7, 1024.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.0, 55.0, 16.0, 8, 1024.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.0, 55.0, 16.0, 7, 1200.0, 768.0));
assertChanged(new StationMapPerformanceSupport.GridRenderInput(
49.0, 6.0, 55.0, 16.0, 7, 1024.0, 800.0));
}
@Test
void disabledMapDebugSkipsTheDiagnosticOperation() {
AtomicBoolean executed = new AtomicBoolean();
StationMapPerformanceSupport.runDebugIfEnabled(
false,
() -> executed.set(true));
assertFalse(executed.get());
}
@Test
void enabledMapDebugRunsTheDiagnosticOperation() {
AtomicBoolean executed = new AtomicBoolean();
StationMapPerformanceSupport.runDebugIfEnabled(
true,
() -> executed.set(true));
assertTrue(executed.get());
}
@Test
void slowRefreshStartsAtOneHundredMilliseconds() {
assertFalse(StationMapPerformanceSupport.isSlowRefresh(99_999_999L));
assertTrue(StationMapPerformanceSupport.isSlowRefresh(100_000_000L));
}
@Test
void slowRefreshMessageSeparatesSnapshotAndRenderTimes() {
assertEquals(
"Station map refresh slow: total=245 ms, snapshots=42 ms, "
+ "render=198 ms, visibleMembers=500, stations=487",
StationMapPerformanceSupport.formatSlowRefreshMessage(
245_000_000L,
42_000_000L,
198_000_000L,
500,
487
)
);
}
private void assertChanged(
StationMapPerformanceSupport.GridRenderInput changed) {
StationMapPerformanceSupport.GridRenderState state =
new StationMapPerformanceSupport.GridRenderState();
assertTrue(state.shouldRender(gridInput()));
assertTrue(state.shouldRender(changed));
}
private StationMapPerformanceSupport.GridRenderInput gridInput() {
return new StationMapPerformanceSupport.GridRenderInput(
49.0,
6.0,
55.0,
16.0,
7,
1024.0,
768.0
);
}
}