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
88 changes: 81 additions & 7 deletions src/main/app/pushed-rules.seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// switch (config:edit).
import { describe, expect, it } from 'vitest'
import { CTRL } from '@shared/app'
import { serializeCapture } from '../dcs/capture-file'
import { makeServices } from '../test-harness/services'

const monitor = { paths: { files: { 'config.json': { sourceMode: 'monitor' } } } }
Expand Down Expand Up @@ -78,19 +79,92 @@ 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).toEqual({
pill: { text: 'unicast listen' },
overview: { text: '239.255.50.10:5010 · local' }
expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010')
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.'
})
app.invoke(CTRL.configEdit, { port: '77a' })
expect(app.files.json('config.json')).toMatchObject({
transport: 'unicast-listen',
commandPort: 7778
commandPort: 7781
})
expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7781)
expect(app.pushesOf('config:changed')).toHaveLength(changes)
})
})

describe('fix track #134: the relay endpoint, Record and the waiting wording', () => {
it('X9: one endpoint, from the settings held, right for each transport', () => {
const app = makeServices(monitor)
expect(app.lastPush('relay:facts')?.endpoint).toBe('127.0.0.1:7778')
app.invoke(CTRL.configEdit, { host: '10.0.0.7', port: '7779' })
expect(app.lastPush('relay:facts')?.endpoint).toBe('10.0.0.7:7779')
app.invoke(CTRL.configEdit, { transport: 'loopback-multicast' })
expect(app.lastPush('relay:facts')?.endpoint).toBe('239.255.50.10:5010')
app.invoke(CTRL.configEdit, { transport: 'unicast-listen' })
expect(app.lastPush('relay:facts')?.endpoint).toBe('this PC:5010')
})

it('X12: Record refused while stopped, before any dialog; allowed while relaying', async () => {
const app = makeServices(monitor)
expect(app.lastPush('relay:facts')).toMatchObject({
running: { canRecord: true },
stopped: { canRecord: false }
})
app.dialogs.saveCapture('session.skyhawk-capture')
expect(await app.invoke(CTRL.captureToggle)).toEqual({ recording: false })
expect(app.lastPush('relay:facts')?.recording).toEqual({ on: false })
app.invoke(CTRL.relayStart)
expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: true })
// Stopped mid-recording: the recording can still be stopped.
app.invoke(CTRL.relayStop)
expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(true)
expect(await app.invoke(CTRL.captureToggle)).toMatchObject({ recording: false })
expect(app.lastPush('relay:facts')?.stopped.canRecord).toBe(false)
})

it('N2: Monitor relaying with no Gateway is running, not waiting for it', () => {
const app = makeServices(monitor)
app.invoke(CTRL.relayStart)
expect(app.lastPush('relay:facts')?.running).toMatchObject({
overviewLink: 'running',
tip: 'waiting-dcs',
waiting: 'dcs'
})
})

it('N3: a driven Replay that loses the Gateway waits for it, as Bridge does', async () => {
const app = makeServices({
paths: {
files: {
'config.json': { sourceMode: 'replay', replayDriveSerial: true },
'flight.skyhawk-capture': serializeCapture([
{ t: 0, dir: 'in', hex: '55555555' },
{ t: 1500, dir: 'in', hex: '55555555' }
])
}
}
})
app.dialogs.openCapture('flight.skyhawk-capture')
await app.invoke(CTRL.replayOpen)
app.serial.open()
expect(app.invoke(CTRL.relayStart)).toEqual({ ok: true })
expect(app.lastPush('relay:facts')?.running.tip).toBe('replay-playing')
app.serial.close()
expect(app.lastPush('relay:facts')?.running).toMatchObject({
waiting: 'device',
tip: 'waiting-device',
overviewLink: 'waiting'
})
expect(app.invoke(CTRL.configGet).config.commandPort).toBe(7778)
})
})

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
9 changes: 9 additions & 0 deletions src/main/dcs/dcs-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { DcsBiosProtocol } from './bios-protocol'
import type { DcsConnection } from './dcs-connection'
import { relayContext, relayEvents } from './relay-events'
import {
canRecord,
captureView,
deviceToDcs,
exportToDevice,
Expand Down Expand Up @@ -254,6 +255,14 @@ export class DcsRelay {
})
}

/**
* May the Record control act now (X12, relay-rules canRecord): start a recording only while
* relaying; stop one already on whenever. The rule the surfaces' `canRecord` shows.
*/
mayRecord(): boolean {
return canRecord(this.running, this.recorder.isRecording())
}

/** The link or the settings changed (AppServices): push the facts if they changed with them. */
inputsChanged(): void {
this.pushFacts()
Expand Down
4 changes: 3 additions & 1 deletion src/main/dcs/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export function registerDcsIpc(ipc: IpcRegistrar, deps: DcsIpcDeps): void {
return { path: filePath }
})

// The Record controls and the Capture card show the answer (relay:facts, R3.3).
// The Record controls and the Capture card show the answer (relay:facts, R3.3). A recording
// starts only while relaying (X12): refused before the save dialog, nothing changes.
ipc.handle(DCS_CTRL.captureToggle, async (): Promise<RecordState> => {
if (!relay.mayRecord()) return { recording: false }
const state = await toggleCapture()
relay.showCapture(state)
return state
Expand Down
Loading
Loading