From 8c483455280d050fa0f2da46effe603db7ae0aee Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 14:31:24 -0400 Subject: [PATCH 1/3] fix(dcs): one relay endpoint label, record only while relaying, waiting wording matches the mode (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #134 (chunk 5): X9, X12, N2 and N3. All four live in `dcs/relay-rules.ts` and are pushed on `relay:facts`. The renderer only reads them. ## X9: one endpoint label - **Today:** the Relay pill and the Overview DCS Link tile each build their own endpoint, and the two disagree. TCP showed `host:port` on the pill and `host:port · remote` on the tile. Unicast showed `unicast listen` on the pill and `239.255.50.10:5010 · local` (the multicast group) on the tile. Both used the host and port as typed in the renderer's form. - **After:** there is one `relayEndpoint()` in `relay-rules.ts`, built from the settings main holds and pushed as `RelayFacts.endpoint` (a string). The pill tooltip, the DCS Link tile and the event log's relay rows all show it. The label per transport: - TCP: `host:commandPort` - Multicast: `239.255.50.10:5010` - Unicast: `0.0.0.0:listenPort` (the transport binds `listenPort` on every address) `relay-events.ts` used to build its own copy of this label; it now calls `relayEndpoint()`. `EndpointLabel`, `pillEndpoint`, `overviewEndpoint` and the renderer's `endpointText` were removed. The title bar no longer passes host and port to the pill. ## X12: Record only while relaying - **Today:** Settings › Connection's Record is enabled even while the relay is stopped. The Connection tab's Record card is enabled only while relaying. - **After:** both read the same `RelaySurfaces.canRecord` boolean, from `canRecord(relaying, recording)`. A recording can start only while relaying. A recording that is already running can always be stopped, so stopping the relay mid-recording never leaves a recording you can't stop. Main also enforces the rule: `DcsRelay.mayRecord()` is checked first in `capture:toggle`, so a refused Start never opens the save dialog. The save dialog still opens when a recording starts. ## N2: no device-waiting wording in Monitor - **Today:** the Overview Link tile says "relay waiting — resumes when it connects" whenever a relay runs with the link down, Monitor included. - **After:** "waiting" follows the same rule as the pill and the note under Start: it shows only while the relay waits for the board. Monitor and an undriven Replay show a new `running` value, worded "stream running". ## N3: a driven Replay shows the waiting reason - **Today:** "waiting for the Gateway" shows in Bridge only. - **After:** `waitingForDevice` uses `exportToDevice` (Bridge, or Replay with Drive Gateway on). So a driven Replay that loses the board now shows the waiting reason: the pill tooltip says it waits for the Gateway and turns gold, the note under Start says the same, and the Overview says "relay waiting". `startBlocked` uses the same `exportToDevice` rule. ## Tests changed on purpose - `relay-rules.test.ts`: the X9, X12, N2 and N3 blocks that pinned the old behaviour were rewritten: the per-surface endpoints, the per-surface `canRecord`, "waiting in every mode", and "a driven Replay is never waiting". The facts test now expects a driven Replay running with no board to show `waiting-device`. - `pushed-rules.seam.test.ts`: the `config:edit` facts test now expects the Unicast endpoint `0.0.0.0:5010`. New seam tests cover X9 (all three transports from the settings main holds), X12 (refused while stopped with no dialog, allowed while relaying, stoppable after Stop), N2 (Monitor with no Gateway is `running`) and N3 (a driven Replay that loses the board waits for it). - `link-view.test.ts`: the relay inputs carry `replayDriveSerial`, and `relayWaiting` gains a driven-Replay case. - `t27`: the DCS Link tile expectations are now the shared label, without the ` · remote` / ` · local` suffix, and Unicast shows its listen port. The old "reconnecting while relaying" Link case is now Bridge, since Monitor no longer waits. New cases: Monitor with no board reads "stream running"; a driven Replay reads "relay waiting"; the pill and the DCS Link tile show the same endpoint in `` for TCP, Multicast and Unicast. - `t31`: Settings › Connection's Record is disabled while stopped and enabled once relaying. This was contract B62; the comment noting the old behaviour was removed. - `t15` / `t28`: new tests. A driven Replay with no board shows the waiting reason on the pill and in the note under Start. An undriven Replay just plays. Monitor with no board has no waiting wording. With the production files reverted to the base branch, 25 of these tests fail. Both the main and renderer tests catch every item. ## How verified `npm run lint`, `npm run lint:deps`, `npm run typecheck` and `npm test` all pass (1414 tests). The API guard header records the type change: no channel, intent or method was renamed. Still to do: the on-device checklist in #134. --- src/main/app/pushed-rules.seam.test.ts | 72 +++++++++- src/main/dcs/dcs-relay.ts | 9 ++ src/main/dcs/ipc.ts | 4 +- src/main/dcs/relay-events.ts | 18 +-- src/main/dcs/relay-rules.test.ts | 119 +++++++++++----- src/main/dcs/relay-rules.ts | 127 ++++++++++-------- src/renderer/src/app/overview/Overview.tsx | 8 +- .../src/app/settings/ConnectionSection.tsx | 6 +- src/renderer/src/app/titlebar/RelayPill.tsx | 6 +- src/renderer/src/dcs/RelayControls.tsx | 8 +- src/renderer/src/dcs/RelayPill.tsx | 24 +--- src/renderer/src/dcs/index.ts | 2 +- src/renderer/src/dcs/relay-view.ts | 14 +- src/renderer/src/gateway/link-view.test.ts | 10 +- src/renderer/test/t15-relay-pill.test.tsx | 34 +++++ .../test/t27-overview-hero-gauges.test.tsx | 59 +++++++- .../t28-connection-relay-controls.test.tsx | 21 +++ ...1-settings-connection-diagnostics.test.tsx | 5 +- src/shared/app/ipc.compat.test-d.ts | 7 + src/shared/dcs/ipc.ts | 37 +++-- 20 files changed, 416 insertions(+), 174 deletions(-) diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index bd642da..455931f 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -5,6 +5,7 @@ // switch (config:edit). import { describe, expect, it } from 'vitest' import { CTRL } from '@shared/app' +import { serializeCapture } from '../dcs/capture-file' import { makeServices } from '../test-harness/services' const monitor = { paths: { files: { 'config.json': { sourceMode: 'monitor' } } } } @@ -81,10 +82,7 @@ describe('relay:facts: what the relay’s surfaces show', () => { it('a form’s edit: built over the settings held, the port as typed; the facts follow', () => { const app = makeServices(monitor) app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) - expect(app.lastPush('relay:facts')?.endpoint).toEqual({ - pill: { text: 'unicast listen' }, - overview: { text: '239.255.50.10:5010 · local' } - }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('0.0.0.0:5010') app.invoke(CTRL.configEdit, { port: '77a' }) expect(app.files.json('config.json')).toMatchObject({ transport: 'unicast-listen', @@ -94,6 +92,72 @@ describe('relay:facts: what the relay’s surfaces show', () => { }) }) +describe('fix track #134: the relay endpoint, Record and the waiting wording', () => { + it('X9: one endpoint, from the settings held, right for each transport', () => { + const app = makeServices(monitor) + expect(app.lastPush('relay:facts')?.endpoint).toBe('127.0.0.1:7778') + app.invoke(CTRL.configEdit, { host: '10.0.0.7', port: '7779' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('10.0.0.7:7779') + app.invoke(CTRL.configEdit, { transport: 'loopback-multicast' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('239.255.50.10:5010') + app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) + expect(app.lastPush('relay:facts')?.endpoint).toBe('0.0.0.0:5010') + }) + + it('X12: Record refused while stopped, before any dialog; allowed while relaying', async () => { + const app = makeServices(monitor) + expect(app.lastPush('relay:facts')).toMatchObject({ + running: { canRecord: true }, + stopped: { canRecord: false } + }) + app.dialogs.saveCapture('session.skyhawk-capture') + expect(await app.invoke(CTRL.captureToggle)).toEqual({ recording: false }) + expect(app.lastPush('relay:facts')?.recording).toEqual({ on: false }) + app.invoke(CTRL.relayStart) + expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: true }) + // Stopped mid-recording: the recording can still be stopped. + app.invoke(CTRL.relayStop) + expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(true) + expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: false }) + expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(false) + }) + + it('N2: Monitor relaying with no Gateway is running, not waiting for it', () => { + const app = makeServices(monitor) + app.invoke(CTRL.relayStart) + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + overviewLink: 'running', + tip: 'waiting-dcs', + waiting: 'dcs' + }) + }) + + it('N3: a driven Replay that loses the Gateway waits for it, as Bridge does', async () => { + const app = makeServices({ + paths: { + files: { + 'config.json': { sourceMode: 'replay', replayDriveSerial: true }, + 'flight.skyhawk-capture': serializeCapture([ + { t: 0, dir: 'in', hex: '55555555' }, + { t: 1500, dir: 'in', hex: '55555555' } + ]) + } + } + }) + app.dialogs.openCapture('flight.skyhawk-capture') + await app.invoke(CTRL.replayOpen) + app.serial.open() + expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true }) + expect(app.lastPush('relay:facts')?.running.tip).toBe('replay-playing') + app.serial.close() + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + waiting: 'device', + tip: 'waiting-device', + overviewLink: 'waiting' + }) + }) +}) + describe('R3.4a: the last renderer decisions, pushed by their owners', () => { it('device:status: the link’s action, and no Refresh fact (#132: the button is gone)', () => { const app = makeServices(monitor) diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index cd4e312..d6a6b18 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -25,6 +25,7 @@ import { DcsBiosProtocol } from './bios-protocol' import type { DcsConnection } from './dcs-connection' import { relayContext, relayEvents } from './relay-events' import { + canRecord, captureView, deviceToDcs, exportToDevice, @@ -254,6 +255,14 @@ export class DcsRelay { }) } + /** + * May the Record control act now (X12, relay-rules canRecord): start a recording only while + * relaying; stop one already on whenever. The rule the surfaces' `canRecord` shows. + */ + mayRecord(): boolean { + return canRecord(this.running, this.recorder.isRecording()) + } + /** The link or the settings changed (AppServices): push the facts if they changed with them. */ inputsChanged(): void { this.pushFacts() diff --git a/src/main/dcs/ipc.ts b/src/main/dcs/ipc.ts index e272559..7b55318 100644 --- a/src/main/dcs/ipc.ts +++ b/src/main/dcs/ipc.ts @@ -32,8 +32,10 @@ export function registerDcsIpc(ipc: IpcRegistrar, deps: DcsIpcDeps): void { return { path: filePath } }) - // The Record controls and the Capture card show the answer (relay:facts, R3.3). + // The Record controls and the Capture card show the answer (relay:facts, R3.3). A recording + // starts only while relaying (X12): refused before the save dialog, nothing changes. ipc.handle(DCS_CTRL.captureToggle, async (): Promise => { + if (!relay.mayRecord()) return { recording: false } const state = await toggleCapture() relay.showCapture(state) return state diff --git a/src/main/dcs/relay-events.ts b/src/main/dcs/relay-events.ts index fb592d0..2b1397f 100644 --- a/src/main/dcs/relay-events.ts +++ b/src/main/dcs/relay-events.ts @@ -1,12 +1,13 @@ // Relay events (pure, no Node/Electron deps): the event-log rows the relay and the DCS-BIOS stream // make as they start, stop, come up and drop. import type { AppConfig, SourceMode } from '@shared/config' -import { EXPORT_MULTICAST_ADDR, EXPORT_PORT, type RelayState } from '@shared/dcs' +import type { RelayState } from '@shared/dcs' import type { LogEventInput } from '@shared/events' +import { relayEndpoint } from './relay-rules' export interface RelayContext { mode: SourceMode - /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778`. */ + /** Where the DCS-BIOS stream comes from, e.g. `127.0.0.1:7778` (relay-rules relayEndpoint). */ endpoint: string /** TCP connects; UDP only binds, so "connected" there is "listening". */ tcp: boolean @@ -16,13 +17,12 @@ export interface RelayContext { export function relayContext( cfg: Pick ): RelayContext { - const tcp = cfg.transport === 'tcp-to-host' - const endpoint = tcp - ? `${cfg.host}:${cfg.commandPort}` - : cfg.transport === 'loopback-multicast' - ? `${EXPORT_MULTICAST_ADDR}:${EXPORT_PORT}` - : `:${cfg.listenPort}` - return { mode: cfg.sourceMode, endpoint, tcp } + // The one endpoint label every surface shows (X9). + return { + mode: cfg.sourceMode, + endpoint: relayEndpoint(cfg), + tcp: cfg.transport === 'tcp-to-host' + } } const MODE_LABEL: Record = { diff --git a/src/main/dcs/relay-rules.test.ts b/src/main/dcs/relay-rules.test.ts index 967f0ba..b3412fd 100644 --- a/src/main/dcs/relay-rules.test.ts +++ b/src/main/dcs/relay-rules.test.ts @@ -7,14 +7,14 @@ import { RELAY_FACTS_IDLE } from '@shared/dcs' import { canRecord, captureView, - overviewEndpoint, overviewLink, captureShown, - pillEndpoint, recordShown, + relayEndpoint, relayFacts, relayNote, relayTip, + relayWaiting, replayView } from './relay-rules' @@ -41,7 +41,9 @@ describe('relayFacts', () => { config: { ...DEFAULT_CONFIG, sourceMode: 'replay', replayDriveSerial: true } }) expect(replay.stopped.startBlocked).toBe('replay') - expect(replay.running.tip).toBe('replay-playing') + expect(replay.running.tip, 'a driven Replay waits for the board (N3)').toBe('waiting-device') + const undriven = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'replay' } }) + expect(undriven.running.tip).toBe('replay-playing') }) it('carries the mode and the transport it was judged against (R3.4c)', () => { @@ -49,50 +51,105 @@ describe('relayFacts', () => { config: { ...DEFAULT_CONFIG, sourceMode: 'replay', transport: 'unicast-listen' } }) expect(f).toMatchObject({ mode: 'replay', transport: 'unicast-listen', captureShown: true }) - expect(f.endpoint.pill).toEqual(pillEndpoint('unicast-listen')) + expect(f.endpoint).toBe(relayEndpoint({ ...DEFAULT_CONFIG, transport: 'unicast-listen' })) }) }) -describe('N3: waiting for the Gateway is Bridge only; a driven Replay is blocked, never waiting', () => { - it.each(['monitor', 'replay'] as const)('%s, no board, running', (mode) => { - const o = { - mode, - relaying: true, - stream: 'connected' as const, - deviceState: 'no-device' as const - } +describe('N3: waiting for the Gateway — Bridge and a driven Replay', () => { + const run = (mode: 'bridge' | 'monitor' | 'replay', replayDriveSerial: boolean) => ({ + mode, + replayDriveSerial, + relaying: true, + stream: 'connected' as const, + deviceState: 'no-device' as const + }) + + it.each([ + ['bridge', false], + ['replay', true] + ] as const)('%s (drive %s), no board, running: the waiting reason', (mode, drive) => { + const o = run(mode, drive) + expect(relayNote(o)).toBe('device') + expect(relayTip(o)).toBe('waiting-device') + expect(relayWaiting(o)).toBe(true) + }) + + it.each([ + ['monitor', false], + ['monitor', true], + ['replay', false] + ] as const)('%s (drive %s), no board, running: never waiting for it', (mode, drive) => { + const o = run(mode, drive) expect(relayNote(o)).toBe('none') expect(relayTip(o)).not.toBe('waiting-device') + expect(relayWaiting(o)).toBe(false) + }) + + it('a driven Replay with the board up plays on', () => { + expect(relayTip({ ...run('replay', true), deviceState: 'connected' })).toBe('replay-playing') }) }) -describe('N2: the Overview says "relay waiting" whenever a relay runs with the link down', () => { - it('in every mode, Monitor included', () => { - expect(overviewLink(true, false)).toBe('waiting') - expect(overviewLink(true, true)).toBe('gateway-relaying') - expect(overviewLink(false, true)).toBe('gateway-stopped') - expect(overviewLink(false, false)).toBe('stopped') +describe('N2: the Overview says "relay waiting" only while the relay waits for the board', () => { + const o = ( + mode: 'bridge' | 'monitor' | 'replay', + relaying: boolean, + replayDriveSerial = false + ) => ({ + mode, + replayDriveSerial, + relaying, + stream: 'connected' as const, + deviceState: 'no-device' as const + }) + + it('Bridge and a driven Replay wait; Monitor and an undriven Replay just run', () => { + expect(overviewLink(o('bridge', true), false)).toBe('waiting') + expect(overviewLink(o('replay', true, true), false)).toBe('waiting') + expect(overviewLink(o('monitor', true), false)).toBe('running') + expect(overviewLink(o('replay', true), false)).toBe('running') + }) + + it('stopped, or with the link up, the same in every mode', () => { + for (const mode of ['bridge', 'monitor', 'replay'] as const) { + expect(overviewLink(o(mode, false), false), mode).toBe('stopped') + expect(overviewLink(o(mode, false), true), mode).toBe('gateway-stopped') + expect(overviewLink(o(mode, true), true), mode).toBe('gateway-relaying') + } + }) + + it('pushed in the facts: Monitor running with no board is not waiting', () => { + expect(facts().running.overviewLink).toBe('running') + const bridge = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'bridge' } }) + expect(bridge.running.overviewLink).toBe('waiting') }) }) -describe('X9: each surface’s own endpoint label', () => { - it('the pill: host and port as typed, the multicast group, or "unicast listen"', () => { - expect(pillEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: '' } }) - expect(pillEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010' }) - expect(pillEndpoint('unicast-listen')).toEqual({ text: 'unicast listen' }) +describe('X9: one endpoint label, right for each transport', () => { + const at = (transport: (typeof DEFAULT_CONFIG)['transport']) => + relayEndpoint({ transport, host: '10.0.0.7', commandPort: 7779, listenPort: 5011 }) + + it('TCP: the host and command port; Multicast: the group; Unicast: the port it listens on', () => { + expect(at('tcp-to-host')).toBe('10.0.0.7:7779') + expect(at('loopback-multicast')).toBe('239.255.50.10:5010') + expect(at('unicast-listen')).toBe('0.0.0.0:5011') }) - it('the Overview: host and port as typed, else the multicast group — for Unicast too', () => { - expect(overviewEndpoint('tcp-to-host')).toEqual({ hostPort: { suffix: ' · remote' } }) - expect(overviewEndpoint('loopback-multicast')).toEqual({ text: '239.255.50.10:5010 · local' }) - expect(overviewEndpoint('unicast-listen')).toEqual({ text: '239.255.50.10:5010 · local' }) + it('pushed once in the facts, from the settings held', () => { + const f = facts({ config: { ...DEFAULT_CONFIG, host: '10.0.0.7', commandPort: 7779 } }) + expect(f.endpoint).toBe('10.0.0.7:7779') }) }) -describe('X12: each surface’s own Record rule', () => { - it('the Connection card only while relaying; Settings › Connection whenever', () => { - expect(canRecord(true)).toEqual({ connection: true, settings: true }) - expect(canRecord(false)).toEqual({ connection: false, settings: true }) +describe('X12: Record only while relaying, on every surface', () => { + it('a recording starts only while relaying; one already on can always be stopped', () => { + expect(canRecord(true, false)).toBe(true) + expect(canRecord(false, false)).toBe(false) + expect(canRecord(false, true)).toBe(true) + const f = facts() + expect([f.running.canRecord, f.stopped.canRecord]).toEqual([true, false]) + const on = facts({ recording: { on: true } }) + expect(on.stopped.canRecord, 'a relay stopped mid-recording can still stop it').toBe(true) }) }) diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts index beddc1c..b3fa31c 100644 --- a/src/main/dcs/relay-rules.ts +++ b/src/main/dcs/relay-rules.ts @@ -1,16 +1,17 @@ // Relay rules (pure, no Node/Electron deps): which way the stream goes in each source mode, which // settings a running relay is built from, and what the relay's surfaces show (R3.3). -import type { AppConfig, DcsTransport, SourceMode } from '@shared/config' -import type { - RecordState, - EndpointLabel, - OverviewLink, - RelayFacts, - RelaySurfaces, - RelayTip, - RelayWaiting, - ReplayLoad, - StreamState +import type { AppConfig, SourceMode } from '@shared/config' +import { + EXPORT_MULTICAST_ADDR, + EXPORT_PORT, + type RecordState, + type OverviewLink, + type RelayFacts, + type RelaySurfaces, + type RelayTip, + type RelayWaiting, + type ReplayLoad, + type StreamState } from '@shared/dcs' import type { DeviceState } from '@shared/gateway' @@ -57,14 +58,21 @@ export function relayConfigChanged(prev: T, next: T): bo interface RelayOpts { mode: SourceMode + /** Replay's "Drive Gateway": a Replay that delivers to the board (N3). */ + replayDriveSerial: boolean relaying: boolean stream?: StreamState deviceState: DeviceState } -/** Running, but Bridge has no device to deliver to. */ +/** + * Running, but a mode that delivers to the board has no board to deliver to: Bridge, and a Replay + * driving the Gateway (N3). Monitor and an undriven Replay never wait for it (N2). + */ const waitingForDevice = (o: RelayOpts): boolean => - o.relaying && o.mode === 'bridge' && o.deviceState !== 'connected' + o.relaying && + exportToDevice({ sourceMode: o.mode, replayDriveSerial: o.replayDriveSerial }) && + o.deviceState !== 'connected' /** Running, but the DCS-BIOS stream is not up. Replay has no DCS to wait for. */ const waitingForDcs = (o: RelayOpts): boolean => @@ -77,6 +85,7 @@ const waitingForDcs = (o: RelayOpts): boolean => export function relayTip(opts: RelayOpts): RelayTip { if (opts.mode === 'replay') { if (!opts.relaying) return 'replay-stopped' + if (waitingForDevice(opts)) return 'waiting-device' if (opts.stream === 'ended') return 'replay-ended' return 'replay-playing' } @@ -104,14 +113,17 @@ export function startBlocked(opts: { replayDriveSerial: boolean deviceState: DeviceState }): boolean { - const needsBoard = opts.mode === 'bridge' || (opts.mode === 'replay' && opts.replayDriveSerial) + const needsBoard = exportToDevice({ + sourceMode: opts.mode, + replayDriveSerial: opts.replayDriveSerial + }) return needsBoard && opts.deviceState !== 'connected' } /** * A running relay that is not delivering end to end — shown degraded rather than green. It keeps * running and resumes on its own; this only stops the pill claiming data is flowing when it is not. - * Bridge also needs the device; Monitor and Replay never use it. + * Bridge and a driven Replay also need the device; Monitor and an undriven Replay never use it. */ export function relayWaiting(opts: RelayOpts): boolean { if (!opts.relaying) return false @@ -123,51 +135,46 @@ export function relayWaiting(opts: RelayOpts): boolean { } /** - * The Overview Link tile's sub-line. "Waiting" whenever a relay runs with the link down, whatever - * the mode — Monitor included, which never uses the link (N2). - */ -export function overviewLink(relaying: boolean, linkUp: boolean): OverviewLink { - return linkUp - ? relaying - ? 'gateway-relaying' - : 'gateway-stopped' - : relaying - ? 'waiting' - : 'stopped' -} - -/** - * The Relay pill's endpoint (X9, its own variant): the host and port as typed for TCP, the - * multicast group for loopback, `unicast listen` for unicast. + * The Overview Link tile's sub-line. "Waiting" only while a running relay waits for the board — the + * same rule as the pill and the note under Start, so Monitor and an undriven Replay, which never + * use the link, never say it (N2). Otherwise the relay is simply running. */ -export function pillEndpoint(transport: DcsTransport): EndpointLabel { - return transport === 'tcp-to-host' - ? { hostPort: { suffix: '' } } - : transport === 'loopback-multicast' - ? { text: '239.255.50.10:5010' } - : { text: 'unicast listen' } +export function overviewLink(opts: RelayOpts, linkUp: boolean): OverviewLink { + if (linkUp) return opts.relaying ? 'gateway-relaying' : 'gateway-stopped' + if (waitingForDevice(opts)) return 'waiting' + return opts.relaying ? 'running' : 'stopped' } /** - * The Overview DCS Link tile's endpoint (X9, its own variant): the multicast group for Unicast - * too, as it reads today. + * Where the relay's stream comes from (X9): the one endpoint label every surface shows — the Relay + * pill, the Overview's DCS Link tile and the event log. The host and command port the TCP link + * connects to; the multicast group and port loopback joins; the port Unicast binds, on every + * address (transports.ts binds it with none). */ -export function overviewEndpoint(transport: DcsTransport): EndpointLabel { - return transport === 'tcp-to-host' - ? { hostPort: { suffix: ' · remote' } } - : { text: '239.255.50.10:5010 · local' } +export function relayEndpoint( + cfg: Pick +): string { + switch (cfg.transport) { + case 'tcp-to-host': + return `${cfg.host}:${cfg.commandPort}` + case 'loopback-multicast': + return `${EXPORT_MULTICAST_ADDR}:${EXPORT_PORT}` + case 'unicast-listen': + return `0.0.0.0:${cfg.listenPort}` + } } /** - * May a recording start (X12, each surface its own variant): the Connection tab's Record card - * only while relaying; Settings › Connection's Record whenever. + * May a recording start (X12): only while relaying, on every surface that offers it — the + * Connection tab's Record card and Settings › Connection's Record row. A recording already on can + * always be stopped, so a relay stopped mid-recording never strands it. */ -export function canRecord(relaying: boolean): RelaySurfaces['canRecord'] { - return { connection: relaying, settings: true } +export function canRecord(relaying: boolean, recording: boolean): boolean { + return relaying || recording } /** - * Is a Record control shown at all (R3.4a, each surface its own variant like canRecord): the + * Is a Record control shown at all (R3.4a, each surface its own variant): the * Connection tab's Record card is hidden in Replay (the renderer's rule before); Settings › * Connection's Record row always shows. */ @@ -196,7 +203,10 @@ export function replayView(load: ReplayLoad, prev: RelayFacts['replay']): RelayF /** What DcsRelay judges the surfaces against. */ export interface RelayInputs { - config: Pick + config: Pick< + AppConfig, + 'sourceMode' | 'replayDriveSerial' | 'transport' | 'host' | 'commandPort' | 'listenPort' + > stream?: StreamState deviceState: DeviceState /** The link is up (the gateway's isLinkUp). */ @@ -207,7 +217,13 @@ export interface RelayInputs { /** The surfaces for one state of the on/off. */ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { - const o = { mode: i.config.sourceMode, relaying, stream: i.stream, deviceState: i.deviceState } + const o = { + mode: i.config.sourceMode, + replayDriveSerial: i.config.replayDriveSerial, + relaying, + stream: i.stream, + deviceState: i.deviceState + } const blocked = !relaying && startBlocked({ @@ -219,12 +235,12 @@ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { waiting: relayNote(o), tip: relayTip(o), ...(blocked ? { startBlocked: i.config.sourceMode === 'replay' ? 'replay' : 'bridge' } : {}), - overviewLink: overviewLink(relaying, i.linkUp), - canRecord: canRecord(relaying) + overviewLink: overviewLink(o, i.linkUp), + canRecord: canRecord(relaying, i.recording.on) } } -/** `relay:facts`: both sides of the on/off, the endpoints, the recording and the capture. */ +/** `relay:facts`: both sides of the on/off, the endpoint, the recording and the capture. */ export function relayFacts(i: RelayInputs): RelayFacts { return { mode: i.config.sourceMode, @@ -233,10 +249,7 @@ export function relayFacts(i: RelayInputs): RelayFacts { stopped: surfaces(i, false), ...(i.stream !== undefined ? { stream: i.stream } : {}), deviceState: i.deviceState, - endpoint: { - pill: pillEndpoint(i.config.transport), - overview: overviewEndpoint(i.config.transport) - }, + endpoint: relayEndpoint(i.config), recording: i.recording, recordShown: recordShown(i.config.sourceMode), captureShown: captureShown(i.config.sourceMode), diff --git a/src/renderer/src/app/overview/Overview.tsx b/src/renderer/src/app/overview/Overview.tsx index 9c160fd..c3e979d 100644 --- a/src/renderer/src/app/overview/Overview.tsx +++ b/src/renderer/src/app/overview/Overview.tsx @@ -2,7 +2,7 @@ import { fmtBytes, fmtUptime } from '@shared/format' import type { DeviceState } from '@shared/gateway' import type { GaugeTone, OverviewLink } from '@shared/dcs' import { useConfigStore, viewTransport } from '../../config' -import { endpointText, relaySide, useDcsStore } from '../../dcs' +import { relaySide, useDcsStore } from '../../dcs' import { gatewayView, useGatewayStore } from '../../gateway' import { Dot, useNodesStore } from '../../nodes' import { useNav, type TabId } from '../kit' @@ -31,6 +31,7 @@ const LINK_SUB: Record = { 'gateway-relaying': 'Gateway · relaying', 'gateway-stopped': 'Gateway · stream stopped', waiting: 'relay waiting — resumes when it connects', + running: 'stream running', stopped: 'stream stopped' } /** A gauge ring's colour, for its level as main judges it (R3.3). */ @@ -100,8 +101,9 @@ export function Overview() { // The transport the relay's facts were judged against (R3.4c), as its endpoint below is. const transport = viewTransport(dcs.relayFacts.transport) const link = transport === 'tcp' ? 'TCP' : transport === 'loop' ? 'Multicast' : 'Unicast' - // The DCS Link tile's endpoint and the Link tile's sub-line are main's (relay:facts, R3.3). - const linkSub = endpointText(dcs.relayFacts.endpoint.overview, cfg.host, cfg.port) + // The DCS Link tile's endpoint (the pill's too, X9) and the Link tile's sub-line are main's + // (relay:facts, R3.3). + const linkSub = dcs.relayFacts.endpoint const relaySub = LINK_SUB[relaySide(dcs.relayFacts, dcs.relaying).overviewLink] const acft = dcs.aircraft.name && dcs.aircraft.name !== 'NONE' ? dcs.aircraft.name : 'NONE' diff --git a/src/renderer/src/app/settings/ConnectionSection.tsx b/src/renderer/src/app/settings/ConnectionSection.tsx index 8251519..ee10a9a 100644 --- a/src/renderer/src/app/settings/ConnectionSection.tsx +++ b/src/renderer/src/app/settings/ConnectionSection.tsx @@ -7,10 +7,10 @@ export function ConnectionSection() { const dcs = useDcsStore() const setConfigField = useConfigStore((x) => x.setConfigField) const toggleCapture = useDcsStore((x) => x.toggleCapture) - // The recording and whether this row may start one (X12: whenever), as main decides (R3.3), and - // whether the row shows (always; R3.4a). + // The recording and whether this row may start one (X12: only while relaying, as the Connection + // tab's card), as main decides (R3.3), and whether the row shows (always; R3.4a). const recording = dcs.relayFacts.recording - const canRecord = relaySide(dcs.relayFacts, dcs.relaying).canRecord.settings + const canRecord = relaySide(dcs.relayFacts, dcs.relaying).canRecord const recordShown = dcs.relayFacts.recordShown.settings return ( diff --git a/src/renderer/src/app/titlebar/RelayPill.tsx b/src/renderer/src/app/titlebar/RelayPill.tsx index 27728e8..fb830a6 100644 --- a/src/renderer/src/app/titlebar/RelayPill.tsx +++ b/src/renderer/src/app/titlebar/RelayPill.tsx @@ -1,10 +1,8 @@ import { useConfigStore } from '../../config' import { RelayPill as Pill } from '../../dcs' -/** The title-bar Relay pill (dcs), with the settings its tooltip names: auto-connect, host, port. */ +/** The title-bar Relay pill (dcs), with the setting its tooltip names: auto-connect. */ export function RelayPill() { const autoConnect = useConfigStore((s) => s.autoConnect) - const host = useConfigStore((s) => s.host) - const port = useConfigStore((s) => s.port) - return + return } diff --git a/src/renderer/src/dcs/RelayControls.tsx b/src/renderer/src/dcs/RelayControls.tsx index c7f9b8d..c730b65 100644 --- a/src/renderer/src/dcs/RelayControls.tsx +++ b/src/renderer/src/dcs/RelayControls.tsx @@ -92,11 +92,7 @@ export function RecordCard() {
Record
- - + {resetOpen && setResetOpen(false)} />} diff --git a/src/renderer/src/app/settings/ConnectionSection.tsx b/src/renderer/src/app/settings/ConnectionSection.tsx index ee10a9a..61581f7 100644 --- a/src/renderer/src/app/settings/ConnectionSection.tsx +++ b/src/renderer/src/app/settings/ConnectionSection.tsx @@ -6,6 +6,8 @@ export function ConnectionSection() { const s = useConfigStore() const dcs = useDcsStore() const setConfigField = useConfigStore((x) => x.setConfigField) + const typeLinkField = useConfigStore((x) => x.typeLinkField) + const commitLinkField = useConfigStore((x) => x.commitLinkField) const toggleCapture = useDcsStore((x) => x.toggleCapture) // The recording and whether this row may start one (X12: only while relaying, as the Connection // tab's card), as main decides (R3.3), and whether the row shows (always; R3.4a). @@ -27,15 +29,20 @@ export function ConnectionSection() { className="input" style={{ width: 160 }} value={s.host} - onChange={(e) => setConfigField({ host: e.target.value })} + onChange={(e) => typeLinkField('host', e.target.value)} + onBlur={() => commitLinkField('host')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('host')} /> setConfigField({ port: e.target.value })} + onChange={(e) => typeLinkField('port', e.target.value)} + onBlur={() => commitLinkField('port')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('port')} /> )} + {s.portError && ( +
+ {s.portError} +
+ )} ) } diff --git a/src/renderer/src/config/store.ts b/src/renderer/src/config/store.ts index 36377c3..0122940 100644 --- a/src/renderer/src/config/store.ts +++ b/src/renderer/src/config/store.ts @@ -20,6 +20,8 @@ import type { PsuLimits, PsuSetting } from '@shared/nodes' export type SourceMode = 'bridge' | 'monitor' | 'replay' export type TransportId = 'tcp' | 'loop' | 'uni' +/** The typed DCS link fields, saved on commit (blur / Enter, N5). */ +export type LinkField = 'host' | 'port' /** A call to main as the view made it: undefined outside Electron (no `window.skyhawk`). */ type Ask> = ( @@ -31,6 +33,15 @@ export interface ConfigState { transport: TransportId host: string port: string + /** Why main refused the last committed port (N5); nothing was saved for it. */ + portError?: string + /** The link fields typed since their last commit: what a commit sends (N5). */ + linkTyped: Partial> + /** + * How many times main's settings replaced the forms (`config:get`, an import): a form that + * keeps its own typed text (Settings › Cockpit's PSU limits) starts over from them on each (N8). + */ + formsReset: number autoConnect: boolean // replay @@ -47,6 +58,10 @@ export interface ConfigState { debugDump?: { path: string; count: number } setConfigField: (patch: Partial) => void + /** A DCS link field as typed: held here, not saved until it is committed (N5). */ + typeLinkField: (field: LinkField, text: string) => void + /** A DCS link field committed (blur / Enter): main checks and saves it, or answers why not (N5). */ + commitLinkField: (field: LinkField) => void /** Settings › Cockpit's PSU switch: main switches the mode over the setting it holds (R3.4a). */ setPsuMode: (mode: PsuSetting['mode']) => void /** Settings › Cockpit's PSU limit, as typed: main refuses, deletes or sets it (R3.4a). */ @@ -90,9 +105,14 @@ export function viewTransport(transport: DcsTransport): TransportId { return t ?? 'tcp' } -/** The store fields an AppConfig sets — on load, and after a config import replaces it. */ -function configState(cfg: AppConfig): ConfigFields { +/** + * The store fields an AppConfig sets — on load, and after a config import replaces it: the forms + * start over, nothing typed left to commit and no refused port shown. + */ +function configState(cfg: AppConfig): ConfigFields & Pick { return { + portError: undefined, + linkTyped: {}, sourceMode: cfg.sourceMode, transport: viewTransport(cfg.transport), host: cfg.host, @@ -128,8 +148,9 @@ function configEdit(patch: Partial): ConfigEdit { /** A form's change, to main (`config:edit`): main builds the settings and saves them (R3.3). */ const editConfig = (edit: ConfigEdit): void => void api()?.editConfig(edit) -export const useConfigStore = create((set) => ({ +export const useConfigStore = create((set, get) => ({ ...configState(DEFAULT_CONFIG), + formsReset: 0, psuView: PSU_VIEW_DEFAULT, // What is typed stays as typed; main builds and saves the settings (`config:edit`, R3.3). @@ -138,6 +159,28 @@ export const useConfigStore = create((set) => ({ editConfig(configEdit(patch)) }, + // Typing saves nothing (N5): the text waits for its commit. + typeLinkField: (field, text) => { + set((s) => ({ [field]: text, linkTyped: { ...s.linkTyped, [field]: true } })) + }, + + // Main checks the port and saves, or saves nothing and answers why: the form shows that answer + // under the port, unless the field has changed since (N5). A commit with nothing typed sends + // nothing (an Enter, then the blur). + commitLinkField: (field) => { + const s = get() + if (!s.linkTyped[field]) return + const text = s[field] + const { [field]: _sent, ...linkTyped } = s.linkTyped + set({ linkTyped }) + const answer = api()?.editConfig({ [field]: text }) + if (field !== 'port' || !answer) return + void answer.then((r) => { + if (get().port !== text) return + set({ portError: r.ok ? undefined : r.error }) + }) + }, + // The switched setting comes back on `config:changed` (R3.4a). setPsuMode: (mode) => { editConfig({ psuMode: mode }) @@ -178,19 +221,26 @@ export const useConfigStore = create((set) => ({ export const configFromMain = { /** `config:get`. */ config: (v: ConfigView): void => - useConfigStore.setState({ ...configState(v.config), psuView: v.psu }), + useConfigStore.setState((s) => ({ + ...configState(v.config), + psuView: v.psu, + formsReset: s.formsReset + 1 + })), /** - * `config:changed`. An import replaced the settings: the forms start over from them. A form's own - * change leaves what is typed alone (the port as typed, N5); the PSU setting is main's either way, - * since main switches its mode (R3.4a). + * `config:changed`. An import replaced the settings: the forms start over from them, the PSU + * limits' typed text too (N8). A form's own change leaves what is typed alone (the port as typed, + * N5); the PSU setting is main's either way, since main switches its mode (R3.4a). */ changed: (c: ConfigChanged): void => - useConfigStore.setState({ + useConfigStore.setState((s) => ({ psuView: c.psu, psu: c.config.psu, - ...(c.source === 'import' ? configState(c.config) : {}) - }), + ...(c.source === 'import' && { + ...configState(c.config), + formsReset: s.formsReset + 1 + }) + })), /** `config:backups`, and `config:backups-get`'s answer. */ backups: (list: BackupList): void => useConfigStore.setState({ backups: list }) diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 674e2aa..5392ee1 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -62,7 +62,8 @@ import { type AppConfig, type BackupList, type ConfigChanged, - type ConfigEdit + type ConfigEdit, + type ConfigEditResult } from '@shared/config' import { AIRCRAFT_NONE, @@ -469,17 +470,17 @@ export function installSkyhawkMock(): SkyhawkMock { * A form's change: SettingsService builds the save (settings-form.ts) and applies it — * `setConfig`, the spy, stands for that apply — the relay's facts follow, then `config:changed`. */ - const editSettings = (edit: ConfigEdit): Promise => { + const editSettings = (edit: ConfigEdit): Promise => { const built = savedEdit(config(), edit) - // A refused PSU limit: nothing saved, nothing pushed (SettingsService.edit). - if (built === undefined) return resolve(config()) - const saved = fns.setConfig(built) - state = { ...state, config: { ...config(), ...built } } + // Refused (a junk port, a refused PSU limit): nothing saved, nothing pushed (SettingsService.edit). + if (!built.ok) return resolve(built) + void fns.setConfig(built.save) + state = { ...state, config: { ...config(), ...built.save } } turn(() => { pushFacts() push('config:changed', configChanged('edit')) }) - return saved + return resolve({ ok: true as const, config: config() }) } // ── the relay's facts, as main's DcsRelay pushes them (relay-rules.ts) ── diff --git a/src/renderer/test/t27-overview-hero-gauges.test.tsx b/src/renderer/test/t27-overview-hero-gauges.test.tsx index 31e4030..025b198 100644 --- a/src/renderer/test/t27-overview-hero-gauges.test.tsx +++ b/src/renderer/test/t27-overview-hero-gauges.test.tsx @@ -188,13 +188,13 @@ describe('T27 Overview hero tiles and gauges (B56, B57)', () => { } ) - it('gauges: five idle readouts, "—", until the first telemetry tick', async () => { + it('gauges: five idle readouts, "—", until the first tick — in kt / ft already (X10)', async () => { await renderScenario(, { device: connected() }) expect(['RPM', 'IAS', 'Flap', 'Press Alt', 'Fuel'].map((l) => gauge(l).text)).toEqual([ '—% RPM', - '—% FS', + '—kt', '—% DN', - '—% FS', + '—ft', '—% QTY' ]) }) diff --git a/src/renderer/test/t28-connection-relay-controls.test.tsx b/src/renderer/test/t28-connection-relay-controls.test.tsx index 65fe778..f81db75 100644 --- a/src/renderer/test/t28-connection-relay-controls.test.tsx +++ b/src/renderer/test/t28-connection-relay-controls.test.tsx @@ -140,17 +140,23 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { ) }) - it('DCS link: host, port and transport are saved as typed or picked', async () => { + it('DCS link: host and port are saved on commit (blur / Enter), transport as picked', async () => { const { sky, user } = await renderScenario(, { device: connected() }) const host = screen.getByText('DCS host').nextElementSibling as HTMLInputElement const port = screen.getByText('Port').nextElementSibling as HTMLInputElement expect([host.value, port.value]).toEqual(['127.0.0.1', '7778']) await user.clear(host) await user.type(host, '10.0.0.7') + expect(sky.setConfig, 'N5: typing saves nothing').not.toHaveBeenCalled() + await user.tab() // the host's blur commits it expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7778 }) await user.clear(port) await user.type(port, '7779') + expect(sky.setConfig).toHaveBeenCalledTimes(1) + await user.keyboard('{Enter}') expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7779 }) + await user.tab() // the blur after Enter: nothing new typed, nothing sent again + expect(sky.setConfig).toHaveBeenCalledTimes(2) await user.click(screen.getByRole('button', { name: /^Loopback multicast/ })) expect(lastSaved(sky)).toMatchObject({ transport: 'loopback-multicast' }) @@ -160,6 +166,27 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { expect(lastSaved(sky)).toMatchObject({ transport: 'tcp-to-host' }) }) + it('DCS link: a junk port shows main’s error and saves nothing; a good one saves', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + const port = screen.getByText('Port').nextElementSibling as HTMLInputElement + await user.clear(port) + await user.type(port, '77a{Enter}') + await settle() + expect(port).toHaveValue('77a') + expect(port).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText('Not a port. Enter a whole number from 1 to 65535.') + ).toBeInTheDocument() + expect(sky.setConfig, 'nothing saved, never a silent 7778').not.toHaveBeenCalled() + + await user.clear(port) + await user.type(port, '7790{Enter}') + await settle() + expect(lastSaved(sky)).toMatchObject({ commandPort: 7790 }) + expect(port).toHaveAttribute('aria-invalid', 'false') + expect(screen.queryByText(/^Not a port/)).toBeNull() + }) + it('Record is greyed out until the relay runs', async () => { const { sky } = await renderScenario(, { device: connected() }) expect(screen.getByRole('button', { name: 'Start recording…' })).toBeDisabled() diff --git a/src/renderer/test/t29-settings-power-supply.test.tsx b/src/renderer/test/t29-settings-power-supply.test.tsx index da1f09c..9e63c06 100644 --- a/src/renderer/test/t29-settings-power-supply.test.tsx +++ b/src/renderer/test/t29-settings-power-supply.test.tsx @@ -1,10 +1,14 @@ // T29 — Settings › Cockpit › Power supply: the NX650 preset or limits typed by hand. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' +import { DEFAULT_CONFIG } from '@shared/config' import type { PsuSetting } from '@shared/nodes' +import { CockpitSection } from '../src/app/settings/CockpitSection' import { Settings } from '../src/app/settings/Settings' +import { ConfigurationSection } from '../src/config' +import { AXIS_LABELS } from '../src/hid' import { renderScenario, settle } from './render' -import { connected } from './scenarios' +import { configFileText, connected, importPreview, setup } from './scenarios' const LIMITS = ['12 V rail limit', '5 V rail limit', 'Total power'] as const @@ -115,4 +119,38 @@ describe('T29 Settings › Cockpit › Power supply (B87)', () => { expect(field('Total power')).toHaveAttribute('aria-invalid', 'true') expect(saved()?.custom.w).not.toBe(Number(typed)) }) + + it('N8: an import with other limits refreshes the fields, a refused text included', async () => { + const psu: PsuSetting = { mode: 'custom', preset: 'nx650', custom: { a12: 40 } } + const imported: PsuSetting = { mode: 'custom', preset: 'nx650', custom: { a12: 30, w: 400 } } + // Both sections on screen, so the limit fields are there when the import's push lands. + const { sky, user } = await renderScenario( + <> + + + , + { device: connected(), config: { psu } } + ) + const field = (l: (typeof LIMITS)[number]) => + screen.getByRole('textbox', { name: l }) as HTMLInputElement + await user.type(field('5 V rail limit'), '15x') + expect(field('5 V rail limit')).toHaveAttribute('aria-invalid', 'true') + + sky.openConfigImport.mockResolvedValueOnce({ + status: 'ready', + preview: importPreview(configFileText({ powerSupply: imported }), setup({ powerSupply: psu })) + }) + sky.applyConfigImport.mockResolvedValueOnce({ + ok: true, + applied: ['powerSupply'], + config: { ...DEFAULT_CONFIG, psu: imported } + }) + await user.click(screen.getByRole('button', { name: 'Import…' })) + await settle() + const dlg = screen.getByRole('dialog', { name: 'Import configuration' }) + await user.click(within(dlg).getByRole('button', { name: 'Apply 1 section' })) + await settle() + expect(LIMITS.map((l) => field(l).value)).toEqual(['30', '', '400']) + expect(field('5 V rail limit')).toHaveAttribute('aria-invalid', 'false') + }) }) diff --git a/src/renderer/test/t31-settings-connection-diagnostics.test.tsx b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx index 05b7b17..70f58e8 100644 --- a/src/renderer/test/t31-settings-connection-diagnostics.test.tsx +++ b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx @@ -38,16 +38,39 @@ describe('T31 Settings › Connection + Diagnostics (B86, B91)', () => { expect(control('Command port')).toHaveValue('7779') }) - it('the DCS host and command port are saved as typed', async () => { - const { user, saved } = await openSettings() + it('the DCS host and command port are saved on commit (blur / Enter), not per keystroke', async () => { + const { sky, user, saved } = await openSettings() await user.clear(control('DCS host')) await user.type(control('DCS host'), '192.168.1.20') + expect(sky.setConfig, 'N5: typing saves nothing').not.toHaveBeenCalled() + await user.keyboard('{Enter}') expect(saved()).toMatchObject({ host: '192.168.1.20' }) await user.clear(control('Command port')) await user.type(control('Command port'), '7780') + expect(sky.setConfig).toHaveBeenCalledTimes(1) + await user.tab() expect(saved()).toMatchObject({ host: '192.168.1.20', commandPort: 7780 }) }) + it('a junk command port: main’s error under the card, nothing saved', async () => { + const { sky, user, saved } = await openSettings(undefined, { commandPort: 7779 }) + await user.clear(control('Command port')) + await user.type(control('Command port'), '99999') + await user.tab() + await settle() + expect(control('Command port')).toHaveValue('99999') + expect(control('Command port')).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText('Not a port. Enter a whole number from 1 to 65535.') + ).toBeInTheDocument() + expect(sky.setConfig).not.toHaveBeenCalled() + await user.clear(control('Command port')) + await user.type(control('Command port'), '7781{Enter}') + await settle() + expect(saved()).toMatchObject({ commandPort: 7781 }) + expect(screen.queryByText(/^Not a port/)).toBeNull() + }) + it('Connect automatically: the toggle saves the setting either way', async () => { // Turning it on while the link is down also connects at once — main does that when the // setting arrives (SESSION seam); the renderer only saves it. diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 37bf78a..84399c2 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -106,6 +106,11 @@ // the Connection card and Settings › Connection. `OverviewLink` gained `running`: a relay that runs // without needing the board (Monitor, an undriven Replay) is never "waiting" for it. // +// Fix track #134 (N5: the command port saved on commit, validated): no channel, intent or method +// was added, removed or renamed. `config:edit` (editConfig) answers `ConfigEditResult` instead of +// `AppConfig`: the settings now held, or the change refused (nothing saved) with why — a command +// port that is not one shows main's error under the field. +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -114,6 +119,7 @@ import type { BackupList, ConfigChanged, ConfigEdit, + ConfigEditResult, ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen, @@ -285,7 +291,7 @@ interface OldSkyhawkApi { on(channel: C, cb: (data: OldPushChannels[C]) => void): () => void getConfig(): Promise setConfig(patch: Partial): Promise - editConfig(edit: ConfigEdit): Promise + editConfig(edit: ConfigEdit): Promise exportConfig(): Promise openConfigImport(): Promise applyConfigImport( diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts index 69513e7..480efdb 100644 --- a/src/shared/app/ipc.ts +++ b/src/shared/app/ipc.ts @@ -10,6 +10,7 @@ import { type AppConfig, type BackupList, type ConfigEdit, + type ConfigEditResult, type ConfigPushChannels, type ConfigView, type ConfigExportResult, @@ -220,9 +221,9 @@ export interface SkyhawkApi { setConfig(patch: Partial): Promise /** * A form's change, as typed (R3.3): main builds the save over what it holds and pushes - * `config:changed`. + * `config:changed`, or refuses it (nothing saved) and answers why (N5). */ - editConfig(edit: ConfigEdit): Promise + editConfig(edit: ConfigEdit): Promise // ── configuration export / import (#67) ────────────────────────────────── /** Save dialog, then write every section to one file. */ diff --git a/src/shared/config/ipc.ts b/src/shared/config/ipc.ts index 21d1855..414df7b 100644 --- a/src/shared/config/ipc.ts +++ b/src/shared/config/ipc.ts @@ -51,14 +51,14 @@ export const DEFAULT_CONFIG: AppConfig = { /** * A change to the settings as the forms make it (R3.3): the fields they edit, the command port as - * typed. Main builds the settings from it (buildConfig: a port that isn't a number saves as the - * default, N5) over what it holds, and saves them. + * typed. Main builds the settings from it over what it holds, and saves them — or refuses it, and + * saves nothing (a port that is not one, N5: settings-form.ts commandPortOf). */ export interface ConfigEdit { sourceMode?: SourceMode transport?: DcsTransport host?: string - /** The command port as typed, e.g. `'7778'`, `''` or `'77a'`. */ + /** The command port as typed, e.g. `'7778'`, `''` or `'77a'`; sent when the field is committed. */ port?: string autoConnect?: boolean replayDriveSerial?: boolean @@ -77,6 +77,12 @@ export interface ConfigEdit { psuLimit?: { key: keyof PsuLimits; text: string } } +/** + * `config:edit`'s answer: the settings now held, or the change refused — nothing saved, and, for a + * field the form shows an error under (the command port, N5), why. + */ +export type ConfigEditResult = { ok: true; config: AppConfig } | { ok: false; error?: string } + /** What the power supply setting comes to (R3.3): SettingsService resolves it. */ export interface PsuView { /** The plan's PSU box: the preset's model, or plain `PSU` for a custom supply. */ diff --git a/src/shared/dcs/ipc.ts b/src/shared/dcs/ipc.ts index 51ff87e..e572b48 100644 --- a/src/shared/dcs/ipc.ts +++ b/src/shared/dcs/ipc.ts @@ -189,15 +189,15 @@ export interface TelemetryReadout { } /** - * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the - * decoder's idle variant (X10 — its ids and units are not the live readouts'; main pins them, - * main/dcs/sim-telemetry-decoder.test.ts). + * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the live + * readouts with nothing decoded yet, in the same units (X10 — main pins it to the decoder's one + * spec, main/dcs/sim-telemetry-decoder.test.ts). */ export const TELEMETRY_IDLE: TelemetryReadout[] = [ { id: 'RPM', label: 'RPM', value: NaN, pct: 0, unit: '% RPM', tone: 'primary' }, - { id: 'D_IAS_DEG', label: 'IAS', value: NaN, pct: 0, unit: '% FS', tone: 'primary' }, + { id: 'D_IAS_DEG', label: 'IAS', value: NaN, pct: 0, unit: 'kt', tone: 'primary' }, { id: 'D_FLAPS_IND', label: 'Flap', value: NaN, pct: 0, unit: '% DN', tone: 'secondary' }, - { id: 'D_ALT_NEEDLE', label: 'Press Alt', value: NaN, pct: 0, unit: '% FS', tone: 'primary' }, + { id: 'ALT_FT', label: 'Press Alt', value: NaN, pct: 0, unit: 'ft', tone: 'secondary' }, { id: 'D_FUEL', label: 'Fuel', value: NaN, pct: 0, unit: '% QTY', tone: 'fuel-low' } ] From bad0bf5013ae0dad1d184ecc9bf7ffbf8f539993 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 21:58:51 -0400 Subject: [PATCH 3/3] fix(dcs): unicast endpoint reads "this PC:" instead of 0.0.0.0 (X9) --- src/main/app/pushed-rules.seam.test.ts | 4 ++-- src/main/dcs/relay-rules.test.ts | 2 +- src/main/dcs/relay-rules.ts | 6 +++--- src/renderer/test/t27-overview-hero-gauges.test.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index f39e845..f291df3 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -82,7 +82,7 @@ describe('relay:facts: what the relay’s surfaces show', () => { it('a form’s edit: built over the settings held, a typed port checked (N5); the facts follow', () => { const app = makeServices(monitor) app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) - expect(app.lastPush('relay:facts')?.endpoint).toBe('0.0.0.0:5010') + expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010') expect(app.invoke(CTRL.configEdit, { port: '7781' })).toMatchObject({ ok: true, config: { commandPort: 7781 } @@ -111,7 +111,7 @@ describe('fix track #134: the relay endpoint, Record and the waiting wording', ( app.invoke(CTRL.configEdit, { transport: 'loopback-multicast' }) expect(app.lastPush('relay:facts')?.endpoint).toBe('239.255.50.10:5010') app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) - expect(app.lastPush('relay:facts')?.endpoint).toBe('0.0.0.0:5010') + expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010') }) it('X12: Record refused while stopped, before any dialog; allowed while relaying', async () => { diff --git a/src/main/dcs/relay-rules.test.ts b/src/main/dcs/relay-rules.test.ts index b3412fd..cb9832a 100644 --- a/src/main/dcs/relay-rules.test.ts +++ b/src/main/dcs/relay-rules.test.ts @@ -132,7 +132,7 @@ describe('X9: one endpoint label, right for each transport', () => { it('TCP: the host and command port; Multicast: the group; Unicast: the port it listens on', () => { expect(at('tcp-to-host')).toBe('10.0.0.7:7779') expect(at('loopback-multicast')).toBe('239.255.50.10:5010') - expect(at('unicast-listen')).toBe('0.0.0.0:5011') + expect(at('unicast-listen')).toBe('this PC:5011') }) it('pushed once in the facts, from the settings held', () => { diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts index b3fa31c..1698eb3 100644 --- a/src/main/dcs/relay-rules.ts +++ b/src/main/dcs/relay-rules.ts @@ -148,8 +148,8 @@ export function overviewLink(opts: RelayOpts, linkUp: boolean): OverviewLink { /** * Where the relay's stream comes from (X9): the one endpoint label every surface shows — the Relay * pill, the Overview's DCS Link tile and the event log. The host and command port the TCP link - * connects to; the multicast group and port loopback joins; the port Unicast binds, on every - * address (transports.ts binds it with none). + * connects to; the multicast group and port loopback joins; "this PC" and the port Unicast binds + * on every address (transports.ts binds it with none). */ export function relayEndpoint( cfg: Pick @@ -160,7 +160,7 @@ export function relayEndpoint( case 'loopback-multicast': return `${EXPORT_MULTICAST_ADDR}:${EXPORT_PORT}` case 'unicast-listen': - return `0.0.0.0:${cfg.listenPort}` + return `this PC:${cfg.listenPort}` } } diff --git a/src/renderer/test/t27-overview-hero-gauges.test.tsx b/src/renderer/test/t27-overview-hero-gauges.test.tsx index 025b198..08d7ce5 100644 --- a/src/renderer/test/t27-overview-hero-gauges.test.tsx +++ b/src/renderer/test/t27-overview-hero-gauges.test.tsx @@ -158,7 +158,7 @@ describe('T27 Overview hero tiles and gauges (B56, B57)', () => { ['tcp-to-host', 'TCP', '10.0.0.7:7779'], ['loopback-multicast', 'Multicast', '239.255.50.10:5010'], // X9: Unicast shows the port it listens on, not the multicast group. - ['unicast-listen', 'Unicast', '0.0.0.0:5011'] + ['unicast-listen', 'Unicast', 'this PC:5011'] ] as const)('DCS Link tile, %s: "%s"', async (transport, v, s) => { await renderScenario(, { config: { transport, host: '10.0.0.7', commandPort: 7779, listenPort: 5011 } @@ -170,7 +170,7 @@ describe('T27 Overview hero tiles and gauges (B56, B57)', () => { it.each([ ['tcp-to-host', '10.0.0.7:7779'], ['loopback-multicast', '239.255.50.10:5010'], - ['unicast-listen', '0.0.0.0:5011'] + ['unicast-listen', 'this PC:5011'] ] as const)( 'X9: the Relay pill and the DCS Link tile show the same endpoint, %s', async (transport, endpoint) => {