diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6f0b7945..65675bae 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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. diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 887cffea..c629cd74 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -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) { diff --git a/src/main/java/kst4contest/controller/Utils4KST.java b/src/main/java/kst4contest/controller/Utils4KST.java index 878d61a2..aeaad7f9 100644 --- a/src/main/java/kst4contest/controller/Utils4KST.java +++ b/src/main/java/kst4contest/controller/Utils4KST.java @@ -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"; } diff --git a/src/main/java/kst4contest/locatorUtils/DirectionUtils.java b/src/main/java/kst4contest/locatorUtils/DirectionUtils.java index bdb1fc63..ef1bbad2 100644 --- a/src/main/java/kst4contest/locatorUtils/DirectionUtils.java +++ b/src/main/java/kst4contest/locatorUtils/DirectionUtils.java @@ -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; } diff --git a/src/main/java/kst4contest/view/map/MapHtmlResources.java b/src/main/java/kst4contest/view/map/MapHtmlResources.java index a33b7fe7..f3b31495 100644 --- a/src/main/java/kst4contest/view/map/MapHtmlResources.java +++ b/src/main/java/kst4contest/view/map/MapHtmlResources.java @@ -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(); }); diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java index 2441788e..821eafa8 100644 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ b/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -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(); - requestImmediateRefresh(); + 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 visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); - ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); + if (!refreshState.shouldRefreshNow(stationMapView.isShowing())) { + refreshCoalescer.stop(); + return; + } - List snapshots = snapshotBuilder.buildSnapshots( + final long refreshStart = System.nanoTime(); + final long snapshotStart = refreshStart; + final List visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); + final ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + final EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); + + final List 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; @@ -436,4 +478,4 @@ public final class StationMapBridge { } -} \ No newline at end of file +} diff --git a/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java b/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java new file mode 100644 index 00000000..7a6a05a0 --- /dev/null +++ b/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java @@ -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; + } + } +} diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java index a9f4cc4f..cc00d5c3 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -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])); - System.out.println("[StationMap FX] pulled viewport south=" + viewportSouthLat - + " west=" + viewportWestLon - + " north=" + viewportNorthLat - + " east=" + viewportEastLon - + " zoom=" + viewportZoom); + 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()); - System.out.println("[StationMap FX] inspect click -> " + target); + 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) { - System.out.println("[StationMap FX] selecting station " + target.callSignRaw()); + if (MAP_DEBUG_LOGGING) { + System.out.println("[StationMap FX] selecting station " + target.callSignRaw()); + } onCallsignRawSelected.accept(target.callSignRaw()); event.consume(); } } case ZOOM_IN -> { - System.out.println("[StationMap FX] zoom in click routed by JavaFX"); + 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 -> { - System.out.println("[StationMap FX] zoom out click routed by JavaFX"); + 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) { - 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 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 visibleGridCells = MaidenheadGridUtils.buildVisibleCells( + final List visibleGridCells = MaidenheadGridUtils.buildVisibleCells( viewportSouthLat, viewportWestLon, viewportNorthLat, @@ -1290,13 +1348,15 @@ public final class StationMapView { renderPlan.precision() ); - 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()); + 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() { - System.out.println("[StationMap JS] 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); - System.out.println("[StationMap JS] onViewportChanged zoom=" + viewportZoom); + 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) { - System.out.println("[StationMap JS] onCallsignRawClicked " + 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) { - System.out.println("[StationMap JS] " + 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); } } diff --git a/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java b/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java new file mode 100644 index 00000000..e2f53b0b --- /dev/null +++ b/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java @@ -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 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 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 messages() { + return List.copyOf(messages); + } + } +} diff --git a/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java b/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java new file mode 100644 index 00000000..1ed49248 --- /dev/null +++ b/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java @@ -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)); + } +} diff --git a/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java b/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java index ade88a5b..4333ecff 100644 --- a/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java +++ b/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java @@ -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); + } } diff --git a/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java b/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java new file mode 100644 index 00000000..c71ec3b4 --- /dev/null +++ b/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java @@ -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 + ); + } +}