From d0035d57a492cd3717c337b22729dcf75d90aef1 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 22:19:21 -0400 Subject: [PATCH] fix(dcs): start with no gateway starts and waits for the board instead of being refused Bridge, and Replay driving the Gateway, refused Start with no board: the pill flipped back to Stopped and the reason never showed. Start now always starts; with no board the relay runs and shows the waiting reason a driven Replay already shows, and delivers once the Gateway connects. The startBlocked rule and fact are removed, with the pill's blocked "!" and callout and the greyed-out Start that only they used. A Replay with no capture is still refused. 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