diff --git a/WebATM/proxy/handlers/commands.py b/WebATM/proxy/handlers/commands.py index 4a0aa25..ffd0224 100644 --- a/WebATM/proxy/handlers/commands.py +++ b/WebATM/proxy/handlers/commands.py @@ -3,11 +3,18 @@ STACKCMDS carries the server's command dictionary, which is cached on the proxy and forwarded to browsers as the ``cmddict`` Socket.IO event so the web console can validate and autocomplete commands. STACK carries command lines -forwarded by the server — commands the simulation did not recognize and -assumes are GUI/client commands (typically PAN/ZOOM lines in scenario files). -They are handled locally and never echoed back to the server. +forwarded by the simulation — commands it did not recognize and assumes are +GUI/client commands (typically PAN/ZOOM lines in scenario files). They are +forwarded to browsers as ``stack_command`` events, where the frontend's +CommandHandler executes the ones it implements (PAN, ZOOM, ...) exactly as if +they had been typed into the console — matching BlueSky's own GUIs, which run +forwarded commands through their client stack. Nothing is ever sent back to +the server (that would loop: the simulation forwarded the line precisely +because it cannot execute it). """ +import time + from ...logger import get_logger from ._base import REPLACE_ACTIONS, active_proxy, is_active_node, shared_context @@ -61,58 +68,58 @@ def on_stackcmds_received(data): def on_stack_received(data): - """Process a BlueSky STACK event carrying server-forwarded command lines. + """Process a BlueSky STACK event carrying sim-forwarded command lines. - Normalizes the payload to a list of command lines and runs each non-empty - line through ``_process_server_command``, which executes it locally and - reports the outcome via the proxy's echo channel. Nothing is sent back to - the BlueSky server. + Normalizes the payload to individual command lines — BlueSky's + ``stack.forward()`` joins multiple commands into one semicolon-separated + string, and its reference client splits them back apart (``stack()`` in + ``bluesky.stack.stackbase``), so the same split happens here — and + forwards each non-empty line to browsers via ``_forward_to_browsers``. Args: - data (str | list | tuple): One command line, or a sequence of command - lines, forwarded by the server. Other types are logged and ignored. + data (str | list | tuple): One command line (possibly + semicolon-joined), or a sequence of such lines. Other types are + logged and ignored. """ proxy = active_proxy() if not proxy: return if isinstance(data, str): - commands_to_process = [data] + received = [data] elif isinstance(data, (list, tuple)): - commands_to_process = list(data) + received = list(data) else: logger.warning(f"Unexpected STACK data format: {type(data)}") return - for cmdline in commands_to_process: - if isinstance(cmdline, str) and cmdline.strip(): - _process_server_command(proxy, cmdline) + for cmdline in received: + if not isinstance(cmdline, str): + continue + for line in cmdline.split(";"): + line = line.strip() + if line: + _forward_to_browsers(proxy, line) -def _process_server_command(proxy, cmdline): - """Execute a server-forwarded command locally without sending it back. +def _forward_to_browsers(proxy, cmdline): + """Emit one sim-forwarded command line to browsers as ``stack_command``. - Runs the command through ``proxy._execute_local_command``. Commands the - web client does not implement (e.g. desktop-GUI view commands like PAN or - ZOOM from a scenario file) are benign no-ops here, so they are reported to - web clients as a warning, not an error. + The frontend executes the line through its CommandHandler (so PAN/ZOOM + lines from scenario files move the map, like they move BlueSky's own + GUIs) and reports unsupported commands itself. With no browser connected + there is nothing to act on the command, so it is only logged. Args: - proxy (BlueSkyProxy): The active proxy used for command execution - and echoing. - cmdline (str): The raw command line received from the server. + proxy (BlueSkyProxy): The active proxy. + cmdline (str): A single, stripped command line from the server. """ - cmd, _, argstring = cmdline.strip().partition(" ") - cmd = cmd.upper() - + if not (proxy.socketio and proxy.connected_clients > 0): + logger.debug(f"No browsers connected; dropping forwarded command: {cmdline}") + return try: - success, echotext = proxy._execute_local_command(cmd, argstring.strip()) + proxy.socketio.emit( + "stack_command", {"command": cmdline, "timestamp": time.time()} + ) except Exception as e: - logger.error(f"Error executing server command '{cmd}': {e}") - proxy._echo_response(f"Error executing server command '{cmd}': {e}", 1) - return - - if success: - proxy._echo_response(echotext, 0) - else: - proxy._echo_response(f"{cmd}: not supported by the web client (ignored)", 2) + logger.error(f"Error forwarding '{cmdline}' to browsers: {e}") diff --git a/WebATM/proxy/handlers/shapes.py b/WebATM/proxy/handlers/shapes.py index 8388bc1..9cc0cae 100644 --- a/WebATM/proxy/handlers/shapes.py +++ b/WebATM/proxy/handlers/shapes.py @@ -23,11 +23,8 @@ # Nautical miles per degree of latitude (1 arc-minute == 1 nm). _NM_PER_DEGREE = 60.0 -# BlueSky stores a shape's vertical extent (metres) on ``Shape.top``/``bottom``, -# defaulting to +/-1e9 for "unbounded". amvlab BlueSky additionally publishes -# these on the POLY payload so WebATM can extrude the shape into a 3D volume; -# vanilla BlueSky omits them. Any bound at or beyond this magnitude is treated -# as unbounded (no vertical extent -> flat 2D, the vanilla behaviour). +# BlueSky uses +/-1e9 m as the "unbounded" vertical extent of a shape; any +# bound at or beyond this magnitude is treated as unbounded (renders flat). _ALT_UNBOUNDED = 9e8 # Cap on stored shapes per node and kind (demo limit): oldest are dropped. @@ -38,15 +35,11 @@ def _normalize_altitudes(shape_dict): """Keep only finite vertical bounds on a shape dict, in place. amvlab BlueSky publishes ``top``/``bottom`` (metres) on each shape so the - frontend can extrude it; vanilla BlueSky sends neither. BlueSky uses - +/-1e9 as its "unbounded" sentinel, which must never reach the 3D renderer - as a literal extrusion height. This keeps ``top`` only when it is a finite - upper bound and ``bottom`` only when it is a finite lower bound, popping - anything else (missing, non-numeric, or sentinel) so the shape renders flat. - - The frontend treats the mere presence of ``top`` as "has altitude", so a - fully-unbounded shape ends up with neither key and renders in 2D, exactly - like it does against vanilla BlueSky. + frontend can extrude it; vanilla BlueSky sends neither. The +/-1e9 + "unbounded" sentinels must never reach the 3D renderer as literal + extrusion heights, so anything non-numeric or at sentinel magnitude is + popped and the shape renders flat (the frontend treats the presence of + ``top`` as "has altitude"). Args: shape_dict (dict): A per-shape payload that may carry ``top``/``bottom``. @@ -192,18 +185,33 @@ def _deleted_names(poly_data): return [] +def _is_full_definition(info): + """Check whether a payload entry is a full shape (re)definition. + + BlueSky's ``defineArea`` always sends ``shape`` and ``coordinates`` + together; partial updates (e.g. COLOR) carry neither. + + Args: + info (Any): Separated per-shape payload. + + Returns: + bool: True when the entry defines the shape rather than patching it. + """ + return isinstance(info, dict) and "shape" in info + + def _merge_shape(target, name, info): - """Merge one shape payload into a store, patching partial updates in place. + """Patch one partial shape update into a store, in place. - An Update action may carry only the changed fields (e.g. a COLOR change - sends ``{"color": ...}`` without coordinates); replacing the stored entry - would wipe the shape's geometry, so dict payloads are merged into the - existing dict instead. + A partial Update carries only the changed fields (e.g. COLOR sends + ``{"color": ...}`` without coordinates); replacing the stored entry would + wipe the shape's geometry, so dict payloads are merged into the existing + dict instead. Args: target (dict): Shape store (name -> shape info) to merge into. name (str): Shape name. - info (Any): New shape payload. + info (Any): Partial shape payload. """ existing = target.get(name) if isinstance(existing, dict) and isinstance(info, dict): @@ -215,6 +223,16 @@ def _merge_shape(target, name, info): def _merge_shapes(store_polys, store_lines, new_polys, new_lines): """Merge separated new shapes into a node's polygon and polyline stores. + A full definition decides its kind authoritatively: BlueSky's + ``defineArea`` overwrites any existing shape with the same name (even one + of a different kind) with a fresh object, so the stored entry is replaced + and the name is evicted from the other store. Otherwise a redefinition + from LINE to POLY would be trapped in the polyline store (rendering an + area as a thin line), the reverse would leave a stale polygon behind, and + dropped server-side fields (a previous COLOR, POLYALT bounds) would + linger. Partial updates merge into whichever store already holds the + shape. + Args: store_polys (dict): Stored polygon mapping for the node. store_lines (dict): Stored polyline mapping for the node. @@ -222,15 +240,20 @@ def _merge_shapes(store_polys, store_lines, new_polys, new_lines): new_lines (dict): Newly received polyline shapes. """ for name, info in new_polys.items(): - # A partial update carries no 'shape' field and lands in the polygon - # bucket by default; route it to the store that already holds the shape - # so a polyline's update doesn't create a phantom polygon. - if name in store_lines and name not in store_polys: + if _is_full_definition(info): + store_lines.pop(name, None) + store_polys[name] = info + elif name in store_lines and name not in store_polys: + # Partial update for a stored polyline: it lands in the polygon + # bucket by default (no 'shape' field), so route it home. _merge_shape(store_lines, name, info) else: _merge_shape(store_polys, name, info) for name, info in new_lines.items(): - _merge_shape(store_lines, name, info) + # Line-bucket entries always carry shape == "LINE" (that is the + # routing criterion), so they are full definitions. + store_polys.pop(name, None) + store_lines[name] = info def _trim_shape_store(polys, kind): @@ -326,16 +349,13 @@ def on_poly_received(data, *args, **kwargs): def _separate_poly_and_polyline_data(poly_data): """Split combined POLY data into polygons and polylines by ``shape`` field. - Shapes marked ``LINE`` become polylines; ``POLYALT`` shapes are rewritten - as ``POLY`` polygons; ``BOX`` (two opposite corners) and ``CIRCLE`` (centre - plus radius in nautical miles) are expanded into ``POLY`` polygon rings; - everything else is treated as a polygon. Flat ``coordinates`` arrays - (``[lat1, lon1, lat2, lon2, ...]``) are converted to separate ``lat``/``lon`` - lists for web-client compatibility. Finite ``top``/``bottom`` vertical - bounds (metres) are passed through so the frontend can extrude the shape in - 3D; BlueSky's unbounded +/-1e9 sentinels (and vanilla BlueSky's absent - altitudes) are dropped so those shapes stay flat. On errors or unexpected - formats the whole payload is treated as polygons. + Shapes marked ``LINE`` become polylines; ``POLYALT`` is rewritten as + ``POLY``; ``BOX`` (two opposite corners) and ``CIRCLE`` (centre plus + radius in nautical miles) are expanded into ``POLY`` polygon rings; + everything else is treated as a polygon. Flat ``coordinates`` arrays are + split into the ``lat``/``lon`` lists the web client wants, and only finite + ``top``/``bottom`` bounds survive (see ``_normalize_altitudes``). On + errors or unexpected formats the whole payload is treated as polygons. Args: poly_data (dict): JSON-serializable POLY payload, expected to contain a @@ -368,20 +388,13 @@ def _separate_poly_and_polyline_data(poly_data): polylines_polys[name] = info continue - if shape == "BOX": - # Two opposite corners; expand into a 4-corner POLY ring - # (the flat-pair path below can't - a box has only 2 points). - info["shape"] = "POLY" - if not ("lat" in info and "lon" in info): - corners = _box_corners(info.get("coordinates")) - if corners: - info["lat"], info["lon"] = corners - info["name"] = name - elif shape == "CIRCLE": - # Centre + radius (nm); tessellate into a POLY ring. + if shape in ("BOX", "CIRCLE"): + # Corner-pair / centre+radius encodings; expand into a + # POLY ring (the flat-pair path below can't handle them). info["shape"] = "POLY" if not ("lat" in info and "lon" in info): - ring = _circle_ring(info.get("coordinates")) + expand = _box_corners if shape == "BOX" else _circle_ring + ring = expand(info.get("coordinates")) if ring: info["lat"], info["lon"] = ring info["name"] = name diff --git a/WebATM/proxy/handlers/simulation.py b/WebATM/proxy/handlers/simulation.py index cb78d5b..aab61e3 100644 --- a/WebATM/proxy/handlers/simulation.py +++ b/WebATM/proxy/handlers/simulation.py @@ -84,17 +84,20 @@ def on_siminfo_received( proxy.socketio.emit("siminfo", proxy.sim_data) proxy.last_siminfo_emit = current_time except Exception as e: - logger.error(f"Proxy→Web: Error sending SIMINFO: {e}") + logger.error(f"Error emitting SIMINFO: {e}") def on_acdata_received(data): """Process a BlueSky ACDATA traffic frame and emit ``acdata`` to web clients. - Hot path: the network timer delivers ACDATA at up to 50 Hz, but it is only - emitted to browsers at ``acdata_interval`` (10 Hz) and only for the active - node. ``make_json_serializable`` is the dominant per-frame cost, so it is - deferred until after the active-node filter and the emit throttle decide the - frame is actually sent. Set WEBATM_PERF=1 (WebATM.proxy.perf) to measure it. + Hot path: the network timer delivers ACDATA at up to 50 Hz, but frames are + only serialized and cached at ``acdata_interval`` (10 Hz), only for the + active node, and only emitted to browsers while any are connected. + ``make_json_serializable`` is the dominant per-frame cost, so it is deferred + until after the active-node filter and the throttle accept the frame. The + cache is refreshed even with no browser attached — ``initial_data``, the + backup timer and the ROUTEDATA filter serve it, so it must track the live + feed. Set WEBATM_PERF=1 (WebATM.proxy.perf) to measure the hot path. Aircraft clearing on a simulation reset is NOT handled here: BlueSky signals a reset on the dedicated RESET topic (events.on_reset_received), @@ -136,24 +139,28 @@ def on_acdata_received(data): return # Throttle BEFORE serializing: serialization is the dominant per-frame - # cost, so pay it only for frames that are actually emitted. + # cost, so pay it at most once per acdata_interval. current_time = time.time() - if not ( - proxy.socketio - and proxy.connected_clients > 0 - and (current_time - proxy.last_acdata_emit) >= proxy.acdata_interval - ): + if (current_time - proxy.last_acdata_emit) < proxy.acdata_interval: return + # Refresh the cache even with no browser attached: the initial_data + # snapshot, the 0.5 s backup timer and the ROUTEDATA aircraft filter + # all serve proxy.traffic_data, so a cache frozen while unwatched + # would hand the next client the aircraft from the moment the last + # one left (ghosts at stale positions). t0 = time.perf_counter() serializable_data = make_json_serializable(data) data_path_perf.record_serialize(time.perf_counter() - t0) proxy.traffic_data = serializable_data + proxy.last_acdata_emit = current_time + + if not (proxy.socketio and proxy.connected_clients > 0): + return try: t1 = time.perf_counter() proxy.socketio.emit("acdata", serializable_data) - proxy.last_acdata_emit = current_time data_path_perf.record_emit(time.perf_counter() - t1) except Exception: logger.exception("Error emitting ACDATA") @@ -210,6 +217,6 @@ def on_statechange_received(data, sender_id=None): try: proxy.socketio.emit("statechange", payload) except Exception as e: - logger.error(f"Proxy->Web: Error sending STATECHANGE: {e}") + logger.error(f"Error emitting STATECHANGE: {e}") logger.info(f"STATECHANGE from {sender_id_str}: simstate={simstate}") diff --git a/WebATM/proxy/perf.py b/WebATM/proxy/perf.py index a5541be..d43a18f 100644 --- a/WebATM/proxy/perf.py +++ b/WebATM/proxy/perf.py @@ -47,6 +47,7 @@ def __init__(self) -> None: except ValueError: self.interval = 5.0 self._window_start = time.time() + self._last_frame_wall: float | None = None self._last_emit_wall: float | None = None self._max_emit_gap = 0.0 self._reset_counters() @@ -54,7 +55,8 @@ def __init__(self) -> None: def _reset_counters(self) -> None: self.received = 0 # ACDATA frames into the handler (any node) self.filtered = 0 # dropped by the active-node filter - self.emits = 0 # frames serialized and emitted + self.serializes = 0 # frames serialized (emit may still fail) + self.emits = 0 # frames actually emitted self.serialize_s = 0.0 self.serialize_max_s = 0.0 self.emit_s = 0.0 @@ -62,9 +64,23 @@ def _reset_counters(self) -> None: # --- recording (cheap no-ops when disabled) -------------------------- def record_received(self) -> None: - """Count one ACDATA frame arriving at the handler (any node).""" - if self.enabled: - self.received += 1 + """Count one ACDATA frame arriving at the handler (any node). + + A frame arriving after an idle gap longer than ``interval`` (process + boot, disconnect/reconnect) restarts the measurement window from that + frame, discarding any partial pre-gap counters. Otherwise the idle + period would dilute the reported CPU shares by an arbitrary factor + and read as a bogus multi-second emit gap. + """ + if not self.enabled: + return + now = time.time() + if self._last_frame_wall is None or now - self._last_frame_wall > self.interval: + self._reset_counters() + self._window_start = now + self._last_emit_wall = None + self._last_frame_wall = now + self.received += 1 def record_filtered(self) -> None: """Count one frame dropped by the active-node filter.""" @@ -79,6 +95,7 @@ def record_serialize(self, seconds: float) -> None: """ if not self.enabled: return + self.serializes += 1 self.serialize_s += seconds if seconds > self.serialize_max_s: self.serialize_max_s = seconds @@ -118,8 +135,8 @@ def maybe_log(self) -> None: if elapsed < self.interval: return if self.received: - throttled = max(0, self.received - self.filtered - self.emits) - avg_ser_s = self.serialize_s / self.emits if self.emits else 0.0 + throttled = max(0, self.received - self.filtered - self.serializes) + avg_ser_s = self.serialize_s / self.serializes if self.serializes else 0.0 avg_emit_ms = 1000.0 * self.emit_s / self.emits if self.emits else 0.0 datapath_cpu = 100.0 * (self.serialize_s + self.emit_s) / elapsed # Estimate the pre-optimization cost: one serialize per received diff --git a/WebATM/server/socket_handlers.py b/WebATM/server/socket_handlers.py index 51540f7..3c0cfd0 100644 --- a/WebATM/server/socket_handlers.py +++ b/WebATM/server/socket_handlers.py @@ -5,6 +5,7 @@ BlueSky events. """ +import time import uuid from flask import current_app, session @@ -142,12 +143,26 @@ def on_command(data): data (dict): Payload with a ``command`` string. Emits a ``command_result`` event with the success flag back to the - sender. + sender. A failed send additionally gets an error ``echo``: no BlueSky + response will follow it, and ``command_result`` carries no + user-visible text, so without the echo the command would vanish + silently. """ command = (data or {}).get("command", "") success = current_app.bluesky_proxy.send_command(command) try: emit("command_result", {"success": success, "command": command}) + if not success: + emit( + "echo", + { + "text": f"Command not sent — not connected to a " + f"BlueSky server: {command}", + "flags": 1, # rendered as an error by the frontend + "timestamp": time.time(), + "sender": None, + }, + ) except Exception as e: logger.info(f"Error emitting command result: {e}") diff --git a/WebATM/static/css/style.css b/WebATM/static/css/style.css index 9f15e54..365d36a 100644 --- a/WebATM/static/css/style.css +++ b/WebATM/static/css/style.css @@ -458,14 +458,14 @@ body { internally) so live simulation data can't resize them and shift the panels below */ .right-panel .traffic-panel { - flex: 0 0 var(--traffic-panel-basis, 200px); + flex: 0 0 200px; min-height: 200px; } /* Aircraft Info is a fixed height sized to fit its full field set with no blank space; extra rows (conflict / 3D overrides) scroll internally. */ .right-panel .aircraft-panel { - flex: 0 0 var(--aircraft-panel-basis, 262px); + flex: 0 0 262px; min-height: 262px; } @@ -477,7 +477,7 @@ body { /* Conflicts grows to fill the column's remaining height so it always reaches the bottom (no gap); content scrolls internally. */ .right-panel .conflicts-panel { - flex: 1 1 var(--conflicts-panel-basis, auto); + flex: 1 1 auto; min-height: 160px; max-height: none; } @@ -489,18 +489,18 @@ body { } .left-panel .node-panel { - flex: 0 0 var(--node-panel-basis, auto); + flex: 0 0 auto; min-height: 160px; } .left-panel .nav-panel { - flex: 0 0 var(--nav-panel-basis, 250px); + flex: 0 0 250px; height: 250px; overflow-y: auto; } .left-panel .display-panel { - flex: 0 0 var(--display-panel-basis, auto); + flex: 0 0 auto; min-height: 200px; } diff --git a/WebATM/templates/index.html b/WebATM/templates/index.html index c615093..6c66536 100644 --- a/WebATM/templates/index.html +++ b/WebATM/templates/index.html @@ -73,13 +73,7 @@ rightPanel: '--right-panel-basis', consoleContainer: '--console-container-basis', consoleSection: '--console-section-basis', - echoSection: '--echo-section-basis', - trafficPanel: '--traffic-panel-basis', - aircraftPanel: '--aircraft-panel-basis', - conflictsPanel: '--conflicts-panel-basis', - nodePanel: '--node-panel-basis', - navPanel: '--nav-panel-basis', - displayPanel: '--display-panel-basis' + echoSection: '--echo-section-basis' }; for (var key in basisVars) { var size = layout[key]; diff --git a/frontend/src/core/App.ts b/frontend/src/core/App.ts index 731b7b3..735b05d 100644 --- a/frontend/src/core/App.ts +++ b/frontend/src/core/App.ts @@ -419,6 +419,14 @@ export class App { */ private setupRouteDataHandler(): void { this.socketManager.setEventHandlers({ + // GUI commands the simulation can't execute (PAN/ZOOM lines in + // scenario files) come back over STACK; run them through the + // same local handlers as console input so they move the map. + onStackCommand: (data) => { + if (data && typeof data.command === 'string') { + this.commandHandler.executeServerCommand(data.command); + } + }, onRouteData: (data: RouteData) => { // Check if we just sent an explicit POS command for this aircraft const isExplicitPosResponse = this.aircraftInteractionManager?.wasLastExplicitPosFor(data.acid) ?? false; @@ -481,16 +489,6 @@ export class App { return this.mapOverlay ? this.mapOverlay.getRouteData() : null; } - /** - * Get the AircraftInteractionManager instance (may be null if not yet - * initialized). Used by RouteDrawingManager to route its trailing POS - * command through the manager's explicit-POS tracking path so the - * resulting ROUTEDATA response is not misclassified as unsolicited. - */ - public getAircraftInteractionManager(): AircraftInteractionManager | null { - return this.aircraftInteractionManager; - } - public getState(): AppState { return this.stateManager.getState(); } diff --git a/frontend/src/core/SocketManager.ts b/frontend/src/core/SocketManager.ts index 4ce3292..72024be 100644 --- a/frontend/src/core/SocketManager.ts +++ b/frontend/src/core/SocketManager.ts @@ -12,7 +12,8 @@ import { CommandDictData, EchoData, Shape, - ShapeBatchData + ShapeBatchData, + StackCommandData } from '../data/types'; import type { StateManager } from './StateManager'; import { parseShapePayload } from './shapePayload'; @@ -45,6 +46,7 @@ interface SocketEventHandlers { onServerDisconnected?: () => void; onReset?: () => void; onCommandDict?: (data: CommandDictData) => void; + onStackCommand?: (data: StackCommandData) => void; } export class SocketManager { @@ -196,6 +198,7 @@ export class SocketManager { ['server_disconnected', 'onServerDisconnected'], ['reset', 'onReset'], ['cmddict', 'onCommandDict'], + ['stack_command', 'onStackCommand'], ]; private setupEventListeners(): void { diff --git a/frontend/src/data/CommandHandler.test.ts b/frontend/src/data/CommandHandler.test.ts index fc4c87f..3e8cbb3 100644 --- a/frontend/src/data/CommandHandler.test.ts +++ b/frontend/src/data/CommandHandler.test.ts @@ -26,8 +26,6 @@ function createAppMock() { isInitialized: vi.fn(() => true), panTo: vi.fn(), setZoom: vi.fn(), - zoomIn: vi.fn(), - zoomOut: vi.fn(), getZoom: vi.fn(() => 8), getCenter: vi.fn((): [number, number] => [4.5, 52.5]), // [west, south, east, north] @@ -192,11 +190,39 @@ describe('CommandHandler', () => { expect(result).toEqual({ handled: true, sendToServer: false }); }); - it('supports ZOOM IN / ZOOM OUT and ZOOMIN / ZOOMOUT', () => { + it('ZOOM IN / ZOOM OUT and ZOOMIN / ZOOMOUT step half a level, like one +/-', () => { handler.handleCommand('ZOOM IN'); - expect(mocks.mapDisplay.zoomIn).toHaveBeenCalledTimes(1); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(8.5); + handler.handleCommand('ZOOM OUT'); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(7.5); + handler.handleCommand('ZOOMIN'); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(8.5); handler.handleCommand('ZOOMOUT'); - expect(mocks.mapDisplay.zoomOut).toHaveBeenCalledTimes(1); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(7.5); + }); + + it('ZOOM IN/OUT accepts an explicit factor (BlueSky: ZOOM IN 2 doubles)', () => { + handler.handleCommand('ZOOM IN 2'); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(9); // 8 + log2(2) + handler.handleCommand('ZOOM OUT 4'); + expect(mocks.mapDisplay.setZoom).toHaveBeenLastCalledWith(6); // 8 - log2(4) + }); + + it('warns on a non-positive or non-numeric factor', () => { + handler.handleCommand('ZOOM IN fast'); + handler.handleCommand('ZOOM OUT 0'); + expect(mocks.mapDisplay.setZoom).not.toHaveBeenCalled(); + expect(addMessage).toHaveBeenCalledTimes(2); + expect(addMessage).toHaveBeenCalledWith( + expect.stringContaining('Invalid zoom factor'), 'warning', 'webatm'); + }); + + it('echoes the level the map actually applied, not the raw request', () => { + // The mock map clamps everything to zoom 8 (getZoom always + // returns 8), standing in for MapLibre's min/max clamping. + handler.handleCommand('ZOOM 99'); + expect(mocks.mapDisplay.setZoom).toHaveBeenCalledWith(99); + expect(addMessage).toHaveBeenCalledWith('Zoom set to 8.0', 'success', 'webatm'); }); it('warns on a non-numeric level', () => { diff --git a/frontend/src/data/CommandHandler.ts b/frontend/src/data/CommandHandler.ts index f73e6f2..d9a138a 100644 --- a/frontend/src/data/CommandHandler.ts +++ b/frontend/src/data/CommandHandler.ts @@ -64,6 +64,13 @@ export class CommandHandler { */ private static readonly QUIT_ALIASES = ['CLOSE', 'END', 'EXIT', 'Q', 'STOP']; + /** + * BlueSky's zoom step is a factor sqrt(2) — one + or - keystroke, and + * also what ZOOM IN/OUT does without an explicit factor. On MapLibre's + * log2 zoom scale that factor is 0.5 levels. + */ + private static readonly ZOOM_STEP_LEVELS = 0.5; + /** * SHOW* display-toggle commands (mirroring BlueSky's GUI commands of the * same names) → the DisplayOptions flag they drive and a label for echo @@ -137,6 +144,40 @@ export class CommandHandler { return { handled: false, sendToServer: true }; } + /** + * Execute a command line the simulation forwarded over BlueSky's STACK + * topic (a scenario-file GUI command such as PAN/ZOOM, relayed by the + * proxy as a `stack_command` event). Runs the local-command handlers + * exactly as if the line had been typed into the console, but never + * sends anything back to the server — the simulation forwarded the line + * precisely because it cannot execute it, so returning it would loop. + */ + public executeServerCommand(command: string): void { + const trimmed = command.trim(); + if (!trimmed) { + return; + } + + const parts = trimmed.split(/\s+/); + const cmd = parts[0].toUpperCase(); + const args = parts.slice(1).join(' '); + + if (/^[+=-]+$/.test(cmd)) { + this.handleZoomShorthand(cmd); + return; + } + + if (this.LOCAL_COMMANDS.includes(cmd)) { + logger.info('CommandHandler', 'Executing server-forwarded command:', trimmed); + this.handleLocalCommand(cmd, args); + return; + } + + // Same benign-no-op semantics the desktop GUIs give commands they + // don't implement: report and ignore. + this.sendEcho(`${cmd}: not supported by the web client (ignored)`, 'warning'); + } + /** * Handle local commands that execute only on client */ @@ -261,71 +302,48 @@ export class CommandHandler { } /** - * Handle ZOOM command - set specific zoom level - * Usage: ZOOM or ZOOM IN or ZOOM OUT + * Handle ZOOM command, mirroring BlueSky's: + * ZOOM set an absolute zoom level + * ZOOM IN/OUT [factor] zoom by factor (default sqrt(2), like one +/-) */ private handleZoomCommand(args: string): CommandResult { - if (!args || args.trim().length === 0) { - this.sendEcho('ZOOM command requires a level (e.g., ZOOM 8) or IN/OUT', 'warning'); - return { handled: true, sendToServer: false }; - } - - const mapDisplay = this.requireMapInitialized(); - if (!mapDisplay) { + const parts = args.trim().split(/\s+/).filter(p => p.length > 0); + if (parts.length === 0) { + this.sendEcho('ZOOM command requires a level (e.g., ZOOM 8) or IN/OUT [factor]', 'warning'); return { handled: true, sendToServer: false }; } - const upperArgs = args.trim().toUpperCase(); - - // Handle ZOOM IN / ZOOM OUT - if (upperArgs === 'IN') { - return this.handleZoomInCommand(); - } - if (upperArgs === 'OUT') { - return this.handleZoomOutCommand(); + const sub = parts[0].toUpperCase(); + if (sub === 'IN' || sub === 'OUT') { + const sign = sub === 'IN' ? 1 : -1; + if (parts[1] === undefined) { + return this.zoomByLevels(sign * CommandHandler.ZOOM_STEP_LEVELS); + } + const factor = parseFloat(parts[1]); + if (isNaN(factor) || factor <= 0) { + this.sendEcho(`Invalid zoom factor: ${parts[1]}`, 'warning'); + return { handled: true, sendToServer: false }; + } + return this.zoomByLevels(sign * Math.log2(factor)); } - // Try to parse as zoom level - const level = parseFloat(args); + const level = parseFloat(parts[0]); if (isNaN(level)) { this.sendEcho('Invalid zoom level. Use a number, IN, or OUT', 'warning'); return { handled: true, sendToServer: false }; } - mapDisplay.setZoom(level); - this.sendEcho(`Zoom set to ${level.toFixed(1)}`, 'success'); - - return { handled: true, sendToServer: false }; + return this.zoomToLevel(level); } - /** - * Handle ZOOMIN command - zoom in one level - */ + /** Handle ZOOMIN command - zoom in one sqrt(2) step */ private handleZoomInCommand(): CommandResult { - const mapDisplay = this.requireMapInitialized(); - if (!mapDisplay) { - return { handled: true, sendToServer: false }; - } - - mapDisplay.zoomIn(); - this.sendEcho('Zoomed in', 'success'); - - return { handled: true, sendToServer: false }; + return this.zoomByLevels(CommandHandler.ZOOM_STEP_LEVELS); } - /** - * Handle ZOOMOUT command - zoom out one level - */ + /** Handle ZOOMOUT command - zoom out one sqrt(2) step */ private handleZoomOutCommand(): CommandResult { - const mapDisplay = this.requireMapInitialized(); - if (!mapDisplay) { - return { handled: true, sendToServer: false }; - } - - mapDisplay.zoomOut(); - this.sendEcho('Zoomed out', 'success'); - - return { handled: true, sendToServer: false }; + return this.zoomByLevels(-CommandHandler.ZOOM_STEP_LEVELS); } /** @@ -361,24 +379,37 @@ export class CommandHandler { /** * Handle BlueSky's +/- console shorthand: each + or = zooms in one - * sqrt(2) step, each - zooms out one. On MapLibre's log2 zoom scale a - * sqrt(2) factor is 0.5 zoom levels. + * sqrt(2) step, each - zooms out one. */ private handleZoomShorthand(token: string): CommandResult { - const mapDisplay = this.requireMapInitialized(); - if (!mapDisplay) { - return { handled: true, sendToServer: false }; - } - let steps = 0; for (const ch of token) { steps += ch === '-' ? -1 : 1; } + return this.zoomByLevels(CommandHandler.ZOOM_STEP_LEVELS * steps); + } - const newZoom = mapDisplay.getZoom() + 0.5 * steps; - mapDisplay.setZoom(newZoom); - this.sendEcho(`Zoom set to ${newZoom.toFixed(1)}`, 'success'); + /** Zoom by a delta in MapLibre levels and echo the applied level. */ + private zoomByLevels(deltaLevels: number): CommandResult { + const mapDisplay = this.requireMapInitialized(); + if (!mapDisplay) { + return { handled: true, sendToServer: false }; + } + return this.zoomToLevel(mapDisplay.getZoom() + deltaLevels); + } + /** + * Set an absolute zoom level, echoing the level actually applied — + * MapLibre clamps to its min/max zoom, so reading it back keeps the + * echo honest for out-of-range requests like ZOOM 99. + */ + private zoomToLevel(level: number): CommandResult { + const mapDisplay = this.requireMapInitialized(); + if (!mapDisplay) { + return { handled: true, sendToServer: false }; + } + mapDisplay.setZoom(level); + this.sendEcho(`Zoom set to ${mapDisplay.getZoom().toFixed(1)}`, 'success'); return { handled: true, sendToServer: false }; } diff --git a/frontend/src/data/CommandSignature.ts b/frontend/src/data/CommandSignature.ts index a0820ef..dc1d25c 100644 --- a/frontend/src/data/CommandSignature.ts +++ b/frontend/src/data/CommandSignature.ts @@ -47,7 +47,7 @@ const DISPLAY_SIGNATURE_OVERRIDES: Record = { */ export const LOCAL_COMMAND_SIGNATURES: Record = { PAN: 'lat/acid/wpt/apt/LEFT/RIGHT/UP/DOWN,[lon]', - ZOOM: 'level/IN/OUT', + ZOOM: 'level/IN/OUT,[factor]', ZOOMIN: '', ZOOMOUT: '', SWRAD: 'APT/WPT/LABEL/SYM/TRAIL/POLY,[level]', diff --git a/frontend/src/data/types.ts b/frontend/src/data/types.ts index dbde495..e15b0d8 100644 --- a/frontend/src/data/types.ts +++ b/frontend/src/data/types.ts @@ -325,6 +325,16 @@ export interface EchoData { sender?: string; } +/** + * A GUI/client command line the simulation forwarded over BlueSky's STACK + * topic (e.g. a PAN/ZOOM line from a scenario file), relayed by the proxy as + * the `stack_command` Socket.IO event for the CommandHandler to execute. + */ +export interface StackCommandData { + command: string; + timestamp?: number; +} + // Modal and UI Component Types export interface ModalOptions { @@ -441,12 +451,6 @@ export interface PanelLayoutState { consoleContainer: string; consoleSection: string; echoSection: string; - trafficPanel: string; - aircraftPanel: string; - conflictsPanel: string; - nodePanel: string; - navPanel: string; - displayPanel: string; timestamp: number; version: string; } diff --git a/frontend/src/integrated/ProcessControlManager.test.ts b/frontend/src/integrated/ProcessControlManager.test.ts index fe67bd8..6fd0f98 100644 --- a/frontend/src/integrated/ProcessControlManager.test.ts +++ b/frontend/src/integrated/ProcessControlManager.test.ts @@ -346,6 +346,36 @@ describe('ProcessControlManager', () => { }); }); + it('a status probe resolving mid-action does not overwrite the lifecycle status', async () => { + // Hold the construction-time probe open so it resolves after Start. + let resolveProbe!: (value: { ok: boolean; json: () => Promise<{ running: boolean }> }) => void; + const probePromise = new Promise<{ ok: boolean; json: () => Promise<{ running: boolean }> }>( + (resolve) => { + resolveProbe = resolve; + }, + ); + // Keep the connect attempt pending so the action still owns the status. + autoConnect.mockImplementation(() => new Promise(() => {})); + + const fetchMock = vi.fn((url: string) => { + if (url.endsWith('/status')) return probePromise; + return Promise.resolve({ + ok: true, + json: async () => ({ success: true, status: 'running', message: 'BlueSky server started' }), + }); + }); + vi.stubGlobal('fetch', fetchMock); + + makeManager(); + click('start'); + await vi.waitFor(() => expect(statusText()).toBe('connecting…')); + + // The stale probe lands now; it must not clobber "connecting…" with "stopped". + resolveProbe({ ok: true, json: async () => ({ running: false }) }); + await new Promise((r) => setTimeout(r, 20)); + expect(statusText()).toBe('connecting…'); + }); + describe('connection-status reconciliation', () => { it('re-probes and re-renders when the live BlueSky connection flips', async () => { const conn = makeConnState(true); diff --git a/frontend/src/integrated/ProcessControlManager.ts b/frontend/src/integrated/ProcessControlManager.ts index 1757421..a1835b4 100644 --- a/frontend/src/integrated/ProcessControlManager.ts +++ b/frontend/src/integrated/ProcessControlManager.ts @@ -152,6 +152,9 @@ export class ProcessControlManager { try { const res = await fetch('/api/integrated/server/status'); const data = await res.json(); + // A lifecycle action may have started while this probe was in + // flight; its transient status owns the display until it settles. + if (this.busy) return; this.serverRunning = !!data.running; this.renderStatus(); } catch { diff --git a/frontend/src/integrated/ServerLogStreamManager.test.ts b/frontend/src/integrated/ServerLogStreamManager.test.ts index 33bddd5..2b18183 100644 --- a/frontend/src/integrated/ServerLogStreamManager.test.ts +++ b/frontend/src/integrated/ServerLogStreamManager.test.ts @@ -26,6 +26,7 @@ function buildEchoScaffold(): void {
+ @@ -169,6 +170,37 @@ describe('ServerLogStreamManager', () => { expect(historyRequestCount(socket)).toBe(1); }); + it('activate() hides every shared header control, including Create Log', () => { + // The Output Log browser view leaves these visible; switching to the + // Server Log tab must not leave any of them floating above the pane. + manager.activate(); + + for (const id of [ + 'clear-echo', + 'create-log-btn', + 'refresh-output-files', + 'clear-log-stream', + 'stop-log-stream', + 'log-stream-filename', + ]) { + expect(byId(id).style.display, `#${id} should be hidden`).toBe('none'); + } + }); + + it('activate() jumps to the newest lines after streaming while hidden', () => { + serverLogListener(socket)({ lines: lines(1, 2, 3) }); + + // While the pane is hidden its scrollHeight is 0, so the ingest-time + // auto-scroll could not track the stream. Simulate the layout being + // measurable again once the tab is shown. + const out = byId('server-log-output'); + Object.defineProperty(out, 'scrollHeight', { value: 500, configurable: true }); + out.scrollTop = 0; + + manager.activate(); + expect(out.scrollTop).toBe(500); + }); + it('re-requests history on reconnect to backfill lines missed while down', () => { manager.activate(); expect(historyRequestCount(socket)).toBe(1); diff --git a/frontend/src/integrated/ServerLogStreamManager.ts b/frontend/src/integrated/ServerLogStreamManager.ts index 52da26f..4703a18 100644 --- a/frontend/src/integrated/ServerLogStreamManager.ts +++ b/frontend/src/integrated/ServerLogStreamManager.ts @@ -3,22 +3,19 @@ import type { ServerLogBatch, ServerLogLine } from '../data/types'; import { logger } from '../utils/Logger'; /** - * Live server-log tab (integrated build only). - * - * Adds a third tab — "Server Log" — next to the existing Echo / Output Log - * tabs and renders the live, in-order stdout/stderr of the `bluesky --headless` - * process tree pushed over the `server_log` Socket.IO event. This is distinct - * from the file-polling "Output Log" tab (LogStreamManager): this one streams a - * running process, that one browses output files on disk. - * - * The tab button and pane are injected at runtime so the core `index.html` - * carries no integrated-specific markup (preserving build-time exclusion). + * Live "Server Log" tab (integrated build only): a third tab next to Echo / + * Output Log rendering the in-order stdout/stderr of the `bluesky --headless` + * process tree, pushed over the `server_log` Socket.IO event. Unlike the + * file-polling Output Log tab, this streams a running process. The tab button + * and pane are injected at runtime so the core `index.html` carries no + * integrated-specific markup (preserving build-time exclusion). */ // Shared header controls owned by the core echo/output-log tabs. Hidden while // the Server Log tab is active; the core tab handlers restore them on switch. const SHARED_CONTROL_IDS = [ 'clear-echo', + 'create-log-btn', 'refresh-output-files', 'clear-log-stream', 'stop-log-stream', @@ -87,8 +84,7 @@ export class ServerLogStreamManager { toolbar.style.gap = '4px'; toolbar.style.padding = '4px 8px'; toolbar.style.borderBottom = '1px solid #404040'; - // The action buttons / status span carry generic data-bs-action and - // .bs-status hooks (alongside their ids) so ProcessControlManager can + // The data-bs-action / .bs-status hooks let ProcessControlManager // drive this toolbar and the Settings-modal controls uniformly. toolbar.innerHTML = ` @@ -129,8 +125,7 @@ export class ServerLogStreamManager { this.ingest(data.lines); } }); - // A reconnect leaves a gap: lines broadcast while the socket was down - // never reach this client. The backend buffers them for replay, so + // Lines broadcast while the socket was down never reached this client; // re-request the history (seq dedup makes the overlap harmless). this.socket.on('connect', () => { if (this.historyRequested) { @@ -155,6 +150,10 @@ export class ServerLogStreamManager { if (el) el.style.display = 'none'; } + // Lines stream in while the pane is hidden (where scrollHeight is 0, + // so auto-scroll can't track them): jump to the newest lines now. + if (this.output) this.output.scrollTop = this.output.scrollHeight; + if (!this.historyRequested) { this.historyRequested = true; this.socket?.emit('request_log_history'); @@ -168,9 +167,8 @@ export class ServerLogStreamManager { /** * A changed stream id means the backend restarted: the new boot's seqs - * restart at 1 and would all collide with the previous session's, so drop - * the stale buffer (its process tree is gone server-side too) before - * ingesting the new stream. + * restart at 1 and would collide with the old session's, so drop the + * stale buffer before ingesting the new stream. */ private trackStream(stream: string | undefined): void { if (typeof stream !== 'string' || stream === this.streamId) return; diff --git a/frontend/src/ui/ConsoleMapPicker.test.ts b/frontend/src/ui/ConsoleMapPicker.test.ts index 3b23fde..b587c7c 100644 --- a/frontend/src/ui/ConsoleMapPicker.test.ts +++ b/frontend/src/ui/ConsoleMapPicker.test.ts @@ -7,7 +7,7 @@ * new aircraft straight at a known airport/waypoint - mirroring the * Draw-Aircraft flow (AircraftCreationManager), which snaps both clicks. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ConsoleMapPicker, GeoContext } from './ConsoleMapPicker'; import type { MapDisplay } from './map/MapDisplay'; import type { Console } from './Console'; @@ -105,14 +105,14 @@ const polyContext: GeoContext = { describe('ConsoleMapPicker navaid snapping', () => { let map: ReturnType; let snapper: { snap: ReturnType; highlight: ReturnType; clearHighlight: ReturnType }; - let consoleInstance: { insertGeoValue: ReturnType }; + let consoleInstance: { insertGeoValue: ReturnType; clearInput: ReturnType }; let picker: ConsoleMapPicker; beforeEach(() => { document.body.innerHTML = '
'; map = createFakeMap(); snapper = { snap: vi.fn(() => null), highlight: vi.fn(), clearHighlight: vi.fn() }; - consoleInstance = { insertGeoValue: vi.fn() }; + consoleInstance = { insertGeoValue: vi.fn(), clearInput: vi.fn() }; const mapDisplay = { getMap: () => map } as unknown as MapDisplay; picker = new ConsoleMapPicker( mapDisplay, @@ -121,6 +121,12 @@ describe('ConsoleMapPicker navaid snapping', () => { ); }); + afterEach(() => { + // Release the document-level Escape listener a POLY session binds, + // so one test's picker can never act on another test's key events. + picker.disable(); + }); + it('aims a heading pick at the snapped navaid', () => { const snapResult: SnapResult = { lat: 53, lng: 5, ident: 'EHAM', kind: 'airport' }; snapper.snap.mockReturnValue(snapResult); @@ -230,4 +236,61 @@ describe('ConsoleMapPicker navaid snapping', () => { picker.disable(); expect(banner.style.display).toBe('none'); }); + + describe('POLY Escape handling', () => { + function pressEscape(): KeyboardEvent { + const e = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true + }); + document.dispatchEvent(e); + return e; + } + + it('leaves a typed-only POLY command intact on Escape', () => { + // The user typed every coordinate by hand (no map clicks), then + // pressed Escape - e.g. just to dismiss the autocomplete. The + // input must survive, exactly as it does for CRE and every other + // command; only the console's own handler should act. + picker.enable(polyContext); + + const e = pressEscape(); + + expect(consoleInstance.clearInput).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + it('cancels a click-drawing in progress on Escape', () => { + picker.enable(polyContext); + map.fire('click', clickEvent(52.5, 4.5)); // one map-clicked vertex + + const e = pressEscape(); + + expect(consoleInstance.clearInput).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it('forgets clicked vertices when the session ends', () => { + picker.enable(polyContext); + map.fire('click', clickEvent(52.5, 4.5)); + picker.disable(); + + // New session, typed-only: the old session's click must not make + // Escape destructive again. + picker.enable(polyContext); + pressEscape(); + + expect(consoleInstance.clearInput).not.toHaveBeenCalled(); + }); + + it('a CRE coordinate pick never arms the Escape cancel', () => { + picker.enable(latContext); // CRE lat/lon slot + map.fire('click', clickEvent(52.5, 4.5)); + + const e = pressEscape(); + + expect(consoleInstance.clearInput).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + }); }); diff --git a/frontend/src/ui/ConsoleMapPicker.ts b/frontend/src/ui/ConsoleMapPicker.ts index 2d3b5b9..c670381 100644 --- a/frontend/src/ui/ConsoleMapPicker.ts +++ b/frontend/src/ui/ConsoleMapPicker.ts @@ -62,6 +62,11 @@ export class ConsoleMapPicker { // Mirrors ShapeDrawingManager's right-click/Esc UX. private polyContextHandler: ((e: MapMouseEvent) => void) | null = null; private polyEscapeHandler: ((e: KeyboardEvent) => void) | null = null; + // Vertices added by map clicks in the active POLY session. Escape only + // cancels (clears the input) once a click-drawing is actually in + // progress; a purely typed command keeps the console's normal Escape + // behaviour (dismiss dropdowns, keep the text) like every other command. + private polyClickedVertices = 0; private hintElement: HTMLDivElement | null = null; @@ -177,6 +182,10 @@ export class ConsoleMapPicker { // firing redundant cleanup. this.polyEscapeHandler = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; + // No map-clicked vertices yet: this is a typed command, not a + // drawing. Let the event through to the console's own Escape + // handling (hide dropdowns, keep the input). + if (this.polyClickedVertices === 0) return; e.preventDefault(); e.stopPropagation(); this.consoleInstance.clearInput(); @@ -245,6 +254,7 @@ export class ConsoleMapPicker { this.snapHoverHandler = null; this.polyContextHandler = null; this.polyEscapeHandler = null; + this.polyClickedVertices = 0; this.hideHint(); @@ -346,6 +356,9 @@ export class ConsoleMapPicker { ctx.params[lonIdx] === 'lon' ) { const value = `${clickLat.toFixed(6)},${clickLon.toFixed(6)}`; + if (this.POLY_FAMILY.has(ctx.command)) { + this.polyClickedVertices++; + } this.consoleInstance.insertGeoValue(value, latIdx, 2); return; } diff --git a/frontend/src/ui/map/MapDisplay.ts b/frontend/src/ui/map/MapDisplay.ts index 949560d..9bb53da 100644 --- a/frontend/src/ui/map/MapDisplay.ts +++ b/frontend/src/ui/map/MapDisplay.ts @@ -461,22 +461,6 @@ export class MapDisplay { this.map.setZoom(zoom); } - /** - * Zoom in by one level - */ - public zoomIn(): void { - if (!this.map) return; - this.map.zoomIn(); - } - - /** - * Zoom out by one level - */ - public zoomOut(): void { - if (!this.map) return; - this.map.zoomOut(); - } - /** * Pan to a specific location (animated) */ diff --git a/frontend/src/ui/map/aircraft/Aircraft3DFleet.test.ts b/frontend/src/ui/map/aircraft/Aircraft3DFleet.test.ts index f2c9cf4..b39de44 100644 --- a/frontend/src/ui/map/aircraft/Aircraft3DFleet.test.ts +++ b/frontend/src/ui/map/aircraft/Aircraft3DFleet.test.ts @@ -184,6 +184,45 @@ describe('Aircraft3DFleet pending-model fallback', () => { expect(mercatorGroup.children.length).toBe(0); }); + it('creating a mesh drops a stale queued wait so the old model loading later cannot ghost it', () => { + // AC1 queued on a slow model, then created directly with a cached one + // (e.g. a per-aircraft override resolved on the next data tick). + const { model } = multiMaterialModel(); + const models: Record = { 'A320.glb': model }; + const { fleet, mercatorGroup } = makeFleetWithModels(models); + + fleet.create('AC1', DATA, 'slow.glb'); // queued, model still loading + fleet.create('AC1', DATA, 'A320.glb'); // cached -> live mesh now + + expect(fleet.get('AC1')?.modelPath).toBe('A320.glb'); + expect(mercatorGroup.children.length).toBe(1); + + // The slow model finally loads: the stale queue entry must be gone, + // or this would overwrite the registry and orphan the live mesh. + models['slow.glb'] = multiMaterialModel().model; + fleet.processPending('slow.glb'); + + expect(fleet.get('AC1')?.modelPath).toBe('A320.glb'); + expect(mercatorGroup.children.length).toBe(1); + }); + + it('create() replaces an existing mesh instead of orphaning it in the group', () => { + const models: Record = { + 'a.glb': multiMaterialModel().model, + 'b.glb': multiMaterialModel().model, + }; + const { fleet, mercatorGroup } = makeFleetWithModels(models); + + fleet.create('AC1', DATA, 'a.glb'); + const firstMesh = fleet.get('AC1')?.mesh; + fleet.create('AC1', DATA, 'b.glb'); + + expect(fleet.size).toBe(1); + expect(fleet.get('AC1')?.modelPath).toBe('b.glb'); + expect(mercatorGroup.children.length).toBe(1); + expect(firstMesh && mercatorGroup.children.includes(firstMesh)).toBe(false); + }); + it('redirectPending leaves aircraft queued for other models alone', () => { const models: Record = {}; const { fleet } = makeFleetWithModels(models); diff --git a/frontend/src/ui/map/aircraft/Aircraft3DFleet.ts b/frontend/src/ui/map/aircraft/Aircraft3DFleet.ts index 47ef02d..78a04bf 100644 --- a/frontend/src/ui/map/aircraft/Aircraft3DFleet.ts +++ b/frontend/src/ui/map/aircraft/Aircraft3DFleet.ts @@ -94,10 +94,18 @@ export class Aircraft3DFleet { return; } - // SkeletonUtils.clone instead of Object3D.clone: identical for plain - // meshes, but re-binds skinned meshes to their cloned bones so rigged - // models animate per-aircraft instead of referencing the cached - // master's skeleton. Geometry/materials stay shared either way. + // This creation supersedes any queued wait for another model — + // without this, that model finishing would create a second mesh + // for the id, orphaning this one in its group as a frozen ghost. + this.pendingAircraft.delete(id); + // Replace, never duplicate: aircraft.set() alone would leak the + // previous mesh into the scene with no registry entry to remove it. + if (this.aircraft.has(id)) { + this.remove(id); + } + + // SkeletonUtils.clone re-binds skinned meshes to their cloned bones + // (plain meshes clone identically); geometry/materials stay shared. const mesh = cloneModelScene(model); mesh.userData.realScale = this.computeRealScale(data, modelPath); // Stamp the aircraft id so transform methods can look up @@ -156,11 +164,9 @@ export class Aircraft3DFleet { } /** - * Detach an aircraft mesh from its group. The mesh is a clone that - * shares the cached model's geometry/materials, so those GPU resources - * are NOT disposed here — the model loader owns and disposes them when - * its cache is cleared. Disposing per-clone would tear down resources - * still used by every other aircraft of the same type. + * Detach an aircraft mesh from its group. The clone shares the cached + * model's geometry/materials, so nothing is disposed here — the model + * loader owns those GPU resources and disposes them with its cache. */ remove(id: string): void { const aircraftMesh = this.aircraft.get(id); @@ -219,22 +225,29 @@ export class Aircraft3DFleet { }); } - /** - * Create meshes for aircraft that were queued waiting for `path` to load. - */ - processPending(path: string): void { - const ready: Array<{ id: string; data: AircraftMeshData }> = []; + /** Remove and return the queue entries waiting on `path`. */ + private takePendingForPath(path: string): Array<{ id: string; data: AircraftMeshData }> { + const taken: Array<{ id: string; data: AircraftMeshData }> = []; this.pendingAircraft.forEach((entry, id) => { if (entry.modelPath === path) { - ready.push({ id, data: entry.data }); + taken.push({ id, data: entry.data }); } }); + for (const { id } of taken) { + this.pendingAircraft.delete(id); + } + return taken; + } + /** + * Create meshes for aircraft that were queued waiting for `path` to load. + */ + processPending(path: string): void { + const ready = this.takePendingForPath(path); if (ready.length === 0) return; logger.debug('Aircraft3DFleet', `Processing ${ready.length} pending aircraft for ${path}`); for (const { id, data } of ready) { - this.pendingAircraft.delete(id); this.create(id, data, path); } } @@ -246,15 +259,7 @@ export class Aircraft3DFleet { * stay absent from the 3D scene. */ redirectPending(fromPath: string, toPath: string | null): void { - const stranded: Array<{ id: string; data: AircraftMeshData }> = []; - this.pendingAircraft.forEach((entry, id) => { - if (entry.modelPath === fromPath) { - stranded.push({ id, data: entry.data }); - } - }); - - for (const { id, data } of stranded) { - this.pendingAircraft.delete(id); + for (const { id, data } of this.takePendingForPath(fromPath)) { if (toPath) { this.create(id, data, toPath); } else { @@ -319,9 +324,8 @@ export class Aircraft3DFleet { } /** - * Detach every live mesh from its group and empty the registry. Shared - * by reload and full-teardown paths. Like remove(), this only detaches; - * the cached model's shared geometry/materials are disposed by the loader. + * Detach every live mesh from its group and empty the registry. + * Like remove(), detach only — the loader owns shared GPU resources. */ private removeAllMeshes(): void { this.aircraft.forEach((aircraftMesh, id) => { diff --git a/frontend/src/ui/map/routes/RouteConstraintsModal.test.ts b/frontend/src/ui/map/routes/RouteConstraintsModal.test.ts index 54be384..69d5e26 100644 --- a/frontend/src/ui/map/routes/RouteConstraintsModal.test.ts +++ b/frontend/src/ui/map/routes/RouteConstraintsModal.test.ts @@ -47,6 +47,7 @@ describe('RouteConstraintsModal', () => { sendCommand = vi.fn<(cmd: string) => Promise>(async () => true); onComplete = vi.fn<() => void>(); onCancel = vi.fn<() => void>(); + vi.stubGlobal('alert', vi.fn()); }); it('reports a single cancel when the modal is closed while a route is pending', () => { @@ -168,6 +169,97 @@ describe('RouteConstraintsModal', () => { expect(sendCommand).toHaveBeenCalledWith('ADDWPT KL204 52.000000,4.000000,20000'); }); + it('a failed send keeps the modal open and a retry resumes with the unsent waypoints only', async () => { + // First waypoint sends, second fails (connection dropped), then the + // connection recovers for the retry. + sendCommand = vi.fn<(cmd: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + const modal = makeModal(); + modal.show('KL204', [{ lat: 52, lng: 4 }, { lat: 53, lng: 5 }], 'ft', 'knots'); + + const submitBtn = document.getElementById('submit-route-constraints-btn') as HTMLButtonElement; + submitBtn.click(); + await vi.waitFor(() => expect(sendCommand).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(submitBtn.disabled).toBe(false)); + + // The route must survive the failure: modal stays open, no callback. + expect(modalManager.isOpen(MODAL_ID)).toBe(true); + expect(onComplete).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + // Retry: only the failed waypoint is re-sent — the first one is + // already in BlueSky and re-sending it would duplicate it. + submitBtn.click(); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(sendCommand).toHaveBeenCalledTimes(3); + expect(sendCommand).toHaveBeenNthCalledWith(2, 'ADDWPT KL204 53.000000,5.000000'); + expect(sendCommand).toHaveBeenNthCalledWith(3, 'ADDWPT KL204 53.000000,5.000000'); + expect(modalManager.isOpen(MODAL_ID)).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('a send that throws keeps the modal open for a retry as well', async () => { + sendCommand = vi.fn<(cmd: string) => Promise>() + .mockRejectedValueOnce(new Error('socket error')) + .mockResolvedValue(true); + const modal = makeModal(); + modal.show('KL204', [{ lat: 52, lng: 4 }], 'ft', 'knots'); + + const submitBtn = document.getElementById('submit-route-constraints-btn') as HTMLButtonElement; + submitBtn.click(); + await vi.waitFor(() => expect(sendCommand).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(submitBtn.disabled).toBe(false)); + + expect(modalManager.isOpen(MODAL_ID)).toBe(true); + expect(onComplete).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + submitBtn.click(); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('a failure after the modal was closed mid-send reports a single cancel', async () => { + const resolvers: Array<(ok: boolean) => void> = []; + sendCommand = vi.fn<(cmd: string) => Promise>( + () => new Promise(resolve => { resolvers.push(resolve); }) + ); + const modal = makeModal(); + modal.show('KL204', [{ lat: 52, lng: 4 }], 'ft', 'knots'); + + (document.getElementById('submit-route-constraints-btn') as HTMLButtonElement).click(); + modalManager.close(MODAL_ID); + expect(onCancel).not.toHaveBeenCalled(); // suppressed while sending + + resolvers[0](false); // ...but the send then fails with the modal gone + await vi.waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('a fresh show() after a failed send restarts from the first waypoint', async () => { + sendCommand = vi.fn<(cmd: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + const modal = makeModal(); + modal.show('KL204', [{ lat: 52, lng: 4 }, { lat: 53, lng: 5 }], 'ft', 'knots'); + + const submitBtn = document.getElementById('submit-route-constraints-btn') as HTMLButtonElement; + submitBtn.click(); + await vi.waitFor(() => expect(sendCommand).toHaveBeenCalledTimes(2)); + + // Abandon the pending route and draw a new one: the resume index + // must not leak into the new route. + modalManager.close(MODAL_ID); + expect(onCancel).toHaveBeenCalledTimes(1); + modal.show('PH101', [{ lat: 50, lng: 3 }], 'ft', 'knots'); + submitBtn.click(); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(sendCommand).toHaveBeenLastCalledWith('ADDWPT PH101 50.000000,3.000000'); + }); + it('converts constraints from the captured units to feet and knots', async () => { const modal = makeModal(); modal.show('KL204', [{ lat: 52, lng: 4 }], 'fl', 'km/h'); diff --git a/frontend/src/ui/map/routes/RouteConstraintsModal.ts b/frontend/src/ui/map/routes/RouteConstraintsModal.ts index 55a0b3f..1ab459f 100644 --- a/frontend/src/ui/map/routes/RouteConstraintsModal.ts +++ b/frontend/src/ui/map/routes/RouteConstraintsModal.ts @@ -33,6 +33,10 @@ export class RouteConstraintsModal { // Blocks re-entrant submits (double-click) and stops a mid-send modal // close from reporting the route as cancelled. private sending = false; + // Index of the first waypoint command not yet sent. A retry after a + // failed submit resumes here instead of re-sending (and duplicating) + // waypoints BlueSky already accepted. + private nextSendIndex = 0; constructor(app: App, onComplete: () => void, onCancel: () => void) { this.app = app; @@ -79,6 +83,7 @@ export class RouteConstraintsModal { ): void { this.active = { acid, points, altUnit, spdUnit }; this.constraintRows = points.map(() => ({ alt: null, spd: null })); + this.nextSendIndex = 0; const table = document.getElementById('route-constraints-table') as HTMLTableElement | null; if (!table) { @@ -112,6 +117,9 @@ export class RouteConstraintsModal { const tbody = table.querySelector('tbody'); if (!tbody) { logger.error('RouteConstraintsModal', 'route-constraints-table has no tbody'); + // Same fallback as a missing table: submit unconstrained rather + // than stranding the route with no modal and no callback. + void this.submit(); return; } tbody.innerHTML = ''; @@ -214,17 +222,20 @@ export class RouteConstraintsModal { this.applyBulkConstraintsIfEnabled(); + // Regenerated on every (re)try; commands map 1:1 to waypoints, so + // skipping the first nextSendIndex entries resumes a partial send. const commands = this.generateCommands(); const consoleInstance = this.app.getConsole(); const acid = this.active.acid; // 50 ms stays snappy for small routes while giving the proxy/ZMQ - // pipeline room to serialize sends cleanly. + // pipeline room to serialize sends cleanly (back-to-back sends race + // on the shared socket and crash BlueSky with msgpack errors). const COMMAND_INTERVAL_MS = 50; logger.info( 'RouteConstraintsModal', - `Sending ${commands.length} ADDWPT command(s) for ${acid} (${COMMAND_INTERVAL_MS}ms spacing)` + `Sending ${commands.length - this.nextSendIndex} ADDWPT command(s) for ${acid} (${COMMAND_INTERVAL_MS}ms spacing)` ); this.sending = true; @@ -232,18 +243,19 @@ export class RouteConstraintsModal { if (submitBtn) submitBtn.disabled = true; try { - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; + while (this.nextSendIndex < commands.length) { + const command = commands[this.nextSendIndex]; logger.debug('RouteConstraintsModal', `Sending: ${command}`); const ok = await this.app.sendCommand(command); if (!ok) { - alert('Failed to send a waypoint command. Please check your connection.'); + alert('Failed to send a waypoint command — check your connection. The route is kept; press Submit to retry the remaining waypoints.'); break; } + this.nextSendIndex++; if (consoleInstance) { consoleInstance.displaySentCommand(command); } - if (i < commands.length - 1) { + if (this.nextSendIndex < commands.length) { await this.sleep(COMMAND_INTERVAL_MS); } } @@ -258,6 +270,19 @@ export class RouteConstraintsModal { if (submitBtn) submitBtn.disabled = false; } + if (this.nextSendIndex < commands.length) { + // Send failed part-way. Keep the route pending so Submit can + // retry the remaining waypoints — unless the modal was closed + // mid-send, in which case there is no UI left to retry from and + // we report the cancel that the close event suppressed while + // `sending` was set. + if (!modalManager.isOpen(MODAL_ID)) { + this.active = null; + this.onCancel(); + } + return; + } + // Clear `active` before closing so the close event isn't a cancel. this.active = null; modalManager.close(MODAL_ID); diff --git a/frontend/src/ui/panels/PanelResizer.test.ts b/frontend/src/ui/panels/PanelResizer.test.ts index 5d0dbdd..78b98cc 100644 --- a/frontend/src/ui/panels/PanelResizer.test.ts +++ b/frontend/src/ui/panels/PanelResizer.test.ts @@ -11,7 +11,7 @@ import { PanelResizer } from './PanelResizer'; function buildLayout(): void { document.body.innerHTML = `
-
+
@@ -19,6 +19,7 @@ function buildLayout(): void {
+
`; @@ -36,6 +37,16 @@ function mouseup(): void { document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 100, clientY: 100 })); } +function mousemove(x: number, buttons: number): void { + document.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, clientX: x, clientY: 100, buttons + })); +} + +function mapElement(): HTMLElement { + return document.getElementById('map') as HTMLElement; +} + describe('PanelResizer mousedown handling', () => { let resizer: PanelResizer; @@ -86,4 +97,89 @@ describe('PanelResizer mousedown handling', () => { // Layout is saved under the StorageManager namespace. expect(localStorage.getItem('webatm-panel-layout')).not.toBeNull(); }); + + it('disables map pointer events during the drag and restores them on mouseup', () => { + mousedown(handle('left-map')); + expect(mapElement().style.pointerEvents).toBe('none'); + + mouseup(); + expect(mapElement().style.pointerEvents).toBe(''); + }); + + it('does not persist the dropped per-section layout keys', () => { + mousedown(handle('left-map')); + mouseup(); + + const saved = JSON.parse(localStorage.getItem('webatm-panel-layout') as string); + expect(saved).not.toHaveProperty('trafficPanel'); + expect(saved).not.toHaveProperty('displayPanel'); + expect(saved).toHaveProperty('version'); + }); +}); + +describe('PanelResizer lost-mouseup recovery', () => { + let resizer: PanelResizer; + + beforeEach(() => { + localStorage.clear(); + buildLayout(); + resizer = new PanelResizer(); + }); + + afterEach(() => { + resizer.destroy(); + document.body.className = ''; + document.body.style.cursor = ''; + }); + + it('keeps dragging while a button is held', () => { + const h = handle('left-map'); + mousedown(h); + mousemove(150, 1); + + expect(h.classList.contains('resizing')).toBe(true); + expect(mapElement().style.pointerEvents).toBe('none'); + }); + + it('ends the drag when a mousemove arrives with no button held', () => { + const h = handle('left-map'); + mousedown(h); + // Simulates a mouseup lost outside the window: the next move has + // buttons === 0 and must tear the drag down. + mousemove(150, 0); + + expect(h.classList.contains('resizing')).toBe(false); + expect(document.body.classList.contains('no-select')).toBe(false); + expect(document.body.style.cursor).toBe(''); + expect(mapElement().style.pointerEvents).toBe(''); + }); + + it('ends the drag when the window loses focus', () => { + const h = handle('content-console'); + mousedown(h); + window.dispatchEvent(new Event('blur')); + + expect(h.classList.contains('resizing')).toBe(false); + expect(document.body.style.cursor).toBe(''); + expect(mapElement().style.pointerEvents).toBe(''); + }); + + it('destroy() mid-drag releases the stuck drag state', () => { + const h = handle('left-map'); + mousedown(h); + resizer.destroy(); + + expect(h.classList.contains('resizing')).toBe(false); + expect(document.body.classList.contains('no-select')).toBe(false); + expect(mapElement().style.pointerEvents).toBe(''); + }); + + it('never writes an inline flex-basis on the map container', () => { + mousedown(handle('map-right')); + mousemove(150, 1); + mouseup(); + + const map = document.querySelector('.map-container') as HTMLElement; + expect(map.style.flexBasis).toBe(''); + }); }); diff --git a/frontend/src/ui/panels/PanelResizer.ts b/frontend/src/ui/panels/PanelResizer.ts index 2487092..dbe3d1e 100644 --- a/frontend/src/ui/panels/PanelResizer.ts +++ b/frontend/src/ui/panels/PanelResizer.ts @@ -47,6 +47,7 @@ export class PanelResizer { private isDragging: boolean = false; private currentHandle: HTMLElement | null = null; + private activeConfig: PanelConfig | null = null; private startPos: Position = { x: 0, y: 0 }; private startSizes: PanelSizes = {}; private handles: NodeListOf | null = null; @@ -117,7 +118,11 @@ export class PanelResizer { // Global mouse events document.addEventListener('mousemove', (e) => this.onMouseMove(e), { signal }); - document.addEventListener('mouseup', (e) => this.onMouseUp(e), { signal }); + document.addEventListener('mouseup', () => this.endDrag(), { signal }); + + // A drag can also end without a mouseup reaching us (focus loss, + // alt-tab); recover instead of leaving the drag state stuck. + window.addEventListener('blur', () => this.endDrag(), { signal }); // Prevent text selection during resize document.addEventListener('selectstart', (e) => { @@ -140,6 +145,7 @@ export class PanelResizer { this.isDragging = true; this.currentHandle = handle; + this.activeConfig = config; this.startPos = { x: e.clientX, y: e.clientY }; this.currentHandle.classList.add('resizing'); this.startSizes = this.getCurrentSizes(config); @@ -156,35 +162,41 @@ export class PanelResizer { } private onMouseMove(e: MouseEvent): void { - if (!this.isDragging || !this.currentHandle) return; - - e.preventDefault(); + if (!this.isDragging || !this.activeConfig) return; - const target = this.currentHandle.getAttribute('data-target'); - if (!target) return; - - const config = this.panelConfigs[target]; + // No button held means the mouseup was lost (released outside the + // window); end the drag instead of resizing with the button up. + if (e.buttons === 0) { + this.endDrag(); + return; + } - if (!config) return; + e.preventDefault(); - if (config.direction === 'horizontal') { - this.handleHorizontalResize(e, config); + if (this.activeConfig.direction === 'horizontal') { + this.handleHorizontalResize(e, this.activeConfig); } else { - this.handleVerticalResize(e, config); + this.handleVerticalResize(e, this.activeConfig); } } - private onMouseUp(_e: MouseEvent): void { + /** + * Leave drag mode: restore the cursor, text selection and map + * interactivity, then persist the layout. Runs on mouseup, but also as + * the recovery path when the mouseup never arrives (release outside the + * window, focus loss, destroy), so it must be safe to call anytime. + */ + private endDrag(): void { if (!this.isDragging) return; this.isDragging = false; + this.activeConfig = null; if (this.currentHandle) { this.currentHandle.classList.remove('resizing'); this.currentHandle = null; } - // Reset cursor and re-enable text selection document.body.style.cursor = ''; document.body.classList.remove('no-select'); @@ -194,12 +206,8 @@ export class PanelResizer { mapElement.style.pointerEvents = ''; } - // No explicit map resize here: MapLibre's built-in trackResize - // observer follows the container through the whole drag with a - // synchronous redraw per step. A delayed bare resize() would clear - // the canvas without repainting it and flash white. - - // Save panel sizes to localStorage + // No explicit map resize: MapLibre's trackResize observer already + // follows the container through the drag. this.savePanelSizes(); } @@ -284,15 +292,17 @@ export class PanelResizer { // Update flex basis if (config.leftPanel?.classList.contains('console-section')) { - // Special handling for console sections (use percentages) + // Console sections use percentages so they keep their split when + // the window resizes const leftPercent = (newLeftWidth / totalWidth) * 100; const rightPercent = (newRightWidth / totalWidth) * 100; config.leftPanel.style.flexBasis = `${leftPercent}%`; config.rightPanel!.style.flexBasis = `${rightPercent}%`; } else { - // Regular panels (use pixels) - if (config.leftPanel) { + // Regular panels use pixels; the map container keeps flex:1 and + // absorbs the remainder, so it never gets an inline basis + if (config.leftPanel && !config.leftPanel.classList.contains('map-container')) { config.leftPanel.style.flexBasis = `${newLeftWidth}px`; } if (config.rightPanel && !config.rightPanel.classList.contains('map-container')) { @@ -330,12 +340,6 @@ export class PanelResizer { consoleContainer: this.getPanelSize('.console-container'), consoleSection: this.getPanelSize('.console-section'), echoSection: this.getPanelSize('.echo-section'), - trafficPanel: this.getPanelSize('.traffic-panel'), - aircraftPanel: this.getPanelSize('.aircraft-panel'), - conflictsPanel: this.getPanelSize('.conflicts-panel'), - nodePanel: this.getPanelSize('.node-panel'), - navPanel: this.getPanelSize('.nav-panel'), - displayPanel: this.getPanelSize('.display-panel'), timestamp: Date.now(), version: PanelResizer.LAYOUT_VERSION }; @@ -371,17 +375,8 @@ export class PanelResizer { this.setPanelSize('.console-container', layoutState.consoleContainer); this.setPanelSize('.console-section', layoutState.consoleSection); this.setPanelSize('.echo-section', layoutState.echoSection); - this.setPanelSize('.traffic-panel', layoutState.trafficPanel); - this.setPanelSize('.aircraft-panel', layoutState.aircraftPanel); - this.setPanelSize('.conflicts-panel', layoutState.conflictsPanel); - this.setPanelSize('.node-panel', layoutState.nodePanel); - this.setPanelSize('.nav-panel', layoutState.navPanel); - this.setPanelSize('.display-panel', layoutState.displayPanel); logger.debug('PanelResizer', 'Panel layout loaded from localStorage'); - - // No explicit map resize: MapLibre's built-in trackResize - // observer picks up the container change from the applied sizes. } catch (error) { logger.error('PanelResizer', 'Error loading panel layout:', error); } @@ -412,8 +407,9 @@ export class PanelResizer { panel.style.flexBasis = size; } - /** Remove every listener registered in setupEventListeners. */ + /** End any active drag and remove every registered listener. */ public destroy(): void { + this.endDrag(); this.listenerAbort.abort(); } } diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c10e5ac..79126ff 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -263,6 +263,44 @@ def test_second_frame_within_interval_is_not_re_emitted(self, proxy, fake_socket assert proxy.traffic_data["id"] == ["AC1"] +class TestAcdataCachingWithoutClients: + """The traffic cache must track the live feed even with no browser + attached: the initial_data snapshot, the 0.5 s backup timer and the + ROUTEDATA aircraft filter all serve ``proxy.traffic_data`` verbatim, so a + cache frozen while unwatched hands the next client the aircraft from the + moment the last one left — ghosts at stale positions, resurrected deleted + aircraft, and route frames filtered against a stale id list.""" + + def test_frame_is_cached_but_not_emitted_without_clients( + self, proxy, fake_socketio + ): + proxy.connected_clients = 0 + on_acdata_received({"id": ["AC1"], "lat": [1.0]}) + assert proxy.traffic_data["id"] == ["AC1"] + assert fake_socketio.count("acdata") == 0 + + def test_deletions_while_unwatched_reach_the_cache(self, proxy, fake_socketio): + # A client watches two aircraft, then leaves... + on_acdata_received({"id": ["AC1", "AC2"]}) + assert proxy.traffic_data["id"] == ["AC1", "AC2"] + + # ...AC2 is deleted while nobody is connected: the next frame must + # replace the cached pair, not be dropped at the client gate. + proxy.connected_clients = 0 + proxy.last_acdata_emit = 0 # throttle interval elapsed + on_acdata_received({"id": ["AC1"]}) + assert proxy.traffic_data["id"] == ["AC1"] + assert fake_socketio.count("acdata") == 1 # only the first frame + + def test_cache_refresh_is_still_throttled(self, proxy, fake_socketio): + proxy.connected_clients = 0 + on_acdata_received({"id": ["AC1"]}) + cached = proxy.traffic_data + # A second frame within acdata_interval is skipped wholesale. + on_acdata_received({"id": ["AC2"]}) + assert proxy.traffic_data is cached + + class TestStatechangeHandler: def test_emits_statechange(self, proxy, fake_socketio): proxy.sim_data = {"state": 0} @@ -437,35 +475,49 @@ def test_ignored_when_reconnection_disallowed(self, proxy, fake_socketio): class TestStackReceivedHandler: - def test_local_help_command_executed(self, proxy, fake_socketio): - on_stack_received("HELP") - # Bare HELP is answered locally -> info echo emitted. - assert fake_socketio.count("echo") == 1 - echo_payload = fake_socketio.last("echo") - assert echo_payload["flags"] == 0 - assert "bluesky web client" in echo_payload["text"].lower() - - def test_gui_only_command_reports_warning_not_error(self, proxy, fake_socketio): - # A scenario-file GUI command (e.g. PAN) forwarded by the server is a - # benign no-op for the web client: warning (flags=2), never an error. + def test_forwarded_command_emitted_to_browsers(self, proxy, fake_socketio): + # A scenario-file GUI command (e.g. PAN) forwarded by the simulation + # goes to browsers as stack_command; the frontend CommandHandler + # executes it (or reports it unsupported) exactly like typed input. on_stack_received("PAN EHAM") - echo_payload = fake_socketio.last("echo") - assert echo_payload["text"] == "PAN: not supported by the web client (ignored)" - assert echo_payload["flags"] == 2 + assert fake_socketio.count("stack_command") == 1 + payload = fake_socketio.last("stack_command") + assert payload["command"] == "PAN EHAM" + assert "timestamp" in payload + + def test_semicolon_joined_payload_is_split(self, proxy, fake_socketio): + # BlueSky's stack.forward() joins multiple commands with ';' and its + # reference client splits them back apart; mirror that. + on_stack_received("PAN EHAM; ZOOM 2") + assert fake_socketio.count("stack_command") == 2 + first, second = fake_socketio.events("stack_command") + assert first["command"] == "PAN EHAM" + assert second["command"] == "ZOOM 2" def test_list_of_commands(self, proxy, fake_socketio): - on_stack_received(["HELP", "?"]) - assert fake_socketio.count("echo") == 2 + on_stack_received(["PAN EHAM", "ZOOM 0.4"]) + assert fake_socketio.count("stack_command") == 2 def test_empty_command_skipped(self, proxy, fake_socketio): - on_stack_received(["", " "]) - assert fake_socketio.count("echo") == 0 + on_stack_received(["", " ", " ; "]) + assert fake_socketio.count("stack_command") == 0 + + def test_non_string_entries_skipped(self, proxy, fake_socketio): + on_stack_received(["PAN EHAM", 42, None]) + assert fake_socketio.count("stack_command") == 1 + + def test_dropped_without_connected_browsers(self, proxy, fake_socketio): + # No browser connected -> nothing can execute the command; no emit, + # no error. + proxy.connected_clients = 0 + on_stack_received("PAN EHAM") + assert fake_socketio.count("stack_command") == 0 def test_ignored_when_reconnection_disallowed(self, proxy, fake_socketio): proxy.allow_reconnection = False proxy.last_successful_update = 42.0 on_stack_received("PAN EHAM") - assert fake_socketio.count("echo") == 0 + assert fake_socketio.count("stack_command") == 0 assert proxy.last_successful_update == 42.0 # liveness untouched @@ -489,10 +541,10 @@ def test_clears_cached_aircraft_and_emits_empty_acdata(self, proxy, fake_socketi def test_clears_cached_aircraft_with_no_clients_connected( self, proxy, fake_socketio ): - # The initial_data snapshot serves proxy.traffic_data verbatim, and - # on_acdata_received only refreshes it while clients are attached — so - # a reset with nobody connected must still clear the cache, or the - # next page load renders the pre-reset aircraft (ghosts). + # The initial_data snapshot serves proxy.traffic_data verbatim, and a + # paused/idle sim may send no further ACDATA to overwrite it — so a + # reset with nobody connected must still clear the cache, or the next + # page load renders the pre-reset aircraft (ghosts). from WebATM.utils import empty_traffic_data proxy.connected_clients = 0 @@ -1013,3 +1065,50 @@ def test_update_action_merges_new_shapes(self, proxy, fake_client): polys = proxy.poly_data_by_node[sender_hex]["polys"] assert set(polys) == {"area1", "area2"} + + def test_redefining_line_as_poly_moves_it_to_the_polygon_store( + self, proxy, fake_client + ): + # BlueSky's defineArea overwrites an existing name with a shape of any + # kind and publishes it as a plain Update. Regression: the full POLY + # definition was routed into the polyline store (the partial-update + # heuristic only checked the name), so the area rendered as a line. + sender_hex = self._activate(proxy, fake_client) + fake_client.context.action = b"U" + on_poly_received({"polys": {"z1": dict(self.LINE)}}) + + on_poly_received({"polys": {"z1": dict(self.AREA)}}) + + assert "z1" in proxy.poly_data_by_node[sender_hex]["polys"] + assert "z1" not in proxy.polyline_data_by_node[sender_hex]["polys"] + assert proxy.poly_data_by_node[sender_hex]["polys"]["z1"]["shape"] == "POLY" + + def test_redefining_poly_as_line_evicts_the_stale_polygon(self, proxy, fake_client): + # Regression: the new polyline was stored, but the old polygon stayed + # in the polygon store, so both kept being emitted and the map + # flickered between the stale area and the new line. + sender_hex = self._activate(proxy, fake_client) + fake_client.context.action = b"U" + on_poly_received({"polys": {"z2": dict(self.AREA)}}) + + on_poly_received({"polys": {"z2": dict(self.LINE)}}) + + assert "z2" not in proxy.poly_data_by_node[sender_hex]["polys"] + assert "z2" in proxy.polyline_data_by_node[sender_hex]["polys"] + + def test_full_redefinition_replaces_stale_fields(self, proxy, fake_client): + # defineArea creates a fresh server-side Shape, so fields of the old + # one (a previous COLOR, POLYALT bounds) no longer exist there; a full + # definition must replace the stored entry, not merge into it. + sender_hex = self._activate(proxy, fake_client) + fake_client.context.action = b"U" + volume = dict(self.AREA, shape="POLYALT", top=3000.0, bottom=500.0) + on_poly_received({"polys": {"z3": volume}}) + on_poly_received({"polys": {"z3": {"color": [255, 0, 0]}}}) + + on_poly_received({"polys": {"z3": dict(self.AREA)}}) + + stored = proxy.poly_data_by_node[sender_hex]["polys"]["z3"] + assert "color" not in stored + assert "top" not in stored + assert stored["lat"] == [1.0, 3.0, 5.0] diff --git a/tests/test_perf.py b/tests/test_perf.py new file mode 100644 index 0000000..21537fd --- /dev/null +++ b/tests/test_perf.py @@ -0,0 +1,146 @@ +"""Tests for :mod:`WebATM.perf` (opt-in ACDATA data-path instrumentation). + +Pin the two properties the summary numbers depend on: + +* the measurement window opens with the first frame after an idle gap, so + idle time (boot, disconnect/reconnect) never dilutes the CPU shares, and +* the serialize average divides by the number of serializations, not emits, + so an emit failure cannot skew it (or be miscounted as throttled). +""" + +import logging +import time + +import pytest + +from WebATM.proxy.perf import DataPathPerf + + +@pytest.fixture +def perf(monkeypatch): + """An enabled DataPathPerf with the default 5 s summary interval.""" + monkeypatch.setenv("WEBATM_PERF", "1") + monkeypatch.delenv("WEBATM_PERF_INTERVAL", raising=False) + return DataPathPerf() + + +def summary_lines(caplog): + return [r.getMessage() for r in caplog.records if r.name == "WebATM.perf"] + + +class TestConfiguration: + def test_disabled_by_default_and_records_nothing(self, monkeypatch): + monkeypatch.delenv("WEBATM_PERF", raising=False) + perf = DataPathPerf() + assert not perf.enabled + + perf.record_received() + perf.record_filtered() + perf.record_serialize(0.1) + perf.record_emit(0.1) + assert (perf.received, perf.filtered, perf.serializes, perf.emits) == ( + 0, + 0, + 0, + 0, + ) + + def test_invalid_interval_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("WEBATM_PERF", "1") + monkeypatch.setenv("WEBATM_PERF_INTERVAL", "not-a-number") + assert DataPathPerf().interval == 5.0 + + def test_interval_has_a_one_second_floor(self, monkeypatch): + monkeypatch.setenv("WEBATM_PERF", "1") + monkeypatch.setenv("WEBATM_PERF_INTERVAL", "0.01") + assert DataPathPerf().interval == 1.0 + + +class TestWindowStart: + """The window must open with the flow's first frame, not at import time.""" + + def test_very_first_frame_opens_the_window(self, perf): + # e.g. the proxy connects two minutes after the module was imported. + perf._window_start = time.time() - 120 + perf.record_received() + assert time.time() - perf._window_start < 1.0 + + def test_frame_after_idle_gap_restarts_a_partial_window(self, perf): + # 10 frames flowed, then the link dropped for a minute mid-window. + for _ in range(10): + perf.record_received() + perf._last_frame_wall = time.time() - 60 + perf._window_start = time.time() - 65 + perf._last_emit_wall = time.time() - 60 + + perf.record_received() + + assert time.time() - perf._window_start < 1.0 + assert perf.received == 1 # pre-gap partial counters discarded + # The pre-idle emit timestamp must not read as a 1-minute "stall". + assert perf._last_emit_wall is None + + def test_continuous_flow_keeps_the_window_anchor(self, perf): + anchor = time.time() - 1.0 # last summary 1 s ago, data still flowing + perf._window_start = anchor + perf._last_frame_wall = time.time() - 0.2 # 5 Hz cadence + perf.record_received() + assert perf._window_start == anchor + assert perf.received == 1 + + +class TestSummary: + def _force_window_closed(self, perf, elapsed=10.0): + perf._window_start = time.time() - elapsed + + def test_no_summary_before_interval_elapsed(self, perf, caplog): + with caplog.at_level(logging.INFO, logger="WebATM.perf"): + perf.record_received() + perf.maybe_log() + assert summary_lines(caplog) == [] + + def test_serialize_average_uses_serialize_count_not_emits(self, perf, caplog): + # 4 frames: 1 filtered, 2 serialized (one emit then failed), 1 throttled. + perf.record_received() + perf.record_filtered() + perf.record_received() + perf.record_serialize(0.010) + perf.record_emit(0.002) + perf.record_received() + perf.record_serialize(0.030) # its socketio.emit raised + perf.record_received() + + self._force_window_closed(perf) + with caplog.at_level(logging.INFO, logger="WebATM.perf"): + perf.maybe_log() + + (line,) = summary_lines(caplog) + # avg = (10 ms + 30 ms) / 2 serializations, NOT / 1 emit (= 40 ms). + assert "serialize avg=20.00ms max=30.00ms" in line + # The failed emit is not miscounted as throttled: 4 - 1 - 2 = 1. + assert "recv=4 filtered=1 emit=1 throttled=1" in line + + def test_counters_and_window_reset_after_summary(self, perf, caplog): + perf.record_received() + perf.record_serialize(0.001) + perf.record_emit(0.001) + self._force_window_closed(perf) + with caplog.at_level(logging.INFO, logger="WebATM.perf"): + perf.maybe_log() + + assert (perf.received, perf.serializes, perf.emits) == (0, 0, 0) + assert perf.serialize_s == 0.0 and perf.emit_s == 0.0 + assert time.time() - perf._window_start < 1.0 + + def test_empty_window_logs_nothing_but_still_resets(self, perf, caplog): + self._force_window_closed(perf) + with caplog.at_level(logging.INFO, logger="WebATM.perf"): + perf.maybe_log() + assert summary_lines(caplog) == [] + assert time.time() - perf._window_start < 1.0 + + def test_max_emit_gap_tracked_within_flowing_data(self, perf): + perf.record_emit(0.001) + perf._last_emit_wall = time.time() - 0.5 # 500 ms since previous emit + perf.record_emit(0.001) + assert perf._max_emit_gap == pytest.approx(0.5, abs=0.05) diff --git a/tests/test_socket_handlers.py b/tests/test_socket_handlers.py index 0a8a144..11920e2 100644 --- a/tests/test_socket_handlers.py +++ b/tests/test_socket_handlers.py @@ -105,6 +105,45 @@ def test_command_returns_result(self, sio): assert results assert results[0]["args"][0]["command"] == "CRE KL204" + def test_failed_command_echoes_error_to_sender(self, sio): + """A command that cannot be sent (no BlueSky client running) must + produce user-visible feedback: an error echo to the sender. Nothing + consumes the bare ``command_result``, and no BlueSky response will + ever follow, so without the echo the command vanishes silently.""" + app, socketio, client = sio + client.get_received() + client.emit("command", {"command": "CRE KL204"}) + received = {p["name"]: p["args"][0] for p in client.get_received()} + assert received["command_result"]["success"] is False + echo = received["echo"] + assert echo["flags"] == 1 # error + assert "CRE KL204" in echo["text"] + assert echo["sender"] is None # WebATM-generated, not from a node + + def test_failed_command_echo_is_not_broadcast(self, sio): + """The failure concerns only the client that sent the command; other + browsers must not see an error echo for it.""" + app, socketio, client = sio + client.get_received() + second = socketio.test_client(app) + try: + second.get_received() # drain connect envelopes + client.emit("command", {"command": "CRE KL204"}) + assert "echo" not in {p["name"] for p in second.get_received()} + finally: + second.disconnect() + + def test_successful_command_does_not_echo(self, sio): + """A command that was sent fine gets its feedback from BlueSky's own + echo response; WebATM must not add one of its own.""" + app, socketio, client = sio + client.get_received() + app.bluesky_proxy.send_command = lambda command: True + client.emit("command", {"command": "CRE KL204"}) + received = {p["name"]: p["args"][0] for p in client.get_received()} + assert received["command_result"]["success"] is True + assert "echo" not in received + class TestNodeEvents: def test_get_nodes_does_not_error(self, sio):