diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index 455931f..f39e845 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -79,16 +79,26 @@ describe('relay:facts: what the relay’s surfaces show', () => { expect(app.lastPush('relay:state')).toEqual({ running: false }) }) - it('a form’s edit: built over the settings held, the port as typed; the facts follow', () => { + it('a form’s edit: built over the settings held, a typed port checked (N5); the facts follow', () => { const app = makeServices(monitor) app.invoke(CTRL.configEdit, { transport: 'unicast-listen' }) expect(app.lastPush('relay:facts')?.endpoint).toBe('0.0.0.0:5010') - app.invoke(CTRL.configEdit, { port: '77a' }) + expect(app.invoke(CTRL.configEdit, { port: '7781' })).toMatchObject({ + ok: true, + config: { commandPort: 7781 } + }) + // N5: a port that is not one is refused with why; nothing is saved, nothing pushed. + const changes = app.pushesOf('config:changed').length + expect(app.invoke(CTRL.configEdit, { port: '77a' })).toEqual({ + ok: false, + error: 'Not a port. Enter a whole number from 1 to 65535.' + }) expect(app.files.json('config.json')).toMatchObject({ transport: 'unicast-listen', - commandPort: 7778 + commandPort: 7781 }) - expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7778) + expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7781) + expect(app.pushesOf('config:changed')).toHaveLength(changes) }) }) diff --git a/src/main/config/settings-edit.test.ts b/src/main/config/settings-edit.test.ts index ad0fdc6..19cb848 100644 --- a/src/main/config/settings-edit.test.ts +++ b/src/main/config/settings-edit.test.ts @@ -12,16 +12,21 @@ import { } from '@shared/config' import { storePaths } from '../app/user-data' import { withSectionNotes } from './config-file' -import { buildConfig, editedConfig, formOf, savedEdit, withPsuLimit } from './settings-form' +import { + buildConfig, + COMMAND_PORT_ERROR, + commandPortOf, + editedConfig, + formOf, + savedEdit, + withPsuLimit +} from './settings-form' import { SettingsService } from './settings-service' describe('the forms’ save (buildConfig)', () => { - it('saves every field the forms edit; a port that isn’t a number saves as 7778 (N5)', () => { + it('saves every field the forms edit', () => { const form = formOf(DEFAULT_CONFIG) - expect(buildConfig({ ...form, port: '7779' }).commandPort).toBe(7779) - for (const junk of ['', '77a', 'abc', '0']) { - expect(buildConfig({ ...form, port: junk }).commandPort, junk).toBe(7778) - } + expect(buildConfig({ ...form, commandPort: 7779 }).commandPort).toBe(7779) expect(Object.keys(buildConfig(form)).sort()).toEqual( [ 'attentionBanner', @@ -44,10 +49,37 @@ describe('the forms’ save (buildConfig)', () => { host: '10.0.0.7', commandPort: 7790 }) - expect(editedConfig(current, { port: '77a' }).commandPort).toBe(7778) expect(editedConfig(current, {})).toEqual(buildConfig(formOf(current))) }) + it('N5: the command port as typed is a port only as whole digits, 1–65535', () => { + expect(commandPortOf('7779')).toBe(7779) + expect(commandPortOf(' 7779 ')).toBe(7779) + expect(commandPortOf('1')).toBe(1) + expect(commandPortOf('65535')).toBe(65535) + for (const junk of ['', ' ', '77a', 'abc', '0', '65536', '-1', '7778.5', '1e3', '0x1f']) { + expect(commandPortOf(junk), junk).toBeUndefined() + } + }) + + it('N5: a junk port is refused with its error, never saved as 7778', () => { + const current = { ...DEFAULT_CONFIG, host: '10.0.0.7', commandPort: 7790 } + for (const junk of ['', '77a', 'abc', '0', '70000']) { + expect(savedEdit(current, { port: junk }), junk).toEqual({ + ok: false, + error: COMMAND_PORT_ERROR + }) + } + expect(savedEdit(current, { port: '7781' })).toEqual({ + ok: true, + save: editedConfig(current, { commandPort: 7781 }) + }) + expect(savedEdit(current, { host: '10.0.0.9' })).toMatchObject({ + ok: true, + save: { host: '10.0.0.9', commandPort: 7790 } + }) + }) + it('a PSU mode switch keeps the preset and the custom limits (R3.4a: `{ ...psu, mode }`)', () => { const psu = { mode: 'preset' as const, preset: 'nx650' as const, custom: { a12: 40, w: 500 } } const current = { ...DEFAULT_CONFIG, psu } @@ -67,15 +99,18 @@ describe('the forms’ save (buildConfig)', () => { } expect(psu.custom, 'the setting held is never changed in place').toEqual({ a12: 40, w: 500 }) const current = { ...DEFAULT_CONFIG, psu } - expect(savedEdit(current, { psuLimit: { key: 'w', text: '400' } })?.psu).toEqual({ - ...psu, - custom: { a12: 40, w: 400 } + expect(savedEdit(current, { psuLimit: { key: 'w', text: '400' } })).toMatchObject({ + ok: true, + save: { psu: { ...psu, custom: { a12: 40, w: 400 } } } }) expect( savedEdit(current, { psuLimit: { key: 'w', text: 'x' } }), - 'nothing to save' - ).toBeUndefined() - expect(savedEdit(current, { host: 'h' })).toEqual(editedConfig(current, { host: 'h' })) + 'nothing to save, quietly' + ).toEqual({ ok: false }) + expect(savedEdit(current, { host: 'h' })).toEqual({ + ok: true, + save: editedConfig(current, { host: 'h' }) + }) }) }) @@ -99,8 +134,9 @@ describe('SettingsService: edits saved and pushed back (config:changed)', () => it('an edit is built over the settings, saved, and comes back as an edit', () => { const { s, pushed } = service() s.update({ host: '10.0.0.7' }) - const cfg = s.edit({ port: '7781' }) - expect(cfg).toMatchObject({ host: '10.0.0.7', commandPort: 7781 }) + const r = s.edit({ port: '7781' }) + expect(r).toMatchObject({ ok: true, config: { host: '10.0.0.7', commandPort: 7781 } }) + const cfg = s.get() expect(JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'))).toMatchObject({ commandPort: 7781 }) @@ -111,13 +147,23 @@ describe('SettingsService: edits saved and pushed back (config:changed)', () => const { s, pushed } = service() s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) const before = readFileSync(join(dir, 'config.json'), 'utf8') - expect(s.edit({ psuLimit: { key: 'w', text: '40x' } })).toBe(s.get()) + expect(s.edit({ psuLimit: { key: 'w', text: '40x' } })).toEqual({ ok: false }) expect(pushed).toHaveLength(1) expect(readFileSync(join(dir, 'config.json'), 'utf8')).toBe(before) s.edit({ psuLimit: { key: 'w', text: '' } }) expect(pushed.at(-1)?.config.psu.custom).toEqual({}) }) + it('N5: a junk port answers why, and saves and pushes nothing', () => { + const { s, pushed } = service() + s.edit({ port: '7781' }) + const before = readFileSync(join(dir, 'config.json'), 'utf8') + expect(s.edit({ port: '77a' })).toEqual({ ok: false, error: COMMAND_PORT_ERROR }) + expect(s.get().commandPort).toBe(7781) + expect(pushed).toHaveLength(1) + expect(readFileSync(join(dir, 'config.json'), 'utf8')).toBe(before) + }) + it('the power supply comes back resolved; an import says so, once it is applied', () => { const { s, pushed } = service() s.edit({ psu: { mode: 'custom', preset: 'nx650', custom: { w: 400 } } }) diff --git a/src/main/config/settings-form.ts b/src/main/config/settings-form.ts index 68f2dfd..693d457 100644 --- a/src/main/config/settings-form.ts +++ b/src/main/config/settings-form.ts @@ -4,16 +4,35 @@ import type { AppConfig, ConfigEdit } from '@shared/config' import { parsePsuLimit, type PsuLimits, type PsuSetting } from '@shared/nodes' -/** The edited fields, with the command port as text (what the Connection forms show). */ -export type ConfigForm = Required> +/** The edited fields as the settings hold them (the command port as its number). */ +export type ConfigForm = Required> & { + commandPort: number +} + +/** A form's change once its typed port is read (commandPortOf): the fields, and the PSU switch. */ +export type FormEdit = Partial & Pick + +/** What a refused command port says, under the field (N5). */ +export const COMMAND_PORT_ERROR = 'Not a port. Enter a whole number from 1 to 65535.' -/** The forms' fields as the settings hold them: the port as its digits. */ +/** + * The command port as typed, if it is one: whole digits (spaces around them ignored), 1–65535. + * Anything else is no port (undefined), and nothing is saved for it (N5). + */ +export function commandPortOf(text: string): number | undefined { + const t = text.trim() + if (!/^\d{1,5}$/.test(t)) return undefined + const n = Number(t) + return n >= 1 && n <= 65535 ? n : undefined +} + +/** The forms' fields as the settings hold them. */ export function formOf(cfg: AppConfig): ConfigForm { return { sourceMode: cfg.sourceMode, transport: cfg.transport, host: cfg.host, - port: String(cfg.commandPort), + commandPort: cfg.commandPort, autoConnect: cfg.autoConnect, replayDriveSerial: cfg.replayDriveSerial, debugMode: cfg.debugMode, @@ -22,16 +41,13 @@ export function formOf(cfg: AppConfig): ConfigForm { } } -/** - * The settings save the forms make: every field they edit, saved whole. A port that isn't a - * number, or is empty, saves as 7778 — while the field keeps showing what was typed (N5). - */ +/** The settings save the forms make: every field they edit, saved whole. */ export function buildConfig(s: ConfigForm): Partial { return { sourceMode: s.sourceMode, transport: s.transport, host: s.host, - commandPort: Number(s.port) || 7778, + commandPort: s.commandPort, autoConnect: s.autoConnect, replayDriveSerial: s.replayDriveSerial, debugMode: s.debugMode, @@ -45,8 +61,8 @@ export function buildConfig(s: ConfigForm): Partial { * them, built into the save (buildConfig). A PSU mode switch keeps the rest of the setting — the * preset and the custom limits — as it is (R3.4a: Settings › Cockpit's `{ ...psu, mode }`). */ -export function editedConfig(current: AppConfig, edit: ConfigEdit): Partial { - const { psuMode: mode, psuLimit: _limit, ...fields } = edit +export function editedConfig(current: AppConfig, edit: FormEdit): Partial { + const { psuMode: mode, ...fields } = edit const form = { ...formOf(current), ...fields } return buildConfig(mode === undefined ? form : { ...form, psu: { ...form.psu, mode } }) } @@ -69,14 +85,24 @@ export function withPsuLimit( return { ...psu, custom } } +/** What a form's change comes to: the save, or why nothing is saved (`error`, for the form). */ +export type SavedEdit = { ok: true; save: Partial } | { ok: false; error?: string } + /** - * A form's change as SettingsService saves it (`config:edit`): editedConfig, with a typed PSU limit - * applied over the setting held. undefined = refused, nothing to save — as the form sent nothing - * for it before. + * A form's change as SettingsService saves it (`config:edit`): editedConfig, with the typed port + * read (commandPortOf) and a typed PSU limit applied over the setting held. A port that is not one + * is refused with its error (N5); a refused PSU limit is refused quietly, as the form sent nothing + * for it before (its field judges itself). Refused = nothing to save. */ -export function savedEdit(current: AppConfig, edit: ConfigEdit): Partial | undefined { - const { psuLimit: limit, ...rest } = edit - if (limit === undefined) return editedConfig(current, rest) +export function savedEdit(current: AppConfig, edit: ConfigEdit): SavedEdit { + const { psuLimit: limit, port, ...rest } = edit + const fields: FormEdit = rest + if (port !== undefined) { + const commandPort = commandPortOf(port) + if (commandPort === undefined) return { ok: false, error: COMMAND_PORT_ERROR } + fields.commandPort = commandPort + } + if (limit === undefined) return { ok: true, save: editedConfig(current, fields) } const psu = withPsuLimit(current.psu, limit.key, limit.text) - return psu && editedConfig(current, { ...rest, psu }) + return psu ? { ok: true, save: editedConfig(current, { ...fields, psu }) } : { ok: false } } diff --git a/src/main/config/settings-service.ts b/src/main/config/settings-service.ts index 74519d8..e5e3bed 100644 --- a/src/main/config/settings-service.ts +++ b/src/main/config/settings-service.ts @@ -17,6 +17,7 @@ import { type AppConfig, type ConfigChanged, type ConfigEdit, + type ConfigEditResult, type ConfigView, type SettingsStore } from '@shared/config' @@ -157,10 +158,12 @@ export class SettingsService { * A form's change (`config:edit`, R3.3): what the renderer's store rebuilt into a save before — * read-modify-write over the settings held here, the port as typed (settings-form.ts). */ - edit(edit: ConfigEdit): AppConfig { - const save = savedEdit(this.get(), edit) - // A refused PSU limit saves and pushes nothing: the form sent nothing for it before (R3.4a). - return save === undefined ? this.get() : this.apply(save) + edit(edit: ConfigEdit): ConfigEditResult { + const built = savedEdit(this.get(), edit) + // A refused change saves and pushes nothing: a port that is not one answers why (N5); a + // refused PSU limit, quietly, as the form sent nothing for it before (R3.4a). + if (!built.ok) return built + return { ok: true, config: this.apply(built.save) } } /** diff --git a/src/main/dcs/sim-telemetry-decoder.test.ts b/src/main/dcs/sim-telemetry-decoder.test.ts index e0d509a..248f1e8 100644 --- a/src/main/dcs/sim-telemetry-decoder.test.ts +++ b/src/main/dcs/sim-telemetry-decoder.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { A4EC_OUTPUTS } from '@shared/firmware' import { TELEMETRY_IDLE } from '@shared/dcs' import { Decoder, extractValue, gaugeTone, idleTelemetry } from './sim-telemetry-decoder' @@ -55,12 +56,21 @@ describe('the gauges’ alert level (Overview.tsx gaugeColor before R3.3)', () = expect(gaugeTone('RPM', 0.9)).toBe('primary') }) - it('X10: the idle readouts are their own spec, and the renderer starts from them', () => { + it('X10: one spec — the idle readouts are the live ones with nothing decoded', () => { expect(idleTelemetry()).toEqual(TELEMETRY_IDLE) const live = new Decoder().telemetrySnapshot() - expect(idleTelemetry().map((r) => r.id)).not.toEqual(live.map((r) => r.id)) - // The idle Press Alt keeps the plain ring; the live one is a secondary ring. - expect(idleTelemetry()[3]).toMatchObject({ id: 'D_ALT_NEEDLE', tone: 'primary' }) - expect(live[3]).toMatchObject({ id: 'ALT_FT', tone: 'secondary' }) + expect(idleTelemetry()).toEqual(live) + // Real units from the start: IAS in kt, Press Alt in ft (its secondary ring), never % FS. + expect(TELEMETRY_IDLE.map((r) => r.unit)).toEqual(['% RPM', 'kt', '% DN', 'ft', '% QTY']) + expect(TELEMETRY_IDLE[3]).toMatchObject({ id: 'ALT_FT', tone: 'secondary' }) + }) + + it('X10: the first frame keeps the idle ids, labels and units', () => { + const d = new Decoder() + d.handle(A4EC_OUTPUTS.find((o) => o.id === 'RPM')!.address, 0x8000, 0) + const first = d.telemetrySnapshot() + const spec = (r: { id: string; label: string; unit: string }) => [r.id, r.label, r.unit] + expect(first.map(spec)).toEqual(TELEMETRY_IDLE.map(spec)) + expect(first[0]!.value).not.toBeNaN() }) }) diff --git a/src/main/dcs/sim-telemetry-decoder.ts b/src/main/dcs/sim-telemetry-decoder.ts index 0417d38..f36804e 100644 --- a/src/main/dcs/sim-telemetry-decoder.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -18,34 +18,14 @@ interface TelemetrySpec { /** * A gauge ring's alert level (the Overview's gaugeColor before R3.3): fuel goes to its alert under - * 25 %; the flap and altitude rings are the secondary ones. Keyed on the readout's id, so the idle - * placeholders (X10: `D_ALT_NEEDLE`, not `ALT_FT`) keep the plain ring they had. + * 25 %; the flap and altitude rings are the secondary ones. Keyed on the readout's id, the idle + * placeholders' and the live readouts' alike: both come from the one spec (TELEMETRY, X10). */ export function gaugeTone(id: string, pct: number): GaugeTone { if (id === 'D_FUEL') return pct < 0.25 ? 'fuel-low' : 'fuel' return id === 'D_FLAPS_IND' || id === 'ALT_FT' ? 'secondary' : 'primary' } -/** - * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives — the - * decoder's idle variant, moved from the renderer's store (X10). Its ids, labels and units - * drifted from the live readouts below (TELEMETRY): IAS and Press Alt read `% FS`, and Press Alt - * keys on `D_ALT_NEEDLE`. Each surface keeps its own until the X10 fix: the renderer starts from - * the contract's TELEMETRY_IDLE, which is this (main/dcs/sim-telemetry-decoder.test.ts). - */ -const IDLE_TELEMETRY: TelemetrySpec[] = [ - { id: 'RPM', label: 'RPM', unit: '% RPM' }, - { id: 'D_IAS_DEG', label: 'IAS', unit: '% FS' }, - { id: 'D_FLAPS_IND', label: 'Flap', unit: '% DN' }, - { id: 'D_ALT_NEEDLE', label: 'Press Alt', unit: '% FS' }, - { id: 'D_FUEL', label: 'Fuel', unit: '% QTY' } -] - -/** The idle readouts: NaN values ("—"), empty rings, each with its ring's level. */ -export function idleTelemetry(): TelemetryReadout[] { - return IDLE_TELEMETRY.map((t) => ({ ...t, value: NaN, pct: 0, tone: gaugeTone(t.id, 0) })) -} - // Three altimeter drum addresses encode digit positions 0–9.999 as 0–65535. // ALT_FT is a virtual telemetry id computed from them; it has no DCS-BIOS address. const ALT_DRUM_IDS = new Set(['D_ALT_10K', 'D_ALT_1K', 'D_ALT_100S']) @@ -81,6 +61,7 @@ function iasRawToKnots(raw: number): number { return NaN } +/** The one telemetry spec: the five gauges, idle and live (X10). */ const TELEMETRY: TelemetrySpec[] = [ { id: 'RPM', label: 'RPM', unit: '% RPM' }, { id: 'D_IAS_DEG', label: 'IAS', unit: 'kt' }, @@ -205,6 +186,16 @@ export class Decoder { } } +/** + * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the live + * readouts with nothing decoded yet, so the same ids, labels and units — kt and ft from the start + * (X10). The contract's TELEMETRY_IDLE, the renderer's starting value, is this + * (main/dcs/sim-telemetry-decoder.test.ts). + */ +export function idleTelemetry(): TelemetryReadout[] { + return new Decoder().telemetrySnapshot() +} + /** The two pushes the telemetry tick makes. */ interface TelemetryEmit { (channel: 'telemetry:tick', payload: TelemetryReadout[]): void diff --git a/src/preload/index.ts b/src/preload/index.ts index 15223e4..06ef318 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import { import type { AppConfig, BackupList, + ConfigEditResult, ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen, @@ -53,7 +54,7 @@ const api: SkyhawkApi = { }, getConfig: () => ipcRenderer.invoke(CTRL.configGet) as Promise, setConfig: (patch) => ipcRenderer.invoke(CTRL.configSet, patch) as Promise, - editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise, + editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise, exportConfig: () => ipcRenderer.invoke(CTRL.configExport) as Promise, openConfigImport: () => ipcRenderer.invoke(CTRL.configImportOpen) as Promise, applyConfigImport: (token, sections, backup) => diff --git a/src/renderer/src/app/connection/Connection.tsx b/src/renderer/src/app/connection/Connection.tsx index 800d2e1..85de12f 100644 --- a/src/renderer/src/app/connection/Connection.tsx +++ b/src/renderer/src/app/connection/Connection.tsx @@ -25,6 +25,8 @@ const TRANSPORTS: { id: TransportId; title: string; detail: string }[] = [ export function Connection() { const s = useConfigStore() const setConfigField = useConfigStore((x) => x.setConfigField) + const typeLinkField = useConfigStore((x) => x.typeLinkField) + const commitLinkField = useConfigStore((x) => x.commitLinkField) // The mode and the transport the relay's facts were judged against (R3.4c): the segment, its // hint and the radios flip in the same push as the cards, Start and the pill, once main has // taken the click. @@ -75,18 +77,28 @@ export function Connection() { setConfigField({ host: e.target.value })} + onChange={(e) => typeLinkField('host', e.target.value)} + onBlur={() => commitLinkField('host')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('host')} />
Port
setConfigField({ port: e.target.value })} + onChange={(e) => typeLinkField('port', e.target.value)} + onBlur={() => commitLinkField('port')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('port')} />
+ {s.portError && ( +
+ {s.portError} +
+ )}
Transport
{TRANSPORTS.map((t) => ( diff --git a/src/renderer/src/app/settings/CockpitSection.tsx b/src/renderer/src/app/settings/CockpitSection.tsx index 4bfacf3..1afb545 100644 --- a/src/renderer/src/app/settings/CockpitSection.tsx +++ b/src/renderer/src/app/settings/CockpitSection.tsx @@ -19,7 +19,8 @@ function PowerSupply() { // The preset as main resolves it (`config:changed`, R3.3). const preset = useConfigStore((s) => s.psuView.preset) const isPreset = psu.mode === 'preset' - // What is typed, per field: a refused value stays visible (and unsaved) until corrected. + // What is typed, per field: a refused value stays visible (and unsaved) until corrected, or until + // main's settings replace the forms (CockpitSection keys this on them, N8). const [typed, setTyped] = useState>(() => ({ a12: text(psu.custom.a12), a5: text(psu.custom.a5), @@ -162,6 +163,8 @@ export function CockpitSection() { const model = useNodesStore((s) => s.nodeModel) const attentionBanner = useConfigStore((s) => s.attentionBanner) const setConfigField = useConfigStore((s) => s.setConfigField) + // Main's settings replaced the forms (an import): the PSU limits start over from them (N8). + const formsReset = useConfigStore((s) => s.formsReset) const [resetOpen, setResetOpen] = useState(false) const differing = pulled ? nodes.differing : 0 @@ -192,7 +195,7 @@ export function CockpitSection() {
- + {resetOpen && setResetOpen(false)} />} diff --git a/src/renderer/src/app/settings/ConnectionSection.tsx b/src/renderer/src/app/settings/ConnectionSection.tsx index ee10a9a..61581f7 100644 --- a/src/renderer/src/app/settings/ConnectionSection.tsx +++ b/src/renderer/src/app/settings/ConnectionSection.tsx @@ -6,6 +6,8 @@ export function ConnectionSection() { const s = useConfigStore() const dcs = useDcsStore() const setConfigField = useConfigStore((x) => x.setConfigField) + const typeLinkField = useConfigStore((x) => x.typeLinkField) + const commitLinkField = useConfigStore((x) => x.commitLinkField) const toggleCapture = useDcsStore((x) => x.toggleCapture) // The recording and whether this row may start one (X12: only while relaying, as the Connection // tab's card), as main decides (R3.3), and whether the row shows (always; R3.4a). @@ -27,15 +29,20 @@ export function ConnectionSection() { className="input" style={{ width: 160 }} value={s.host} - onChange={(e) => setConfigField({ host: e.target.value })} + onChange={(e) => typeLinkField('host', e.target.value)} + onBlur={() => commitLinkField('host')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('host')} /> setConfigField({ port: e.target.value })} + onChange={(e) => typeLinkField('port', e.target.value)} + onBlur={() => commitLinkField('port')} + onKeyDown={(e) => e.key === 'Enter' && commitLinkField('port')} /> )} + {s.portError && ( +
+ {s.portError} +
+ )} ) } diff --git a/src/renderer/src/config/store.ts b/src/renderer/src/config/store.ts index 36377c3..0122940 100644 --- a/src/renderer/src/config/store.ts +++ b/src/renderer/src/config/store.ts @@ -20,6 +20,8 @@ import type { PsuLimits, PsuSetting } from '@shared/nodes' export type SourceMode = 'bridge' | 'monitor' | 'replay' export type TransportId = 'tcp' | 'loop' | 'uni' +/** The typed DCS link fields, saved on commit (blur / Enter, N5). */ +export type LinkField = 'host' | 'port' /** A call to main as the view made it: undefined outside Electron (no `window.skyhawk`). */ type Ask> = ( @@ -31,6 +33,15 @@ export interface ConfigState { transport: TransportId host: string port: string + /** Why main refused the last committed port (N5); nothing was saved for it. */ + portError?: string + /** The link fields typed since their last commit: what a commit sends (N5). */ + linkTyped: Partial> + /** + * How many times main's settings replaced the forms (`config:get`, an import): a form that + * keeps its own typed text (Settings › Cockpit's PSU limits) starts over from them on each (N8). + */ + formsReset: number autoConnect: boolean // replay @@ -47,6 +58,10 @@ export interface ConfigState { debugDump?: { path: string; count: number } setConfigField: (patch: Partial) => void + /** A DCS link field as typed: held here, not saved until it is committed (N5). */ + typeLinkField: (field: LinkField, text: string) => void + /** A DCS link field committed (blur / Enter): main checks and saves it, or answers why not (N5). */ + commitLinkField: (field: LinkField) => void /** Settings › Cockpit's PSU switch: main switches the mode over the setting it holds (R3.4a). */ setPsuMode: (mode: PsuSetting['mode']) => void /** Settings › Cockpit's PSU limit, as typed: main refuses, deletes or sets it (R3.4a). */ @@ -90,9 +105,14 @@ export function viewTransport(transport: DcsTransport): TransportId { return t ?? 'tcp' } -/** The store fields an AppConfig sets — on load, and after a config import replaces it. */ -function configState(cfg: AppConfig): ConfigFields { +/** + * The store fields an AppConfig sets — on load, and after a config import replaces it: the forms + * start over, nothing typed left to commit and no refused port shown. + */ +function configState(cfg: AppConfig): ConfigFields & Pick { return { + portError: undefined, + linkTyped: {}, sourceMode: cfg.sourceMode, transport: viewTransport(cfg.transport), host: cfg.host, @@ -128,8 +148,9 @@ function configEdit(patch: Partial): ConfigEdit { /** A form's change, to main (`config:edit`): main builds the settings and saves them (R3.3). */ const editConfig = (edit: ConfigEdit): void => void api()?.editConfig(edit) -export const useConfigStore = create((set) => ({ +export const useConfigStore = create((set, get) => ({ ...configState(DEFAULT_CONFIG), + formsReset: 0, psuView: PSU_VIEW_DEFAULT, // What is typed stays as typed; main builds and saves the settings (`config:edit`, R3.3). @@ -138,6 +159,28 @@ export const useConfigStore = create((set) => ({ editConfig(configEdit(patch)) }, + // Typing saves nothing (N5): the text waits for its commit. + typeLinkField: (field, text) => { + set((s) => ({ [field]: text, linkTyped: { ...s.linkTyped, [field]: true } })) + }, + + // Main checks the port and saves, or saves nothing and answers why: the form shows that answer + // under the port, unless the field has changed since (N5). A commit with nothing typed sends + // nothing (an Enter, then the blur). + commitLinkField: (field) => { + const s = get() + if (!s.linkTyped[field]) return + const text = s[field] + const { [field]: _sent, ...linkTyped } = s.linkTyped + set({ linkTyped }) + const answer = api()?.editConfig({ [field]: text }) + if (field !== 'port' || !answer) return + void answer.then((r) => { + if (get().port !== text) return + set({ portError: r.ok ? undefined : r.error }) + }) + }, + // The switched setting comes back on `config:changed` (R3.4a). setPsuMode: (mode) => { editConfig({ psuMode: mode }) @@ -178,19 +221,26 @@ export const useConfigStore = create((set) => ({ export const configFromMain = { /** `config:get`. */ config: (v: ConfigView): void => - useConfigStore.setState({ ...configState(v.config), psuView: v.psu }), + useConfigStore.setState((s) => ({ + ...configState(v.config), + psuView: v.psu, + formsReset: s.formsReset + 1 + })), /** - * `config:changed`. An import replaced the settings: the forms start over from them. A form's own - * change leaves what is typed alone (the port as typed, N5); the PSU setting is main's either way, - * since main switches its mode (R3.4a). + * `config:changed`. An import replaced the settings: the forms start over from them, the PSU + * limits' typed text too (N8). A form's own change leaves what is typed alone (the port as typed, + * N5); the PSU setting is main's either way, since main switches its mode (R3.4a). */ changed: (c: ConfigChanged): void => - useConfigStore.setState({ + useConfigStore.setState((s) => ({ psuView: c.psu, psu: c.config.psu, - ...(c.source === 'import' ? configState(c.config) : {}) - }), + ...(c.source === 'import' && { + ...configState(c.config), + formsReset: s.formsReset + 1 + }) + })), /** `config:backups`, and `config:backups-get`'s answer. */ backups: (list: BackupList): void => useConfigStore.setState({ backups: list }) diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 674e2aa..5392ee1 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -62,7 +62,8 @@ import { type AppConfig, type BackupList, type ConfigChanged, - type ConfigEdit + type ConfigEdit, + type ConfigEditResult } from '@shared/config' import { AIRCRAFT_NONE, @@ -469,17 +470,17 @@ export function installSkyhawkMock(): SkyhawkMock { * A form's change: SettingsService builds the save (settings-form.ts) and applies it — * `setConfig`, the spy, stands for that apply — the relay's facts follow, then `config:changed`. */ - const editSettings = (edit: ConfigEdit): Promise => { + const editSettings = (edit: ConfigEdit): Promise => { const built = savedEdit(config(), edit) - // A refused PSU limit: nothing saved, nothing pushed (SettingsService.edit). - if (built === undefined) return resolve(config()) - const saved = fns.setConfig(built) - state = { ...state, config: { ...config(), ...built } } + // Refused (a junk port, a refused PSU limit): nothing saved, nothing pushed (SettingsService.edit). + if (!built.ok) return resolve(built) + void fns.setConfig(built.save) + state = { ...state, config: { ...config(), ...built.save } } turn(() => { pushFacts() push('config:changed', configChanged('edit')) }) - return saved + return resolve({ ok: true as const, config: config() }) } // ── the relay's facts, as main's DcsRelay pushes them (relay-rules.ts) ── diff --git a/src/renderer/test/t27-overview-hero-gauges.test.tsx b/src/renderer/test/t27-overview-hero-gauges.test.tsx index 31e4030..025b198 100644 --- a/src/renderer/test/t27-overview-hero-gauges.test.tsx +++ b/src/renderer/test/t27-overview-hero-gauges.test.tsx @@ -188,13 +188,13 @@ describe('T27 Overview hero tiles and gauges (B56, B57)', () => { } ) - it('gauges: five idle readouts, "—", until the first telemetry tick', async () => { + it('gauges: five idle readouts, "—", until the first tick — in kt / ft already (X10)', async () => { await renderScenario(, { device: connected() }) expect(['RPM', 'IAS', 'Flap', 'Press Alt', 'Fuel'].map((l) => gauge(l).text)).toEqual([ '—% RPM', - '—% FS', + '—kt', '—% DN', - '—% FS', + '—ft', '—% QTY' ]) }) diff --git a/src/renderer/test/t28-connection-relay-controls.test.tsx b/src/renderer/test/t28-connection-relay-controls.test.tsx index 65fe778..f81db75 100644 --- a/src/renderer/test/t28-connection-relay-controls.test.tsx +++ b/src/renderer/test/t28-connection-relay-controls.test.tsx @@ -140,17 +140,23 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { ) }) - it('DCS link: host, port and transport are saved as typed or picked', async () => { + it('DCS link: host and port are saved on commit (blur / Enter), transport as picked', async () => { const { sky, user } = await renderScenario(, { device: connected() }) const host = screen.getByText('DCS host').nextElementSibling as HTMLInputElement const port = screen.getByText('Port').nextElementSibling as HTMLInputElement expect([host.value, port.value]).toEqual(['127.0.0.1', '7778']) await user.clear(host) await user.type(host, '10.0.0.7') + expect(sky.setConfig, 'N5: typing saves nothing').not.toHaveBeenCalled() + await user.tab() // the host's blur commits it expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7778 }) await user.clear(port) await user.type(port, '7779') + expect(sky.setConfig).toHaveBeenCalledTimes(1) + await user.keyboard('{Enter}') expect(lastSaved(sky)).toMatchObject({ host: '10.0.0.7', commandPort: 7779 }) + await user.tab() // the blur after Enter: nothing new typed, nothing sent again + expect(sky.setConfig).toHaveBeenCalledTimes(2) await user.click(screen.getByRole('button', { name: /^Loopback multicast/ })) expect(lastSaved(sky)).toMatchObject({ transport: 'loopback-multicast' }) @@ -160,6 +166,27 @@ describe('T28 Connection device card and relay controls (B61, B62)', () => { expect(lastSaved(sky)).toMatchObject({ transport: 'tcp-to-host' }) }) + it('DCS link: a junk port shows main’s error and saves nothing; a good one saves', async () => { + const { sky, user } = await renderScenario(, { device: connected() }) + const port = screen.getByText('Port').nextElementSibling as HTMLInputElement + await user.clear(port) + await user.type(port, '77a{Enter}') + await settle() + expect(port).toHaveValue('77a') + expect(port).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText('Not a port. Enter a whole number from 1 to 65535.') + ).toBeInTheDocument() + expect(sky.setConfig, 'nothing saved, never a silent 7778').not.toHaveBeenCalled() + + await user.clear(port) + await user.type(port, '7790{Enter}') + await settle() + expect(lastSaved(sky)).toMatchObject({ commandPort: 7790 }) + expect(port).toHaveAttribute('aria-invalid', 'false') + expect(screen.queryByText(/^Not a port/)).toBeNull() + }) + it('Record is greyed out until the relay runs', async () => { const { sky } = await renderScenario(, { device: connected() }) expect(screen.getByRole('button', { name: 'Start recording…' })).toBeDisabled() diff --git a/src/renderer/test/t29-settings-power-supply.test.tsx b/src/renderer/test/t29-settings-power-supply.test.tsx index da1f09c..9e63c06 100644 --- a/src/renderer/test/t29-settings-power-supply.test.tsx +++ b/src/renderer/test/t29-settings-power-supply.test.tsx @@ -1,10 +1,14 @@ // T29 — Settings › Cockpit › Power supply: the NX650 preset or limits typed by hand. import { describe, expect, it } from 'vitest' import { screen, within } from '@testing-library/react' +import { DEFAULT_CONFIG } from '@shared/config' import type { PsuSetting } from '@shared/nodes' +import { CockpitSection } from '../src/app/settings/CockpitSection' import { Settings } from '../src/app/settings/Settings' +import { ConfigurationSection } from '../src/config' +import { AXIS_LABELS } from '../src/hid' import { renderScenario, settle } from './render' -import { connected } from './scenarios' +import { configFileText, connected, importPreview, setup } from './scenarios' const LIMITS = ['12 V rail limit', '5 V rail limit', 'Total power'] as const @@ -115,4 +119,38 @@ describe('T29 Settings › Cockpit › Power supply (B87)', () => { expect(field('Total power')).toHaveAttribute('aria-invalid', 'true') expect(saved()?.custom.w).not.toBe(Number(typed)) }) + + it('N8: an import with other limits refreshes the fields, a refused text included', async () => { + const psu: PsuSetting = { mode: 'custom', preset: 'nx650', custom: { a12: 40 } } + const imported: PsuSetting = { mode: 'custom', preset: 'nx650', custom: { a12: 30, w: 400 } } + // Both sections on screen, so the limit fields are there when the import's push lands. + const { sky, user } = await renderScenario( + <> + + + , + { device: connected(), config: { psu } } + ) + const field = (l: (typeof LIMITS)[number]) => + screen.getByRole('textbox', { name: l }) as HTMLInputElement + await user.type(field('5 V rail limit'), '15x') + expect(field('5 V rail limit')).toHaveAttribute('aria-invalid', 'true') + + sky.openConfigImport.mockResolvedValueOnce({ + status: 'ready', + preview: importPreview(configFileText({ powerSupply: imported }), setup({ powerSupply: psu })) + }) + sky.applyConfigImport.mockResolvedValueOnce({ + ok: true, + applied: ['powerSupply'], + config: { ...DEFAULT_CONFIG, psu: imported } + }) + await user.click(screen.getByRole('button', { name: 'Import…' })) + await settle() + const dlg = screen.getByRole('dialog', { name: 'Import configuration' }) + await user.click(within(dlg).getByRole('button', { name: 'Apply 1 section' })) + await settle() + expect(LIMITS.map((l) => field(l).value)).toEqual(['30', '', '400']) + expect(field('5 V rail limit')).toHaveAttribute('aria-invalid', 'false') + }) }) diff --git a/src/renderer/test/t31-settings-connection-diagnostics.test.tsx b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx index 05b7b17..70f58e8 100644 --- a/src/renderer/test/t31-settings-connection-diagnostics.test.tsx +++ b/src/renderer/test/t31-settings-connection-diagnostics.test.tsx @@ -38,16 +38,39 @@ describe('T31 Settings › Connection + Diagnostics (B86, B91)', () => { expect(control('Command port')).toHaveValue('7779') }) - it('the DCS host and command port are saved as typed', async () => { - const { user, saved } = await openSettings() + it('the DCS host and command port are saved on commit (blur / Enter), not per keystroke', async () => { + const { sky, user, saved } = await openSettings() await user.clear(control('DCS host')) await user.type(control('DCS host'), '192.168.1.20') + expect(sky.setConfig, 'N5: typing saves nothing').not.toHaveBeenCalled() + await user.keyboard('{Enter}') expect(saved()).toMatchObject({ host: '192.168.1.20' }) await user.clear(control('Command port')) await user.type(control('Command port'), '7780') + expect(sky.setConfig).toHaveBeenCalledTimes(1) + await user.tab() expect(saved()).toMatchObject({ host: '192.168.1.20', commandPort: 7780 }) }) + it('a junk command port: main’s error under the card, nothing saved', async () => { + const { sky, user, saved } = await openSettings(undefined, { commandPort: 7779 }) + await user.clear(control('Command port')) + await user.type(control('Command port'), '99999') + await user.tab() + await settle() + expect(control('Command port')).toHaveValue('99999') + expect(control('Command port')).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByText('Not a port. Enter a whole number from 1 to 65535.') + ).toBeInTheDocument() + expect(sky.setConfig).not.toHaveBeenCalled() + await user.clear(control('Command port')) + await user.type(control('Command port'), '7781{Enter}') + await settle() + expect(saved()).toMatchObject({ commandPort: 7781 }) + expect(screen.queryByText(/^Not a port/)).toBeNull() + }) + it('Connect automatically: the toggle saves the setting either way', async () => { // Turning it on while the link is down also connects at once — main does that when the // setting arrives (SESSION seam); the renderer only saves it. diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 37bf78a..84399c2 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -106,6 +106,11 @@ // the Connection card and Settings › Connection. `OverviewLink` gained `running`: a relay that runs // without needing the board (Monitor, an undriven Replay) is never "waiting" for it. // +// Fix track #134 (N5: the command port saved on commit, validated): no channel, intent or method +// was added, removed or renamed. `config:edit` (editConfig) answers `ConfigEditResult` instead of +// `AppConfig`: the settings now held, or the change refused (nothing saved) with why — a command +// port that is not one shows main's error under the field. +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -114,6 +119,7 @@ import type { BackupList, ConfigChanged, ConfigEdit, + ConfigEditResult, ConfigExportResult, ConfigImportApplyResult, ConfigImportOpen, @@ -285,7 +291,7 @@ interface OldSkyhawkApi { on(channel: C, cb: (data: OldPushChannels[C]) => void): () => void getConfig(): Promise setConfig(patch: Partial): Promise - editConfig(edit: ConfigEdit): Promise + editConfig(edit: ConfigEdit): Promise exportConfig(): Promise openConfigImport(): Promise applyConfigImport( diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts index 69513e7..480efdb 100644 --- a/src/shared/app/ipc.ts +++ b/src/shared/app/ipc.ts @@ -10,6 +10,7 @@ import { type AppConfig, type BackupList, type ConfigEdit, + type ConfigEditResult, type ConfigPushChannels, type ConfigView, type ConfigExportResult, @@ -220,9 +221,9 @@ export interface SkyhawkApi { setConfig(patch: Partial): Promise /** * A form's change, as typed (R3.3): main builds the save over what it holds and pushes - * `config:changed`. + * `config:changed`, or refuses it (nothing saved) and answers why (N5). */ - editConfig(edit: ConfigEdit): Promise + editConfig(edit: ConfigEdit): Promise // ── configuration export / import (#67) ────────────────────────────────── /** Save dialog, then write every section to one file. */ diff --git a/src/shared/config/ipc.ts b/src/shared/config/ipc.ts index 21d1855..414df7b 100644 --- a/src/shared/config/ipc.ts +++ b/src/shared/config/ipc.ts @@ -51,14 +51,14 @@ export const DEFAULT_CONFIG: AppConfig = { /** * A change to the settings as the forms make it (R3.3): the fields they edit, the command port as - * typed. Main builds the settings from it (buildConfig: a port that isn't a number saves as the - * default, N5) over what it holds, and saves them. + * typed. Main builds the settings from it over what it holds, and saves them — or refuses it, and + * saves nothing (a port that is not one, N5: settings-form.ts commandPortOf). */ export interface ConfigEdit { sourceMode?: SourceMode transport?: DcsTransport host?: string - /** The command port as typed, e.g. `'7778'`, `''` or `'77a'`. */ + /** The command port as typed, e.g. `'7778'`, `''` or `'77a'`; sent when the field is committed. */ port?: string autoConnect?: boolean replayDriveSerial?: boolean @@ -77,6 +77,12 @@ export interface ConfigEdit { psuLimit?: { key: keyof PsuLimits; text: string } } +/** + * `config:edit`'s answer: the settings now held, or the change refused — nothing saved, and, for a + * field the form shows an error under (the command port, N5), why. + */ +export type ConfigEditResult = { ok: true; config: AppConfig } | { ok: false; error?: string } + /** What the power supply setting comes to (R3.3): SettingsService resolves it. */ export interface PsuView { /** The plan's PSU box: the preset's model, or plain `PSU` for a custom supply. */ diff --git a/src/shared/dcs/ipc.ts b/src/shared/dcs/ipc.ts index 51ff87e..e572b48 100644 --- a/src/shared/dcs/ipc.ts +++ b/src/shared/dcs/ipc.ts @@ -189,15 +189,15 @@ export interface TelemetryReadout { } /** - * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the - * decoder's idle variant (X10 — its ids and units are not the live readouts'; main pins them, - * main/dcs/sim-telemetry-decoder.test.ts). + * The idle gauges (value NaN -> "—") the renderer shows until live telemetry arrives: the live + * readouts with nothing decoded yet, in the same units (X10 — main pins it to the decoder's one + * spec, main/dcs/sim-telemetry-decoder.test.ts). */ export const TELEMETRY_IDLE: TelemetryReadout[] = [ { id: 'RPM', label: 'RPM', value: NaN, pct: 0, unit: '% RPM', tone: 'primary' }, - { id: 'D_IAS_DEG', label: 'IAS', value: NaN, pct: 0, unit: '% FS', tone: 'primary' }, + { id: 'D_IAS_DEG', label: 'IAS', value: NaN, pct: 0, unit: 'kt', tone: 'primary' }, { id: 'D_FLAPS_IND', label: 'Flap', value: NaN, pct: 0, unit: '% DN', tone: 'secondary' }, - { id: 'D_ALT_NEEDLE', label: 'Press Alt', value: NaN, pct: 0, unit: '% FS', tone: 'primary' }, + { id: 'ALT_FT', label: 'Press Alt', value: NaN, pct: 0, unit: 'ft', tone: 'secondary' }, { id: 'D_FUEL', label: 'Fuel', value: NaN, pct: 0, unit: '% QTY', tone: 'fuel-low' } ]