Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/main/app/pushed-rules.seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
78 changes: 62 additions & 16 deletions src/main/config/settings-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 }
Expand All @@ -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' })
})
})
})

Expand All @@ -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
})
Expand All @@ -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 } } })
Expand Down
62 changes: 44 additions & 18 deletions src/main/config/settings-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<ConfigEdit, 'psuMode' | 'psuLimit'>>
/** The edited fields as the settings hold them (the command port as its number). */
export type ConfigForm = Required<Omit<ConfigEdit, 'psuMode' | 'psuLimit' | 'port'>> & {
commandPort: number
}

/** A form's change once its typed port is read (commandPortOf): the fields, and the PSU switch. */
export type FormEdit = Partial<ConfigForm> & Pick<ConfigEdit, 'psuMode'>

/** 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,
Expand All @@ -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<AppConfig> {
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,
Expand All @@ -45,8 +61,8 @@ export function buildConfig(s: ConfigForm): Partial<AppConfig> {
* 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<AppConfig> {
const { psuMode: mode, psuLimit: _limit, ...fields } = edit
export function editedConfig(current: AppConfig, edit: FormEdit): Partial<AppConfig> {
const { psuMode: mode, ...fields } = edit
const form = { ...formOf(current), ...fields }
return buildConfig(mode === undefined ? form : { ...form, psu: { ...form.psu, mode } })
}
Expand All @@ -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<AppConfig> } | { 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<AppConfig> | 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 }
}
11 changes: 7 additions & 4 deletions src/main/config/settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type AppConfig,
type ConfigChanged,
type ConfigEdit,
type ConfigEditResult,
type ConfigView,
type SettingsStore
} from '@shared/config'
Expand Down Expand Up @@ -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) }
}

/**
Expand Down
20 changes: 15 additions & 5 deletions src/main/dcs/sim-telemetry-decoder.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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()
})
})
35 changes: 13 additions & 22 deletions src/main/dcs/sim-telemetry-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import type {
AppConfig,
BackupList,
ConfigEditResult,
ConfigExportResult,
ConfigImportApplyResult,
ConfigImportOpen,
Expand Down Expand Up @@ -53,7 +54,7 @@ const api: SkyhawkApi = {
},
getConfig: () => ipcRenderer.invoke(CTRL.configGet) as Promise<ConfigView>,
setConfig: (patch) => ipcRenderer.invoke(CTRL.configSet, patch) as Promise<AppConfig>,
editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise<AppConfig>,
editConfig: (edit) => ipcRenderer.invoke(CTRL.configEdit, edit) as Promise<ConfigEditResult>,
exportConfig: () => ipcRenderer.invoke(CTRL.configExport) as Promise<ConfigExportResult>,
openConfigImport: () => ipcRenderer.invoke(CTRL.configImportOpen) as Promise<ConfigImportOpen>,
applyConfigImport: (token, sections, backup) =>
Expand Down
Loading
Loading