From 9337aac8519e0804bc99b7e067cb91b41f0336e9 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 22:15:51 -0400 Subject: [PATCH 1/4] fix(nodes): healthy CAN state reads "OK" instead of error-active (#163) **Today:** a board panel's CAN state row shows "error-active" for the healthy CAN state (`panels.ts:241`), which reads like a fault. **After:** the healthy state reads "OK"; bus-off and error-passive unchanged. Display text only. **Tests changed on purpose:** `panels.test.ts` (CAN state row) and `t18-node-panel-status` (rendered row) expect "OK". Closes #162 --- src/main/nodes/cockpit-snapshot/panels.test.ts | 2 +- src/main/nodes/cockpit-snapshot/panels.ts | 2 +- src/renderer/test/t18-node-panel-status.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/nodes/cockpit-snapshot/panels.test.ts b/src/main/nodes/cockpit-snapshot/panels.test.ts index cd6932b..a5c1899 100644 --- a/src/main/nodes/cockpit-snapshot/panels.test.ts +++ b/src/main/nodes/cockpit-snapshot/panels.test.ts @@ -295,7 +295,7 @@ describe('nodeStatusRows', () => { { k: 'Uptime', v: '01:02:05' }, { k: 'CAN frames rx', v: '12,345' }, { k: 'CAN TEC / REC', v: '8 / 1' }, - { k: 'CAN state', v: 'error-active' }, + { k: 'CAN state', v: 'OK' }, { k: 'MCU temp', v: '33 °C' } ]) }) diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index dbf397c..100480d 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -238,7 +238,7 @@ export function nodeStatusRows(v: NodeView): Row[] { { k: 'CAN TEC / REC', v: live ? `${live.tec} / ${live.rec}` : DASH }, { k: 'CAN state', - v: live ? (live.boff ? 'bus-off' : live.epvf ? 'error-passive' : 'error-active') : DASH + v: live ? (live.boff ? 'bus-off' : live.epvf ? 'error-passive' : 'OK') : DASH }, { k: 'MCU temp', v: live && live.dieTempC !== null ? `${live.dieTempC} °C` : DASH } ] diff --git a/src/renderer/test/t18-node-panel-status.test.tsx b/src/renderer/test/t18-node-panel-status.test.tsx index ddf0139..c0020a3 100644 --- a/src/renderer/test/t18-node-panel-status.test.tsx +++ b/src/renderer/test/t18-node-panel-status.test.tsx @@ -56,7 +56,7 @@ describe('T18 Node panel, Status tab (B70, B14, F11)', () => { 'Uptime00:01:40', 'CAN frames rx10', 'CAN TEC / REC0 / 0', - 'CAN stateerror-active', + 'CAN stateOK', 'MCU temp30 °C' ]) }) From 09b948a300278927732b26c305b9f595f10dfd22 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 22:20:43 -0400 Subject: [PATCH 2/4] fix(nodes): connected nodes points to new boards on the bus instead of saying none are reported (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Connection › Connected nodes said "No nodes reported. Power on a PanelGroup." while a board not yet added to the cockpit was on the bus and reporting. With no board of your cockpit to count, the card now says where the new ones are: - 1 board: "1 new board on the bus — add it from the Cockpit." - 2+ boards: "2 new boards on the bus — add them from the Cockpit." - "No nodes reported. Power on a PanelGroup." only when the bus reports nothing (none on it, or none identified yet). When cockpit boards exist the card is unchanged. ## How - `src/shared/nodes/ipc.ts` — `NodesSummary` gains `newCount` and `empty` (the card's line when `known` is 0); `NO_NODES` constant used by `COCKPIT_IDLE` and main. - `src/main/nodes/cockpit-snapshot/summaries.ts` — `nodesSummary` takes `newCount` from `newDevices(views, live)` (the Overview notice's count; the same `isReportableNew` rule as the Cockpit's "New on the bus · N"), and `emptyLine` builds the sentence in main, like the card's `footer`. - `src/renderer/src/nodes/NodesCard.tsx` — the existing `hint` shows `sum.empty` instead of the hard-coded text. No layout or styling change. ## Tests changed on purpose - `src/main/nodes/cockpit-snapshot/connection-summary.test.ts` — new `nodesSummary with no board of your cockpit (#160)`: 1 / 2 new boards → count + wording; empty bus and a board still identifying → "No nodes reported…". - `src/renderer/test/t5-connection-nodes-card.test.tsx` — new case via `emitScenario`: empty bus → old line; 1 and 2 new boards → new lines; board added → normal card. All three new tests fail on the old code. `npm run lint`, `lint:deps`, `typecheck`, `npm test` (1464) pass. Closes #160 --- .../connection-summary.test.ts | 20 +++++++++++++++++++ src/main/nodes/cockpit-snapshot/summaries.ts | 19 +++++++++++++++++- src/renderer/src/nodes/NodesCard.tsx | 2 +- .../test/t5-connection-nodes-card.test.tsx | 20 +++++++++++++++++++ src/shared/nodes/ipc.ts | 17 +++++++++++++++- 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts index a6545f1..b4ca4cc 100644 --- a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts @@ -200,3 +200,23 @@ describe('nodesSummary rows', () => { expect([s.rows, s.more, s.footer]).toEqual([[], 0, 'healthy nodes hidden']) }) }) + +describe('nodesSummary with no board of your cockpit (#160)', () => { + const panelNew = (nodeId: number) => node(nodeId, { source: 'new', identified: 'panel' }) + + it('points to the new boards on the bus — the Overview notice’s count — and where to add them', () => { + const one = summary([panelNew(41)]) + expect([one.known, one.newCount]).toEqual([0, 1]) + expect(one.empty).toBe('1 new board on the bus — add it from the Cockpit.') + const two = summary([panelNew(41), pdu(42, 'Left', { source: 'new', identified: 'pdu' })]) + expect(two.newCount).toBe(2) + expect(two.empty).toBe('2 new boards on the bus — add them from the Cockpit.') + }) + + it('says the bus reports nothing only when it does: none on it, or none identified yet', () => { + expect(summary([]).empty).toBe('No nodes reported. Power on a PanelGroup.') + // Still identifying: not news yet (isReportableNew), so not counted. + const s = summary([node(41, { source: 'new' })]) + expect([s.newCount, s.empty]).toEqual([0, 'No nodes reported. Power on a PanelGroup.']) + }) +}) diff --git a/src/main/nodes/cockpit-snapshot/summaries.ts b/src/main/nodes/cockpit-snapshot/summaries.ts index c135e74..8db0ef7 100644 --- a/src/main/nodes/cockpit-snapshot/summaries.ts +++ b/src/main/nodes/cockpit-snapshot/summaries.ts @@ -7,11 +7,14 @@ // adopted (node-model.ts newState), each one a board up and a warning, and an Attention item. // What is on the bus is one rule (node-model.ts onBusNow, X2): while the link is down the tile says // "no live node status", and the banner, the notice and Connected nodes say nothing. +// With none of your cockpit's boards to count, Connected nodes points to the new ones on the bus +// (#160): the same count as the Overview's notice and the Cockpit's "New on the bus". // A Connected nodes row names its board's resolved power zone (D7, the same one the Cockpit reads): // `PDU_Right`, `PDU_Right (hidden)`, else "no power zone". import { hhmmss, lastSeenLabel, plural } from '@shared/format' import { COCKPIT_IDLE, + NO_NODES, type AttentionItem, type AttentionSummary, type CockpitTile, @@ -139,6 +142,16 @@ export function overviewSummary( // ── Connection › Connected nodes ──────────────────────────────────────────── +/** + * The card's line with no board of your cockpit to count: the new boards on the bus and where to + * add them (#160) — `1 new board on the bus — add it from the Cockpit.` — else that the bus + * reports nothing. + */ +function emptyLine(newCount: number): string { + if (newCount === 0) return NO_NODES + return `${plural(newCount, 'new board')} on the bus — add ${newCount === 1 ? 'it' : 'them'} from the Cockpit.` +} + /** * Connected nodes: the counts and the rows that need attention, the Cockpit's strays included (X3: * each one a board up and a warning, with its Attention row). Quiet while the link is down — it @@ -157,6 +170,8 @@ export function nodesSummary( const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) const items = attentionItems(views, outages, strays) const healthy = band.online - band.warn + // The Overview's notice's count (the Cockpit's "New on the bus · N"): one rule, isReportableNew. + const newCount = newDevices(views, live)?.count ?? 0 const rows = items.slice(0, ATTENTION_SHOWN).map((i): SummaryRow => { const e = byId.get(i.nodeId)! @@ -202,6 +217,8 @@ export function nodesSummary( watts: band.watts, rows, more, - footer + footer, + newCount, + empty: emptyLine(newCount) } } diff --git a/src/renderer/src/nodes/NodesCard.tsx b/src/renderer/src/nodes/NodesCard.tsx index 0743de6..e3b777e 100644 --- a/src/renderer/src/nodes/NodesCard.tsx +++ b/src/renderer/src/nodes/NodesCard.tsx @@ -25,7 +25,7 @@ export function NodesCard({ autoConnect }: { autoConnect: boolean }) { {!linkUp ? (
{deviceView(deviceState, { autoConnect }).nodesHint}
) : sum.known === 0 ? ( -
No nodes reported. Power on a PanelGroup.
+
{sum.empty}
) : ( <>
diff --git a/src/renderer/test/t5-connection-nodes-card.test.tsx b/src/renderer/test/t5-connection-nodes-card.test.tsx index 80b7c5b..2efcdf3 100644 --- a/src/renderer/test/t5-connection-nodes-card.test.tsx +++ b/src/renderer/test/t5-connection-nodes-card.test.tsx @@ -114,6 +114,26 @@ describe('T5 Connection › nodes summary card (B63, B14, B37, X2)', () => { expect(rowButtons()).toEqual([]) }) + it('with none of your cockpit’s boards, it points to the new ones on the bus (#160)', async () => { + const T0 = Date.UTC(2026, 9, 16, 12, 0, 0) + const fresh = (n: number) => newBoard(n, T0, { identified: 'panel' }) + const { sky } = await renderScenario(, { device: connected(), model: model([]) }) + expect(screen.getByText('No nodes reported. Power on a PanelGroup.')).toBeInTheDocument() + sky.emitScenario({ model: model([fresh(1)]) }) + expect( + screen.getByText('1 new board on the bus — add it from the Cockpit.') + ).toBeInTheDocument() + expect(screen.queryByText('No nodes reported. Power on a PanelGroup.')).toBeNull() + sky.emitScenario({ model: model([fresh(1), fresh(2)]) }) + expect( + screen.getByText('2 new boards on the bus — add them from the Cockpit.') + ).toBeInTheDocument() + // Added to your cockpit: the normal card again. + sky.emitScenario({ model: model([panel(1, 'Center_Armament')]) }) + expect(count('healthy')).toBe('1healthy') + expect(screen.queryByText(/new boards? on the bus/)).toBeNull() + }) + it('"›" opens the Cockpit on that board', async () => { const { user } = await renderScenario(, { device: connected(), model: scene() }) await user.click(screen.getByRole('tab', { name: 'Connection' })) diff --git a/src/shared/nodes/ipc.ts b/src/shared/nodes/ipc.ts index 3ac0ab1..ea7e968 100644 --- a/src/shared/nodes/ipc.ts +++ b/src/shared/nodes/ipc.ts @@ -787,6 +787,16 @@ export interface NodesSummary { more: number /** `+5 more · healthy nodes hidden`; empty when there is nothing to say. */ footer: string + /** + * New boards on the bus that can be added: the Overview's new-devices count (the Cockpit's "New + * on the bus · N"), not part of `known`. + */ + newCount: number + /** + * The card's line when `known` is 0: where to add the new boards on the bus (#160), else that + * the bus reports nothing. + */ + empty: string } /** A registry row's "On the bus" column. */ @@ -848,6 +858,9 @@ export interface CockpitSnapshot { settings: SettingsNodes } +/** Connected nodes' line while the bus reports nothing at all (NodesSummary `empty`). */ +export const NO_NODES = 'No nodes reported. Power on a PanelGroup.' + /** What the Cockpit shows before main's first snapshot: no model, link down. */ export const COCKPIT_IDLE: CockpitSnapshot = { rev: 0, @@ -899,7 +912,9 @@ export const COCKPIT_IDLE: CockpitSnapshot = { watts: null, rows: [], more: 0, - footer: '' + footer: '', + newCount: 0, + empty: NO_NODES }, settings: { differing: 0, canReset: false, hidden: [], registry: { summary: '', rows: [] } } } From 6f09c95535b8ad91b85994ba79de8f7a98afc15d Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 22:25:12 -0400 Subject: [PATCH 3/4] fix(dcs): start with no gateway starts and waits for the board instead of being refused (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What In Bridge, and in Replay with Drive Gateway on, Start with no Gateway was refused (`startBlocked`, enforced in `DcsRelay.start()`): the pill flipped back to Stopped and main's reason never showed. Start now always starts. With no board the relay runs and shows the waiting reason a driven Replay already shows after a drop: gold pill + "waiting for the Gateway" tooltip, the note under Start, the Overview's "relay waiting — resumes when it connects". When the Gateway connects, delivery begins with no further click. ## Changes - `src/main/dcs/dcs-relay.ts` — `start()` no longer gated on the link; it just runs. - `src/main/dcs/relay-rules.ts` — `startBlocked()` and the `startBlocked` surface fact removed. - `src/shared/dcs/ipc.ts` — `RelaySurfaces.startBlocked` removed (type change only; noted in `ipc.compat.test-d.ts` header). - Renderer, now dead without the fact: - `RelayPill.tsx` — the "!" mark, `aria-disabled`, and the "Can't start the relay" callout + Connect button removed. - `RelayControls.tsx` — Start is never disabled. - `relay-view.ts` — `startBlockedReason` / `startBlockedCallout` removed; `relayNote` returns the text only, since its `info` flag was only set for the blocked reason. - `styles.css` — `.pill--blocked`, `.devpop__hint`, `.waitnote--info` removed (only those used them). - Still refused: Replay with no capture loaded ("No capture loaded"), plus a transport that throws on start. The store's take-back of a refused pending Start stays for those cases (comment updated). ## Tests New (they fail on the old code): - `pushed-rules.seam.test.ts` — Bridge with no board: Start → `running`, `waiting: 'device'`, Overview `waiting`; the board arrives → `delivering` and export reaches the board. Same for a driven Replay with a capture. - `t15-relay-pill.test.tsx` — Bridge / driven Replay with no board: a click starts, the pill goes gold with the waiting-for-Gateway tooltip; the board arriving clears it with no further click. Updated because they pinned the old refusal: - `src/main/dcs/relay-rules.test.ts` — "each side of the on/off": the stopped side carries no `startBlocked`. - `src/main/app/app-services.test.ts` — "refuses Start until the board is connected…" → "starts with no board … and waits for the board" (now also checks delivery once the board opens); "does not reconnect a released link on Start" now expects `ok: true`; the Monitor/Replay test expects a driven Replay to be refused only for having no capture. - `src/main/app/pushed-rules.seam.test.ts` — "Bridge with no board: Start blocked…" replaced (above); "toggle: … a refused Start pushes no relay state" now refuses with a Replay that has no capture. - `src/renderer/src/gateway/link-view.test.ts` — `startBlocked` / `startBlockedReason` describes removed; `relayNote` expectations are strings. - `src/renderer/test/t15-relay-pill.test.tsx` — the three blocked/callout tests ("blocked with '!'…", "the board arriving lifts the block…", "Esc closes the callout…") replaced by the start-and-wait test; `aria-disabled` checks removed. - `src/renderer/test/t28-connection-relay-controls.test.tsx` — "Drive Gateway … without a board it blocks Start" and "Bridge without a board: Start is greyed out" now start and show the waiting note. Closes #159 --- src/main/app/app-services.test.ts | 25 +++-- src/main/app/app-services.ts | 4 +- src/main/app/pushed-rules.seam.test.ts | 59 ++++++++--- src/main/dcs/dcs-relay.ts | 12 +-- src/main/dcs/relay-rules.test.ts | 11 ++- src/main/dcs/relay-rules.ts | 28 +----- src/renderer/src/dcs/RelayControls.tsx | 12 +-- src/renderer/src/dcs/RelayPill.tsx | 98 +++---------------- src/renderer/src/dcs/relay-view.ts | 39 +------- src/renderer/src/dcs/store.ts | 4 +- src/renderer/src/gateway/link-view.test.ts | 55 ++--------- src/renderer/src/styles.css | 15 --- src/renderer/test/t15-relay-pill.test.tsx | 70 +++++-------- .../t28-connection-relay-controls.test.tsx | 31 ++++-- src/shared/app/ipc.compat.test-d.ts | 4 + src/shared/dcs/ipc.ts | 2 - 16 files changed, 160 insertions(+), 309 deletions(-) diff --git a/src/main/app/app-services.test.ts b/src/main/app/app-services.test.ts index 426b8db..f1f23c7 100644 --- a/src/main/app/app-services.test.ts +++ b/src/main/app/app-services.test.ts @@ -570,13 +570,20 @@ describe('device link is independent of the relay (#69)', () => { session.shutdown() }) - it('refuses Start until the board is connected when the mode delivers to it', () => { + // #159: it used to be refused ('Gateway is not connected'), the pill silently flipping back. + it('starts with no board when the mode delivers to it, and waits for the board', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) - expect(session.relay.start()).toEqual({ ok: false, error: 'Gateway is not connected' }) - expect(session.status().running).toBe(false) - expect(FakeTransport.last, 'no DCS transport is opened').toBeUndefined() + expect(session.relay.start()).toEqual({ ok: true }) + expect(session.status().running).toBe(true) + expect(FakeTransport.last, 'the DCS transport is opened').toBeDefined() + expect(session.relay.facts().running.waiting, 'waiting for the Gateway').toBe('device') + // The board arrives: delivery begins with no further Start. serial.openCb('/dev/fake') - expect(session.relay.start().ok).toBe(true) + expect(session.relay.facts().running.waiting).not.toBe('device') + serial.written.length = 0 + FakeTransport.last!.exportCb(Buffer.from([0x55, 0x55])) + expect(Buffer.concat(serial.written)).toEqual(Buffer.from([0x55, 0x55])) + expect(session.status().running).toBe(true) session.shutdown() }) @@ -584,21 +591,21 @@ describe('device link is independent of the relay (#69)', () => { const { session, serial } = launched({ sourceMode: 'bridge' }) serial.openCb('/dev/fake') session.connection.disconnect() - expect(session.relay.start().ok).toBe(false) + expect(session.relay.start().ok, 'it starts, and waits for the board').toBe(true) expect(FakeSerial.last, 'Start leaves the link alone').toBe(serial) expect(session.status().device.state).toBe('disconnected') session.shutdown() }) - it('lets Monitor and an undriven Replay start with no board', () => { + it('lets Monitor start with no board; Replay is refused only for having no capture', () => { const monitor = launched({ sourceMode: 'monitor' }) expect(monitor.session.relay.start().ok).toBe(true) monitor.session.shutdown() + // Replay, driven or not, is only refused for having no capture, never for the board (#159). const driven = launched({ sourceMode: 'replay', replayDriveSerial: true }) - expect(driven.session.relay.start()).toEqual({ ok: false, error: 'Gateway is not connected' }) + expect(driven.session.relay.start()).toEqual({ ok: false, error: 'No capture loaded' }) driven.session.shutdown() - // Undriven Replay is only refused for having no capture, never for the board. const plain = launched({ sourceMode: 'replay', replayDriveSerial: false }) expect(plain.session.relay.start()).toEqual({ ok: false, error: 'No capture loaded' }) plain.session.shutdown() diff --git a/src/main/app/app-services.ts b/src/main/app/app-services.ts index b35c781..e9d4443 100644 --- a/src/main/app/app-services.ts +++ b/src/main/app/app-services.ts @@ -111,7 +111,7 @@ export class AppServices { // The DCS side keeps its order from before the split too (R2.3a). None of its constructors has // a side effect. // open the stream log's flush (33) first, then the connection's start - // relay Start Bridge / driven Replay refused unless the link is up → a running relay + // relay Start never gated on the link (#159) → a running relay // stopped → decoder, parser, stats reset → DCS error forgotten → Replay: // `connected`, playback; else the transport built and subscribed → // `connecting` → transport start; then the telemetry (200) and stats @@ -167,7 +167,7 @@ export class AppServices { // The Cockpit snapshot follows the link too (strays and Attention only while it is up), after // `device:status` has gone out, as the Cockpit re-rendered on it (R3.2). this.connection.onStatus(() => this.nodes.linkStatus()) - // So do the relay's facts (blocked, waiting for the Gateway, the Link tile), R3.3. + // So do the relay's facts (waiting for the Gateway, the Link tile), R3.3. this.connection.onStatus(() => this.relay.inputsChanged()) // A settings change, in one listener so its order is explicit: auto-connect, then the relay. this.settings.store.subscribe((config, prev) => { diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index f291df3..86eccd8 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -54,25 +54,62 @@ describe('device:status carries the link’s facts', () => { }) describe('relay:facts: what the relay’s surfaces show', () => { - it('Bridge with no board: Start blocked; the board arriving lifts it', () => { + // #159: Start with no board used to be refused ('Gateway is not connected'): the pill flipped + // back to Stopped with no word why. Now it starts and waits, and the board arriving delivers. + it('Bridge with no board: Start starts and waits for the board; the board arriving delivers', () => { const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) - expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBe('bridge') + expect(app.lastPush('relay:facts')?.stopped).not.toHaveProperty('startBlocked') + expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true }) + expect(app.lastPush('relay:state')).toEqual({ running: true, stream: 'connecting' }) + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + waiting: 'device', + tip: 'waiting-device', + overviewLink: 'waiting' + }) + app.transport.connected(true) + // The board arrives: no further click, the relay delivers to it. app.serial.open() - expect(app.lastPush('relay:facts')?.stopped.startBlocked).toBeUndefined() - expect(app.lastPush('relay:facts')?.running.waiting).toBe('dcs') + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + waiting: 'none', + tip: 'delivering' + }) + app.serial.clearWritten() + app.transport.export('UUUU') + expect(app.serial.writtenText()).toBe('UUUU') + expect(app.lastPush('relay:state')?.running, 'still the one relay').toBe(true) + }) + + it('a driven Replay with no board: Start plays and waits for the board (#159)', async () => { + const app = makeServices({ + paths: { + files: { + 'config.json': { sourceMode: 'replay', replayDriveSerial: true }, + 'flight.skyhawk-capture': serializeCapture([{ t: 0, dir: 'in', hex: '55555555' }]) + } + } + }) + app.dialogs.openCapture('flight.skyhawk-capture') + await app.invoke(CTRL.replayOpen) + expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true }) + expect(app.lastPush('relay:facts')?.running).toMatchObject({ + waiting: 'device', + tip: 'waiting-device', + overviewLink: 'waiting' + }) + app.serial.open() + expect(app.lastPush('relay:facts')?.running.tip).toBe('replay-playing') }) it('toggle: Start saves the settings first; a refused Start pushes no relay state', () => { - const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'bridge' } } } }) + // Replay with no capture loaded: the one Start main still refuses. + const app = makeServices({ paths: { files: { 'config.json': { sourceMode: 'replay' } } } }) + app.serial.open() const states = app.pushesOf('relay:state').length - expect(app.invoke(CTRL.relayToggle, false)).toEqual({ - ok: false, - error: 'Gateway is not connected' - }) + expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: false, error: 'No capture loaded' }) expect(app.pushesOf('relay:state')).toHaveLength(states) - expect(app.files.json('config.json')).toMatchObject({ sourceMode: 'bridge', commandPort: 7778 }) + expect(app.files.json('config.json')).toMatchObject({ sourceMode: 'replay', commandPort: 7778 }) expect(app.lastPush('config:changed')?.source).toBe('edit') - app.serial.open() + app.invoke(CTRL.configEdit, { sourceMode: 'bridge' }) expect(app.invoke(CTRL.relayToggle, false)).toEqual({ ok: true }) expect(app.lastPush('relay:state')).toEqual({ running: true, stream: 'connecting' }) expect(app.invoke(CTRL.relayToggle, true)).toEqual({ ok: true }) diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index d6a6b18..bc14f92 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -125,22 +125,18 @@ export class DcsRelay { /** * Start relaying. Attaches the stream to the device link; never opens or closes the port. * - * Refused while a mode that delivers to the board has no board to deliver to: nothing can be - * relayed before the link is up. Once running, a drop does not end the relay — it waits, and - * resumes when the link returns. + * Never gated on the board (#159): a mode that delivers to it starts with no board and waits, + * as it does through a drop (relay-rules waitingForDevice), and delivers once the link is up. */ start(): RelayResult { - if (exportToDevice(this.config) && !isLinkUp(this.device.deviceStatus().state)) { - return { ok: false, error: 'Gateway is not connected' } - } return this.run() } /** A settings change: a running relay is rebuilt if a setting it is built from changed. */ reconfigure(prev: RelayOptions, next: RelayOptions): void { if (this.running && relayConfigChanged(prev, next)) { - // A rebuild of a relay already running, not a new Start: it is not gated on the device, - // or editing a setting during a drop would end a relay that only Stop may end. + // A rebuild of a relay already running: only Stop may end it, so a rebuild during a drop + // keeps it running, waiting for the board. this.stop() this.run() } diff --git a/src/main/dcs/relay-rules.test.ts b/src/main/dcs/relay-rules.test.ts index cb9832a..c530e61 100644 --- a/src/main/dcs/relay-rules.test.ts +++ b/src/main/dcs/relay-rules.test.ts @@ -34,13 +34,18 @@ describe('relayFacts', () => { it('each side of the on/off: a pending Start reads as running, with the stream not up yet', () => { const f = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'bridge' } }) - expect(f.stopped).toMatchObject({ tip: 'stopped', startBlocked: 'bridge', waiting: 'none' }) + // #159: Start is never blocked for want of the board — the Start it shows waits for it. + expect(f.stopped).toEqual({ + tip: 'stopped', + waiting: 'none', + overviewLink: 'stopped', + canRecord: false + }) expect(f.running).toMatchObject({ tip: 'waiting-device', waiting: 'device' }) - expect(f.running.startBlocked, 'a running relay is never blocked').toBeUndefined() const replay = facts({ config: { ...DEFAULT_CONFIG, sourceMode: 'replay', replayDriveSerial: true } }) - expect(replay.stopped.startBlocked).toBe('replay') + expect(replay.stopped).not.toHaveProperty('startBlocked') 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') diff --git a/src/main/dcs/relay-rules.ts b/src/main/dcs/relay-rules.ts index 1698eb3..013da17 100644 --- a/src/main/dcs/relay-rules.ts +++ b/src/main/dcs/relay-rules.ts @@ -31,7 +31,8 @@ export type RelayOptions = Pick type ForwardConfig = Pick /** - * Whether the export stream is written to the SimGateway — and so whether Start needs the board. + * Whether the export stream is written to the SimGateway — and so whether a running relay waits + * for the board (#159: Start never needs it; with no board the relay runs and waits). * * Bridge always; Replay only with "Drive SimGateway"; Monitor never. Monitor has to say so * explicitly now that the port is open in every mode — before #69 it was read-only only because @@ -103,23 +104,6 @@ export function relayNote(opts: RelayOpts): RelayWaiting { return 'none' } -/** - * Start is refused while a mode that delivers to the board has no board to deliver to: Bridge, and - * Replay driving the SimGateway. Monitor and an undriven Replay never touch it. The same rule as - * DcsRelay.start(), which is the one that actually enforces it. - */ -export function startBlocked(opts: { - mode: SourceMode - replayDriveSerial: boolean - deviceState: DeviceState -}): boolean { - 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. @@ -224,17 +208,9 @@ function surfaces(i: RelayInputs, relaying: boolean): RelaySurfaces { stream: i.stream, deviceState: i.deviceState } - const blocked = - !relaying && - startBlocked({ - mode: i.config.sourceMode, - replayDriveSerial: i.config.replayDriveSerial, - deviceState: i.deviceState - }) return { waiting: relayNote(o), tip: relayTip(o), - ...(blocked ? { startBlocked: i.config.sourceMode === 'replay' ? 'replay' : 'bridge' } : {}), overviewLink: overviewLink(o, i.linkUp), canRecord: canRecord(relaying, i.recording.on) } diff --git a/src/renderer/src/dcs/RelayControls.tsx b/src/renderer/src/dcs/RelayControls.tsx index c730b65..006a916 100644 --- a/src/renderer/src/dcs/RelayControls.tsx +++ b/src/renderer/src/dcs/RelayControls.tsx @@ -1,5 +1,5 @@ import { useDcsStore } from './store' -import { relayNote, relaySide, startBlockedReason } from './relay-view' +import { relayNote, relaySide } from './relay-view' // The Connection tab's relay controls (R3.4b: cut from Connection.tsx). The tab puts its settings // cards (source mode, DCS link) between them, so each is its own component. @@ -112,28 +112,24 @@ export function RecordCard() { ) } -/** Start / Stop relay, and what a running relay is waiting for or why Start is blocked. */ +/** Start / Stop relay, and what a running relay is waiting for. Start always starts (#159). */ export function RelayStart() { const facts = useDcsStore((x) => x.relayFacts) const relaying = useDcsStore((x) => x.relaying) const toggleRelay = useDcsStore((x) => x.toggleRelay) // The relay controls as main decides them (relay:facts, R3.3), for the on/off as shown. const side = relaySide(facts, relaying) - const blocked = side.startBlocked - const note = blocked - ? { text: startBlockedReason(blocked), info: true } - : relayNote(side.waiting, facts.stream) + const note = relayNote(side.waiting, facts.stream) return (
- {note &&
{note.text}
} + {note &&
{note}
}
) } diff --git a/src/renderer/src/dcs/RelayPill.tsx b/src/renderer/src/dcs/RelayPill.tsx index e385fde..2fa45d0 100644 --- a/src/renderer/src/dcs/RelayPill.tsx +++ b/src/renderer/src/dcs/RelayPill.tsx @@ -1,107 +1,39 @@ -import { useEffect, useRef, useState } from 'react' -import { deviceView, useGatewayStore } from '../gateway' +import { deviceView } from '../gateway' import { useDcsStore } from './store' -import { relaySide, relayTip, startBlockedCallout, startBlockedReason } from './relay-view' - -/** How long the blocked-Start callout stays up on its own. */ -const CALLOUT_MS = 6000 +import { relaySide, relayTip } from './relay-view' /** * The title-bar Relay pill (#69): the one on/off for the DCS-BIOS stream. * - * Green while delivering end to end, gold while running but waiting, grey when stopped. When Start - * would be refused — no board for a mode that needs one — it says so at a glance with a "!", and a - * click answers with a callout under the pill: why, and the Connect that fixes it. What each of - * those is, main decides (`relay:facts`, R3.3), the endpoint included (X9); the pill words it. - * `autoConnect` is Settings' (the title bar hands it in). + * Green while delivering end to end, gold while running but waiting, grey when stopped. Start + * always starts (#159): with no board for a mode that needs one, it runs gold and its tooltip says + * it waits for the Gateway. What each of those is, main decides (`relay:facts`, R3.3), the + * endpoint included (X9); the pill words it. `autoConnect` is Settings' (the title bar hands it in). */ export function RelayPill({ autoConnect }: { autoConnect: boolean }) { const relaying = useDcsStore((s) => s.relaying) const facts = useDcsStore((s) => s.relayFacts) const toggleRelay = useDcsStore((s) => s.toggleRelay) - const connectDevice = useGatewayStore((s) => s.connectDevice) - const [callout, setCallout] = useState(false) - const btn = useRef(null) // The side of the on/off the pill shows: a click's pending Start or Stop reads at once. const side = relaySide(facts, relaying) - const blocked = side.startBlocked const waiting = side.waiting !== 'none' - const title = blocked - ? startBlockedReason(blocked) - : relayTip({ - tip: side.tip, - stream: facts.stream, - deviceLabel: deviceView(facts.deviceState, { autoConnect }).label, - endpoint: facts.endpoint, - replayFile: facts.replay?.file - }) - - // The board arriving answers the callout; so does it timing out. - const open = callout && !!blocked - useEffect(() => { - if (!open) return - const timer = setTimeout(() => setCallout(false), CALLOUT_MS) - const onKey = (e: KeyboardEvent): void => { - if (e.key !== 'Escape') return - setCallout(false) - btn.current?.focus() - } - window.addEventListener('keydown', onKey) - return () => { - clearTimeout(timer) - window.removeEventListener('keydown', onKey) - } - }, [open]) + const title = relayTip({ + tip: side.tip, + stream: facts.stream, + deviceLabel: deviceView(facts.deviceState, { autoConnect }).label, + endpoint: facts.endpoint, + replayFile: facts.replay?.file + }) - const cls = relaying ? `pill--on${waiting ? ' pill--wait' : ''}` : blocked ? 'pill--blocked' : '' + const cls = relaying ? `pill--on${waiting ? ' pill--wait' : ''}` : '' return ( - - {open && ( - <> -
setCallout(false)} /> -
-
- - Can't start the relay -
-
{startBlockedCallout(blocked!)}
-
- - then press Start -
-
- - )} ) } diff --git a/src/renderer/src/dcs/relay-view.ts b/src/renderer/src/dcs/relay-view.ts index 86ad0be..bc4559f 100644 --- a/src/renderer/src/dcs/relay-view.ts +++ b/src/renderer/src/dcs/relay-view.ts @@ -1,5 +1,5 @@ // The relay's surfaces in words (R3.4b: the relay half of the old device view): the pill's tooltip, -// the note under Start, why Start is blocked. What each says is main's (relay:facts, relay-rules). +// the note under Start. What each says is main's (relay:facts, relay-rules). import type { RelayFacts, RelaySurfaces, RelayTip, RelayWaiting, StreamState } from '@shared/dcs' /** The relay's surfaces for the on/off as shown — the pending Start or Stop included (R3.3). */ @@ -43,38 +43,9 @@ export function relayTip(opts: { * The line under the Connection tab's Start / Stop button: what a running relay is waiting for, * as main judged it (relay-rules relayNote). */ -export function relayNote( - waiting: RelayWaiting, - stream?: StreamState -): { text: string; info: boolean } | undefined { - if (waiting === 'device') { - return { text: 'Relaying — waiting for the Gateway. Resumes when it connects.', info: false } - } - if (waiting === 'dcs') { - return { - text: `Relaying — waiting for DCS-BIOS (${stream ?? 'connecting'}).`, - info: false - } - } - if (waiting === 'replay-ended') { - return { text: 'Replay ended — Stop, or load another capture.', info: false } - } +export function relayNote(waiting: RelayWaiting, stream?: StreamState): string | undefined { + if (waiting === 'device') return 'Relaying — waiting for the Gateway. Resumes when it connects.' + if (waiting === 'dcs') return `Relaying — waiting for DCS-BIOS (${stream ?? 'connecting'}).` + if (waiting === 'replay-ended') return 'Replay ended — Stop, or load another capture.' return undefined } - -/** - * Why Start is unavailable, for the pill tooltip and the note under the Start button: by the - * mode main says needs the board (relay-rules startBlocked). - */ -export function startBlockedReason(reason: 'bridge' | 'replay'): string { - return reason === 'replay' - ? 'Connect the Gateway to play the capture to it, or turn Drive Gateway off.' - : 'Connect the Gateway to start the relay.' -} - -/** Body of the callout a blocked Relay pill answers a click with. */ -export function startBlockedCallout(reason: 'bridge' | 'replay'): string { - return reason === 'replay' - ? "The Gateway isn't connected, and Drive Gateway plays the capture to it. Connect first, or turn Drive off." - : "The Gateway isn't connected, and the relay delivers to it. Connect first." -} diff --git a/src/renderer/src/dcs/store.ts b/src/renderer/src/dcs/store.ts index e379b5f..7022051 100644 --- a/src/renderer/src/dcs/store.ts +++ b/src/renderer/src/dcs/store.ts @@ -106,8 +106,8 @@ export const useDcsStore = create((set, get) => ({ void api() ?.toggleRelay(s.relaying) .then((r) => { - // Main refuses a Start it cannot honour (no board for a mode that needs one) and pushes - // no relay:state for it, so the pending Relaying has to be taken back here. + // Main refuses a Start it cannot honour (a Replay with no capture loaded) and pushes no + // relay:state for it, so the pending Relaying has to be taken back here. if (!s.relaying && r && !r.ok) set({ relaying: false }) }) set({ relaying: !s.relaying }) diff --git a/src/renderer/src/gateway/link-view.test.ts b/src/renderer/src/gateway/link-view.test.ts index 053d06c..77e7479 100644 --- a/src/renderer/src/gateway/link-view.test.ts +++ b/src/renderer/src/gateway/link-view.test.ts @@ -1,19 +1,9 @@ import { describe, expect, it } from 'vitest' import type { DeviceState } from '@shared/gateway' import { isBusy, linkAction } from '../../../main/gateway/link-state' -import { - relayNote as noteOf, - relayTip as tipOf, - relayWaiting, - startBlocked -} from '../../../main/dcs/relay-rules' +import { relayNote as noteOf, relayTip as tipOf, relayWaiting } from '../../../main/dcs/relay-rules' import { deviceView as viewOf, linkButton } from './link-view' -import { - relayNote as noteText, - relayTip as tipText, - startBlockedCallout, - startBlockedReason -} from '../dcs/relay-view' +import { relayNote as noteText, relayTip as tipText } from '../dcs/relay-view' import type { SourceMode } from '../config' import type { StreamState } from '@shared/dcs' @@ -199,43 +189,10 @@ describe('relayNote', () => { }) it('says what a running relay waits for', () => { - expect(relayNote({ ...run, deviceState: 'no-device' })).toEqual({ - text: 'Relaying — waiting for the Gateway. Resumes when it connects.', - info: false - }) - expect(relayNote({ ...run, stream: 'error' })?.text).toBe( - 'Relaying — waiting for DCS-BIOS (error).' - ) - expect(relayNote({ ...run, mode: 'replay', stream: 'ended' })?.text).toMatch(/Replay ended/) - }) -}) - -describe('startBlocked', () => { - const at = ( - mode: 'bridge' | 'monitor' | 'replay', - deviceState: DeviceState, - replayDriveSerial = false - ) => startBlocked({ mode, deviceState, replayDriveSerial }) - - it('needs the board in Bridge, and in Replay only when driving it', () => { - for (const s of STATES.filter((x) => x !== 'connected')) { - expect(at('bridge', s), s).toBe(true) - expect(at('replay', s, true), s).toBe(true) - expect(at('replay', s, false), s).toBe(false) - expect(at('monitor', s), s).toBe(false) - } - expect(at('bridge', 'connected')).toBe(false) - expect(at('replay', 'connected', true)).toBe(false) - }) -}) - -describe('startBlockedReason', () => { - it('tells Replay it can also run undriven', () => { - expect(startBlockedReason('bridge')).toBe('Connect the Gateway to start the relay.') - expect(startBlockedReason('replay')).toMatch(/turn Drive Gateway off/) - expect(startBlockedCallout('bridge')).toBe( - "The Gateway isn't connected, and the relay delivers to it. Connect first." + expect(relayNote({ ...run, deviceState: 'no-device' })).toBe( + 'Relaying — waiting for the Gateway. Resumes when it connects.' ) - expect(startBlockedCallout('replay')).toMatch(/Connect first, or turn Drive off/) + expect(relayNote({ ...run, stream: 'error' })).toBe('Relaying — waiting for DCS-BIOS (error).') + expect(relayNote({ ...run, mode: 'replay', stream: 'ended' })).toMatch(/Replay ended/) }) }) diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index d42e398..309e08d 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -177,14 +177,6 @@ body { .pill--on .pill__txt { color: var(--green); } -/* Stopped, and Start would be refused: no board for a mode that needs one (#69). A grey "!" - replaces the dot — grey because nothing failed. Still a normal pointer: it answers a click. */ -.pill--blocked { - opacity: 0.85; -} -.pill--blocked .pill__dot { - display: none; -} .pill__sym { width: 13px; height: 13px; @@ -196,10 +188,6 @@ body { color: #04101f; background: var(--muted-2); } -.devpop__hint { - font-size: 11px; - color: var(--muted-3); -} /* Running but not delivering end to end — still "Relaying", gold (#69). */ .pill--wait { background: rgba(244, 179, 33, 0.08); @@ -222,9 +210,6 @@ body { color: var(--gold); text-align: center; } -.waitnote--info { - color: var(--muted-3); -} /* ── device link indicator (#69): the automatic SimGateway link, separate from the relay pill ── */ button.devind { diff --git a/src/renderer/test/t15-relay-pill.test.tsx b/src/renderer/test/t15-relay-pill.test.tsx index 6d43828..3ce8bb6 100644 --- a/src/renderer/test/t15-relay-pill.test.tsx +++ b/src/renderer/test/t15-relay-pill.test.tsx @@ -1,19 +1,18 @@ // T15 — the title-bar Relay pill: the one on/off for the DCS-BIOS stream. import { describe, expect, it } from 'vitest' -import { screen, within } from '@testing-library/react' +import { screen } from '@testing-library/react' import { RelayPill } from '../src/app/titlebar/RelayPill' import { renderScenario, settle } from './render' import { connected, link } from './scenarios' -/** The pill: the only button until the blocked callout opens. */ -const pill = () => screen.getByRole('button', { name: /^!?(Stopped|Relaying)$/ }) +/** The pill: the only button. */ +const pill = () => screen.getByRole('button', { name: /^(Stopped|Relaying)$/ }) describe('T15 RelayPill (B45, B47)', () => { it('stopped: "Stopped", and a click asks main to start', async () => { const { sky, user } = await renderScenario(, { device: connected() }) expect(pill()).toHaveTextContent(/^Stopped$/) expect(pill()).toHaveAttribute('title', 'DCS-BIOS stream stopped — click to start') - expect(pill()).toHaveAttribute('aria-disabled', 'false') await user.click(pill()) await settle() // Settings are saved first, then the relay starts. @@ -106,60 +105,35 @@ describe('T15 RelayPill (B45, B47)', () => { ) }) - it('Bridge without a board: blocked with "!", and a click explains instead of starting', async () => { + // #159: it used to show a "!" and answer a click with a "Can't start" callout, never starting. + it.each([ + ['Bridge', { sourceMode: 'bridge' as const }], + ['a driven Replay', { sourceMode: 'replay' as const, replayDriveSerial: true }] + ])('%s without a board: a click starts, and the pill waits for the Gateway', async (_, cfg) => { const { sky, user } = await renderScenario(, { - config: { sourceMode: 'bridge' }, + config: cfg, device: link('no-device') }) - expect(pill()).toHaveTextContent(/^!Stopped$/) - expect(pill()).toHaveAttribute('aria-disabled', 'true') - expect(pill()).toHaveAttribute('title', 'Connect the Gateway to start the relay.') - expect(screen.queryByRole('alertdialog')).toBeNull() - - await user.click(pill()) - const callout = screen.getByRole('alertdialog', { name: "Can't start the relay" }) - expect( - within(callout).getByText( - "The Gateway isn't connected, and the relay delivers to it. Connect first." - ) - ).toBeInTheDocument() - expect(sky.startRelay).not.toHaveBeenCalled() - - // Connect only connects: Start stays a second step. - await user.click(within(callout).getByRole('button', { name: 'Connect' })) - expect(sky.connectDevice).toHaveBeenCalledTimes(1) - expect(sky.startRelay).not.toHaveBeenCalled() - expect(screen.queryByRole('alertdialog')).toBeNull() - }) - - it('the board arriving lifts the block and closes the callout', async () => { - const { sky, user } = await renderScenario(, { - config: { sourceMode: 'bridge' }, - device: link('no-device') - }) - await user.click(pill()) - expect(screen.getByRole('alertdialog')).toBeInTheDocument() - sky.emitScenario({ device: connected() }) - expect(screen.queryByRole('alertdialog')).toBeNull() expect(pill()).toHaveTextContent(/^Stopped$/) - expect(pill()).toHaveAttribute('aria-disabled', 'false') - }) - - it('Esc closes the callout and focus returns to the pill', async () => { - const { user } = await renderScenario(, { - config: { sourceMode: 'bridge' }, - device: link('no-device') - }) await user.click(pill()) - expect(screen.getByRole('alertdialog')).toBeInTheDocument() - await user.keyboard('{Escape}') + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) expect(screen.queryByRole('alertdialog')).toBeNull() - expect(pill()).toHaveFocus() + expect(pill()).toHaveTextContent(/^Relaying$/) + expect(pill()).toHaveClass('pill--wait') + expect(pill()).toHaveAttribute( + 'title', + 'Relaying — waiting for the Gateway (device link no device). Resumes when it connects — click to stop' + ) + // The board arrives: no further click, the wait for it is over. + sky.emitScenario({ device: connected() }) + expect(pill()).toHaveTextContent(/^Relaying$/) + expect(pill().getAttribute('title')).not.toMatch(/waiting for the Gateway/) + expect(sky.startRelay).toHaveBeenCalledTimes(1) }) it('Monitor starts without a board', async () => { const { sky, user } = await renderScenario(, { device: link('no-device') }) - expect(pill()).toHaveAttribute('aria-disabled', 'false') await user.click(pill()) await settle() expect(sky.startRelay).toHaveBeenCalledTimes(1) diff --git a/src/renderer/test/t28-connection-relay-controls.test.tsx b/src/renderer/test/t28-connection-relay-controls.test.tsx index f81db75..f15f454 100644 --- a/src/renderer/test/t28-connection-relay-controls.test.tsx +++ b/src/renderer/test/t28-connection-relay-controls.test.tsx @@ -123,7 +123,8 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { ) }) - it('Replay: the Drive Gateway toggle saves, and without a board it blocks Start', async () => { + // #159: without a board, Drive Gateway used to grey Start out ("Connect the Gateway…"). + it('Replay: the Drive Gateway toggle saves, and without a board Start still starts', async () => { const { sky, user } = await renderScenario(, { device: link('no-device'), config: { sourceMode: 'replay' } @@ -134,10 +135,13 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { await user.click(drive) expect(drive).toHaveAttribute('aria-pressed', 'true') expect(lastSaved(sky)).toMatchObject({ replayDriveSerial: true }) - expect(startBtn()).toBeDisabled() - expect(waitnote()).toBe( - 'Connect the Gateway to play the capture to it, or turn Drive Gateway off.' - ) + expect(startBtn()).toBeEnabled() + expect(waitnote()).toBeNull() + await user.click(startBtn()) + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) + expect(startBtn()).toHaveTextContent('Stop relay') + expect(waitnote()).toBe('Relaying — waiting for the Gateway. Resumes when it connects.') }) it('DCS link: host and port are saved on commit (blur / Enter), transport as picked', async () => { @@ -215,14 +219,23 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { ).toBeInTheDocument() }) - it('Bridge without a board: Start is greyed out, with the reason under it', async () => { - await renderScenario(, { + // #159: it used to be greyed out, with "Connect the Gateway to start the relay." under it. + it('Bridge without a board: Start starts, and the note says it waits for the Gateway', async () => { + const { sky, user } = await renderScenario(, { device: link('no-device'), config: { sourceMode: 'bridge' } }) expect(startBtn()).toHaveTextContent('Start relay') - expect(startBtn()).toBeDisabled() - expect(waitnote()).toBe('Connect the Gateway to start the relay.') + expect(startBtn()).toBeEnabled() + expect(waitnote()).toBeNull() + await user.click(startBtn()) + await settle() + expect(sky.startRelay).toHaveBeenCalledTimes(1) + expect(startBtn()).toHaveTextContent('Stop relay') + expect(waitnote()).toBe('Relaying — waiting for the Gateway. Resumes when it connects.') + // The board arrives: no further click; now it waits only for DCS-BIOS. + sky.emitScenario({ device: connected() }) + expect(waitnote()).toBe('Relaying — waiting for DCS-BIOS (connecting).') }) it('Monitor needs no board: Start asks main; relaying, the button stops it', async () => { diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 41a2e35..0fe9023 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -121,6 +121,10 @@ // `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. // +// Fix track #159 (Start with no Gateway waits for it): a type change only — no channel, intent or +// method was added, removed or renamed. `RelaySurfaces.startBlocked` was removed: Start is never +// refused for want of the board; the relay runs and waits for it (`waiting: 'device'`). +// // 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 diff --git a/src/shared/dcs/ipc.ts b/src/shared/dcs/ipc.ts index e572b48..5453b97 100644 --- a/src/shared/dcs/ipc.ts +++ b/src/shared/dcs/ipc.ts @@ -67,8 +67,6 @@ export type OverviewLink = export interface RelaySurfaces { waiting: RelayWaiting tip: RelayTip - /** Start would be refused, by the mode that needs the board for it; absent = Start may go. */ - startBlocked?: 'bridge' | 'replay' overviewLink: OverviewLink /** * May the Record control be used, on every surface that offers it (X12): a recording starts only From b5714c9b45231b782fa27401f8bb412e1fc46ab4 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 22:26:53 -0400 Subject: [PATCH 4/4] fix(log): the stream keeps rows that arrive while paused and shows them on resume (#166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Log › Pause held the Events view (rows recorded, shown on Resume), but the Stream view dropped every `log:batch` that arrived while paused, so that data was lost. Now the stream holds those rows while paused and shows them on Resume, within its existing 1000-row cap (the oldest fall off, as they do live). The paused list stays frozen in the meantime. ## Changes - `src/renderer/src/dcs/store.ts` - `logBatch` no longer returns early while paused. It numbers the rows as before and puts them in a new `private_held` list (newest first, capped at `MAX_LOG`) instead of `log`. - A subscription on the events store's pause adds `private_held` on top of `log` when the pause turns off (Resume), capped at `MAX_LOG`. - `clearLog` empties `private_held` too. - The pause stays owned by `src/renderer/src/events/store.ts` (`logPaused` / `togglePause`, unchanged). Only its header comment was updated to say the stream also reads it on Resume. - `Log.tsx` is unchanged: no layout or styling change. ## Tests `src/renderer/test/t23-log-stream.test.tsx`: - Updated (it pinned the old behaviour): "Pause freezes the list: rows that arrive while paused are dropped, not held (today)" became "Pause freezes the list and holds what arrives; Resume shows it". - New: "rows held while paused keep the list's cap: the oldest fall off as they do live". - New: "Clear while paused also drops what was held". All three fail on the old code. Closes #161 --- src/renderer/src/dcs/store.ts | 30 +++++++++++++++-- src/renderer/src/events/store.ts | 5 +-- src/renderer/test/t23-log-stream.test.tsx | 41 ++++++++++++++++++++--- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/dcs/store.ts b/src/renderer/src/dcs/store.ts index 7022051..20295e5 100644 --- a/src/renderer/src/dcs/store.ts +++ b/src/renderer/src/dcs/store.ts @@ -49,6 +49,11 @@ export interface DcsState { lastCmd: string log: StreamLine[] + /** + * Rows that arrived while the Log tab is paused (#161), newest first, capped like `log`: held + * out of the frozen list and added to it on Resume. + */ + private_held: StreamLine[] private_logSeq: number set: (patch: Partial) => void @@ -91,6 +96,7 @@ export const useDcsStore = create((set, get) => ({ lastCmd: '—', log: [], + private_held: [], private_logSeq: 1000, set: (patch) => set(patch), @@ -122,7 +128,7 @@ export const useDcsStore = create((set, get) => ({ void api()?.openReplay() }, - clearLog: () => set({ log: [] }), + clearLog: () => set({ log: [], private_held: [] }), exportLog: async () => { const rows = get().log @@ -178,10 +184,12 @@ export const dcsFromMain = { lastCmd: st.lastCommand ?? '—' }), - /** `log:batch`: dropped while the Log tab is paused (the pause is the events slice's). */ + /** + * `log:batch`. While the Log tab is paused (the pause is the events slice's) the list holds still + * and the rows are held instead, shown on Resume (#161). + */ logBatch: (rows: StreamRow[]): void => { const s = useDcsStore.getState() - if (useEventsStore.getState().logPaused) return let seq = s.private_logSeq const mapped: StreamLine[] = rows.map((r: StreamRow) => ({ id: seq++, @@ -193,9 +201,25 @@ export const dcsFromMain = { raw: r.raw ?? rawHex(r.address, r.value) })) // newest first, capped + if (useEventsStore.getState().logPaused) { + useDcsStore.setState({ + private_held: [...mapped.reverse(), ...s.private_held].slice(0, MAX_LOG), + private_logSeq: seq + }) + return + } useDcsStore.setState({ log: [...mapped.reverse(), ...s.log].slice(0, MAX_LOG), private_logSeq: seq }) } } + +// Resume (the events slice's pause turning off) shows the rows held while paused, newest first +// over the list, within its cap: the oldest fall off as they do while live (#161). +useEventsStore.subscribe((ev, prev) => { + if (!prev.logPaused || ev.logPaused) return + const s = useDcsStore.getState() + if (s.private_held.length === 0) return + useDcsStore.setState({ log: [...s.private_held, ...s.log].slice(0, MAX_LOG), private_held: [] }) +}) diff --git a/src/renderer/src/events/store.ts b/src/renderer/src/events/store.ts index a407545..2df2085 100644 --- a/src/renderer/src/events/store.ts +++ b/src/renderer/src/events/store.ts @@ -1,7 +1,8 @@ // The session's events as main pushes them, and the Log tab's pause (R3.4b: the events slice of // the old single store, cut verbatim). The pause lives here because it sets the Events view's -// pause mark in the same update and Clear reads it; the stream's `log:batch` reads it from here -// (dcs → events). The IPC adapter binds the intents and delivers the pushes. +// pause mark in the same update and Clear reads it; the stream reads it from here (dcs → events): +// `log:batch` holds its rows while paused, and Resume shows them. The IPC adapter binds the +// intents and delivers the pushes. import { create } from 'zustand' import type { SkyhawkApi } from '@shared/app' import type { LogEvent } from '@shared/events' diff --git a/src/renderer/test/t23-log-stream.test.tsx b/src/renderer/test/t23-log-stream.test.tsx index 744c2c6..90bb9bd 100644 --- a/src/renderer/test/t23-log-stream.test.tsx +++ b/src/renderer/test/t23-log-stream.test.tsx @@ -72,20 +72,51 @@ describe('T23 Log › Stream (B77)', () => { ]) }) - it('Pause freezes the list: rows that arrive while paused are dropped, not held (today)', async () => { + // #161: rows that arrived while paused used to be dropped, never shown. + it('Pause freezes the list and holds what arrives; Resume shows it', async () => { const { user, sky, button } = await openStream() await user.click(button('❚❚ Pause')) expect(screen.getByText('PAUSED')).toBeInTheDocument() sky.emitScenario({ stream: [streamRow(0x1a4, { t: T(4), name: 'FLAPS', value: 2 })] }) - expect(rows()).toHaveLength(3) + sky.emitScenario({ stream: [streamRow(0x1a6, { t: T(5), name: 'HOOK', value: 1 })] }) + expect(rows(), 'the list holds still while paused').toHaveLength(3) await user.click(button('▶ Resume')) expect(screen.getByText('LIVE')).toBeInTheDocument() - // contract B77 lists Pause; today the renderer drops a stream batch that lands while paused - // (Log › Events holds its rows instead), so the paused row never shows. + expect(rows()).toEqual([ + '12:00:05.000IN ▸HOOK1', + '12:00:04.000IN ▸FLAPS2', + '12:00:03.000◂ OUTGUN_ARMTOGGLE', + '12:00:02.000IN ▸ARC51_FREQ251.00', + '12:00:01.000IN ▸MASTER_ARM1' + ]) + sky.emitScenario({ stream: [streamRow(0x1a8, { t: T(6), name: 'GEAR', value: 0 })] }) + expect(rows()[0]).toBe('12:00:06.000IN ▸GEAR0') + }) + + it('rows held while paused keep the list’s cap: the oldest fall off as they do live', async () => { + const { user, sky, button } = await openStream() + await user.click(button('❚❚ Pause')) + const many = Array.from({ length: 1000 }, (_, i) => + streamRow(0x1a4, { t: T(4) + i, name: `HELD_${i}`, value: i }) + ) + sky.emitScenario({ stream: many }) expect(rows()).toHaveLength(3) + await user.click(button('▶ Resume')) + expect(screen.getByRole('tab', { name: 'Stream 1,000' })).toBeInTheDocument() + expect(rows()[0]).toMatch(/HELD_999/) + expect(screen.queryByText('MASTER_ARM'), 'the oldest rows fell off').toBeNull() + }) + + it('Clear while paused also drops what was held', async () => { + const { user, sky, button } = await openStream() + await user.click(button('❚❚ Pause')) + sky.emitScenario({ stream: [streamRow(0x1a4, { t: T(4), name: 'FLAPS', value: 2 })] }) + await user.click(button('Clear')) sky.emitScenario({ stream: [streamRow(0x1a6, { t: T(5), name: 'HOOK', value: 1 })] }) - expect(rows()[0]).toBe('12:00:05.000IN ▸HOOK1') + expect(rows()).toEqual([]) + await user.click(button('▶ Resume')) + expect(rows(), 'only what arrived after Clear').toEqual(['12:00:05.000IN ▸HOOK1']) }) it('Clear empties the view on this side only', async () => {