diff --git a/src/main/events/events.test.ts b/src/main/events/events.test.ts index 9001200..b0f1a66 100644 --- a/src/main/events/events.test.ts +++ b/src/main/events/events.test.ts @@ -90,7 +90,9 @@ describe('NodeEventTracker — changes', () => { ]) }) - it('raises and clears bus-off, error-passive (with TEC), overheat', () => { + // X6/R8 (#133): in the Cockpit's order — bus-off, fault, overheat, error-passive (was bus-off, + // error-passive, overheat). + it('raises and clears bus-off, overheat, error-passive (with TEC)', () => { const t = live([st(2)]) expect( short( @@ -98,13 +100,13 @@ describe('NodeEventTracker — changes', () => { ) ).toEqual([ 'Right_Navigation: bus-off [error]', - 'Right_Navigation: error-passive · TEC 131 [warn]', - 'Right_Navigation: overheat [warn]' + 'Right_Navigation: overheat [warn]', + 'Right_Navigation: error-passive · TEC 131 [warn]' ]) expect(short(t.status([st(2)], NODE_MSG, '', 30))).toEqual([ 'Right_Navigation: bus-off cleared [info]', - 'Right_Navigation: error-passive cleared [info]', - 'Right_Navigation: overheat cleared [info]' + 'Right_Navigation: overheat cleared [info]', + 'Right_Navigation: error-passive cleared [info]' ]) }) diff --git a/src/main/events/status-flags.ts b/src/main/events/status-flags.ts index 51cbcab..41fc964 100644 --- a/src/main/events/status-flags.ts +++ b/src/main/events/status-flags.ts @@ -5,13 +5,20 @@ import type { NodeStatus } from '@shared/nodes' export type StatusFlag = 'boff' | 'epvf' | 'degraded' | 'overheat' -/** A node's own heartbeat / health flags, as every row and the Gateway (#94) name them. */ -export const STATUS_FLAGS: { key: StatusFlag; label: string; severity: EventSeverity }[] = [ - { key: 'boff', label: 'bus-off', severity: 'error' }, - { key: 'epvf', label: 'error-passive', severity: 'warn' }, - { key: 'degraded', label: 'degraded', severity: 'warn' }, - { key: 'overheat', label: 'overheat', severity: 'warn' } -] +/** + * A node's own heartbeat / health flags, as every row and the Gateway (#94) name them: the label and + * the severity, by flag. No order here: the order a node's flags are named in is nodes/faults.ts's + * (NODE_FLAG_ORDER), the Gateway's own is gateway-health's. + */ +export const STATUS_FLAGS: Record< + StatusFlag, + { key: StatusFlag; label: string; severity: EventSeverity } +> = { + boff: { key: 'boff', label: 'bus-off', severity: 'error' }, + epvf: { key: 'epvf', label: 'error-passive', severity: 'warn' }, + degraded: { key: 'degraded', label: 'degraded', severity: 'warn' }, + overheat: { key: 'overheat', label: 'overheat', severity: 'warn' } +} /** The fault-dictionary label, else `Fault N`. */ export function faultLabel(n: Pick): string { diff --git a/src/main/gateway/gateway-health.ts b/src/main/gateway/gateway-health.ts index 953be95..223a463 100644 --- a/src/main/gateway/gateway-health.ts +++ b/src/main/gateway/gateway-health.ts @@ -31,8 +31,8 @@ export const GATEWAY_REASK_MS = 1000 const SECS = GATEWAY_NO_ANSWER_MS / 1000 -type Flag = (typeof STATUS_FLAGS)[number] -const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)! +type Flag = (typeof STATUS_FLAGS)[StatusFlag] +const flag = (key: StatusFlag): Flag => STATUS_FLAGS[key] /** * Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name. diff --git a/src/main/nodes/cockpit-snapshot/adopt.ts b/src/main/nodes/cockpit-snapshot/adopt.ts index df053f5..a894178 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.ts @@ -13,7 +13,7 @@ import { type NodeView } from '@shared/nodes' import { amps, fmtUptime, volts } from '@shared/format' -import { nodeFaultTag } from '../faults' +import { nodeFlagWords } from '../faults' import { isAdoptableKind, reportedIdentity } from '../node-kind' import { displayCurrentMa } from '../node-telem' import { resolveZone } from '../power-zone' @@ -110,12 +110,7 @@ export function adoptEvidence(v: NodeView): Evidence { const reporting = e.kind === 'pdu' ? 'telemetry: board status' : 'not reporting right now' return { detected, reporting, registry } } - const faults = [ - st.boff && 'bus-off', - st.degraded && nodeFaultTag(st), - st.overheat && 'overheat', - st.epvf && 'error-passive' - ].filter((f): f is string => !!f) + const faults = nodeFlagWords(st) return { detected, reporting: `status only · up ${fmtUptime(st.uptimeSec)} · ${faults.length ? faults.join(', ') : 'healthy'}`, diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index 7e7e489..d666a9a 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -12,6 +12,7 @@ import { type FaultBox, type FedBoard, type NodeModelEntry, + type NodeStatus, type NodeView, type PduOutage, type PduRailPage, @@ -21,7 +22,8 @@ import { type ZoneChoices } from '@shared/nodes' import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' -import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults' +import type { StatusFlag } from '../../events' +import { nodeFaultTag, pduAlertFlags, pduFlagMeanings, raisedNodeFlags } from '../faults' import { displayCurrentMa } from '../node-telem' import { canForget, canHide, type OfflineGate } from '../offline-actions' import { fedBy, zoneLabel } from '../power-zone' @@ -100,7 +102,7 @@ function notSeenBox(e: NodeModelEntry): FaultBox { * The boxes at the top of the node panel's Status tab, stacked; empty when there is nothing to * explain. New, no-status-yet and offline each stand alone. Otherwise every flag the node raised * gets its own box — bus-off, then degraded (the fault table's label), overheat, error-passive, - * the order the tile and Attention name them in — so a second fault never hides behind the first. + * the one order (faults.ts NODE_FLAG_ORDER) — so a second fault never hides behind the first. */ export function nodeFaultBoxes(v: NodeView): FaultBox[] { const e = v.entry @@ -134,32 +136,31 @@ export function nodeFaultBoxes(v: NodeView): FaultBox[] { ] } if (!st) return [] - const out: FaultBox[] = [] - if (st.boff) { - out.push({ - tone: 'err', - title: 'Bus-off', - text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.' - }) - } - if (st.degraded) { - out.push({ - tone: 'warn', - title: st.faultLabel ?? nodeFaultTag(st), - text: st.faultDesc || 'reported by the node; this client has no description for this code.' - }) - } - if (st.overheat) { - out.push({ tone: 'warn', title: 'Overheat', text: 'the node raised its own temperature trip.' }) - } - if (st.epvf) { - out.push({ - tone: 'warn', - title: 'Error-passive', - text: 'its CAN error counters are high; it still communicates but can only signal errors passively.' - }) - } - return out + return raisedNodeFlags(st).map((k) => FLAG_BOX[k](st)) +} + +/** Each flag's box, as nodeFaultBoxes stacks them (in faults.ts NODE_FLAG_ORDER). */ +const FLAG_BOX: Record FaultBox> = { + boff: () => ({ + tone: 'err', + title: 'Bus-off', + text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.' + }), + degraded: (st) => ({ + tone: 'warn', + title: st.faultLabel ?? nodeFaultTag(st), + text: st.faultDesc || 'reported by the node; this client has no description for this code.' + }), + overheat: () => ({ + tone: 'warn', + title: 'Overheat', + text: 'the node raised its own temperature trip.' + }), + epvf: () => ({ + tone: 'warn', + title: 'Error-passive', + text: 'its CAN error counters are high; it still communicates but can only signal errors passively.' + }) } /** diff --git a/src/main/nodes/cockpit-snapshot/views.ts b/src/main/nodes/cockpit-snapshot/views.ts index 56a2036..e4b2409 100644 --- a/src/main/nodes/cockpit-snapshot/views.ts +++ b/src/main/nodes/cockpit-snapshot/views.ts @@ -9,7 +9,7 @@ import type { NodeView, PduReadings } from '@shared/nodes' -import { nodeFaultTag, pduAlertFlags } from '../faults' +import { nodeFlagChips, pduAlertFlags } from '../faults' /** A PDU's pages that are fresh right now. A stale page is not a reading. */ function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { @@ -28,10 +28,7 @@ export function nodeHealth(e: NodeModelEntry, r: PduReadings = {}): NodeHealth { if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' } const st = e.status if (st?.boff) return { state: 'off', flag: 'BUS-OFF' } - const flags: string[] = [] - if (st?.degraded) flags.push(nodeFaultTag(st)) - if (st?.overheat) flags.push('OVERHEAT') - if (st?.epvf) flags.push('ERR-PASSIVE') + const flags = st ? nodeFlagChips(st) : [] if (e.kind === 'pdu') flags.push(...pduAlertFlags(r)) return flags.length ? { state: 'warn', flag: flags.join(' · ') } : { state: 'on' } } diff --git a/src/main/nodes/faults.test.ts b/src/main/nodes/faults.test.ts new file mode 100644 index 0000000..1fe13cb --- /dev/null +++ b/src/main/nodes/faults.test.ts @@ -0,0 +1,73 @@ +// X6 / R8: one order for a node's own flags. The Log and every Cockpit surface name a board's flags +// the same way round: bus-off, the fault it reports (degraded), overheat, error-passive. +import { describe, expect, it } from 'vitest' +import type { LogEventInput } from '@shared/events' +import type { NodeStatus } from '@shared/nodes' +import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' +import { adoptEvidence } from './cockpit-snapshot/adopt' +import { nodeFaultBoxes } from './cockpit-snapshot/panels' +import { nodeViews } from './cockpit-snapshot/views' +import { NODE_FLAG_ORDER, nodeFlagChips, nodeFlagWords, raisedNodeFlags } from './faults' +import { NodeEventTracker } from './node-events' +import { NODE_END_MSG, NODE_MSG } from './node-status-wire' + +const ALL: Partial = { + boff: true, + degraded: true, + faultId: 1, + faultAbbr: 'I2C', + faultLabel: 'I2C peripheral', + overheat: true, + epvf: true, + tec: 131 +} +const st = (over: Partial = {}): NodeStatus => + nodeStatus(2, { uptimeSec: 130, dieTempC: null, ...over }) + +/** The rows the Log gets when the board raises `over`, after a healthy baseline. */ +function logRows(over: Partial): string[] { + const t = new NodeEventTracker({ + name: () => 'Right_Navigation', + isNew: () => false, + unmapped: () => false + }) + t.status([st()], NODE_MSG, '', 0) + t.status([st()], NODE_END_MSG, '', 10) + return t.status([st(over)], NODE_MSG, '', 20).map((e: LogEventInput) => e.event) +} + +const view = (over: Partial, source: 'registry' | 'new' = 'registry') => + nodeViews(nodeModel([modelNode(2, { source, status: st(over), firstSeenAt: 1 })]), [])[0]! + +describe('node flag order (X6/R8)', () => { + it('is the Cockpit’s: bus-off, fault, overheat, error-passive', () => { + expect(NODE_FLAG_ORDER).toEqual(['boff', 'degraded', 'overheat', 'epvf']) + expect(raisedNodeFlags(st(ALL))).toEqual(['boff', 'degraded', 'overheat', 'epvf']) + expect(nodeFlagWords(st(ALL))).toEqual(['bus-off', 'I2C', 'overheat', 'error-passive']) + expect(nodeFlagChips(st(ALL))).toEqual(['BUS-OFF', 'I2C', 'OVERHEAT', 'ERR-PASSIVE']) + }) + + it('a board with every flag: the Log and the Cockpit name them in the same order', () => { + expect(logRows(ALL)).toEqual(['bus-off', 'degraded', 'overheat', 'error-passive']) + expect(nodeFaultBoxes(view(ALL)).map((b) => b.title)).toEqual([ + 'Bus-off', + 'I2C peripheral', + 'Overheat', + 'Error-passive' + ]) + expect(adoptEvidence(view(ALL, 'new')).reporting).toBe( + 'status only · up 00:02:10 · bus-off, I2C, overheat, error-passive' + ) + }) + + it('without bus-off: the tile’s chip, the boxes and the Log agree', () => { + const { boff: _boff, ...three } = ALL + expect(logRows(three)).toEqual(['degraded', 'overheat', 'error-passive']) + expect(view(three).health).toEqual({ state: 'warn', flag: 'I2C · OVERHEAT · ERR-PASSIVE' }) + expect(nodeFaultBoxes(view(three)).map((b) => b.title)).toEqual([ + 'I2C peripheral', + 'Overheat', + 'Error-passive' + ]) + }) +}) diff --git a/src/main/nodes/faults.ts b/src/main/nodes/faults.ts index 6e6f173..ed12651 100644 --- a/src/main/nodes/faults.ts +++ b/src/main/nodes/faults.ts @@ -1,11 +1,49 @@ // Node fault and flag text (pure, no Node/Electron deps): what a raised PDU rail or status flag is -// called in an event row and on the Cockpit, what it means, and the reading that goes with it. +// called in an event row and on the Cockpit, what it means, and the reading that goes with it — and +// the one order a node's own flags are named in, on the Cockpit and in the event log alike (X6/R8). import type { EventSeverity } from '@shared/events' import { amps, volts } from '@shared/format' import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import type { NodeStatus, NodeView, PduRailPage, PduReadings } from '@shared/nodes' +import { STATUS_FLAGS, type StatusFlag } from '../events' import { displayCurrentMa } from './node-telem' +// ── a node's own flags ── + +/** + * The order a node's own flags are named in, everywhere (X6/R8): bus-off, then the fault it reports + * (degraded), overheat, error-passive. The Cockpit's tile, Attention, status boxes and Adopt + * evidence and the event log's rows all read it from here. + */ +export const NODE_FLAG_ORDER: readonly StatusFlag[] = ['boff', 'degraded', 'overheat', 'epvf'] + +/** The status-flag vocabulary (label, severity) in NODE_FLAG_ORDER: what the event log walks. */ +export const NODE_FLAGS = NODE_FLAG_ORDER.map((k) => STATUS_FLAGS[k]) + +/** The flags a node raised, in NODE_FLAG_ORDER. */ +export function raisedNodeFlags(n: NodeStatus): StatusFlag[] { + return NODE_FLAG_ORDER.filter((k) => n[k]) +} + +/** The flags a node raised, in words (`bus-off`, the fault tag, `overheat`, `error-passive`). */ +export function nodeFlagWords(n: NodeStatus): string[] { + return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : STATUS_FLAGS[k].label)) +} + +const CHIP: Record = { + boff: 'BUS-OFF', + degraded: '', + overheat: 'OVERHEAT', + epvf: 'ERR-PASSIVE' +} + +/** The flags a node raised, as the Cockpit's chip prints them (`BUS-OFF`, the fault tag, …). */ +export function nodeFlagChips(n: NodeStatus): string[] { + return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : CHIP[k])) +} + +// ── PDU flags ── + /** Rail flag text, as the Cockpit prints it (`5V UNDER-V`). */ export const RAIL_FLAGS: [number, string][] = [ [RAIL_FLAG.UNDER_V, 'UNDER-V'], @@ -38,31 +76,20 @@ export function nodeFaultTag(n: NodeStatus): string { return n.faultAbbr ?? `Fault ${n.faultId}` } -// ── the Cockpit's flag text (moved from the renderer, R3.2) ── +// ── the Cockpit's flag text (moved from the renderer, R3.2): the same tables as the log's ── -const RAIL_FLAG_TEXT: [number, string][] = [ - [RAIL_FLAG.UNDER_V, 'UNDER-V'], - [RAIL_FLAG.OVER_V, 'OVER-V'], - [RAIL_FLAG.OVER_CURRENT, 'OVER-I'], - [RAIL_FLAG.FUSE_OPEN, 'FUSE OPEN'], - [RAIL_FLAG.SENSOR_FAULT, 'SENSOR FAULT'] -] -/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state, not alerts. */ -const STATUS_FLAG_TEXT: [number, string][] = [ - [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT'], - [PDU_FLAG.TEMP_WARN, 'TEMP WARN'], - [PDU_FLAG.NTC_FAULT, 'NTC FAULT'] -] +/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state (info), not alerts. */ +const STATUS_ALERTS = PDU_STATUS_FLAGS.filter(([, , severity]) => severity !== 'info') /** The alert flags a PDU raises on its fresh pages, e.g. `5V UNDER-V`. Empty = healthy. */ export function pduAlertFlags(r: PduReadings): string[] { const out: string[] = [] const rail = (label: string, p?: PduRailPage) => { - for (const [bit, text] of RAIL_FLAG_TEXT) if (p && p.flags & bit) out.push(`${label} ${text}`) + for (const [bit, text] of RAIL_FLAGS) if (p && p.flags & bit) out.push(`${label} ${text}`) } rail('12V', r.rail12V) rail('5V', r.rail5V) - for (const [bit, text] of STATUS_FLAG_TEXT) if (r.status && r.status.flags & bit) out.push(text) + for (const [bit, text] of STATUS_ALERTS) if (r.status && r.status.flags & bit) out.push(text) return out } diff --git a/src/main/nodes/mapping-sequence.test.ts b/src/main/nodes/mapping-sequence.test.ts index 5fcc686..e9ec121 100644 --- a/src/main/nodes/mapping-sequence.test.ts +++ b/src/main/nodes/mapping-sequence.test.ts @@ -1,9 +1,10 @@ // "Add all" and "Un-hide all" (R3.2b): the loops the Adopt dialog and Settings ran, one board at a -// time — in order, each awaited before the next, stopping at the first refusal. +// time — in order, each awaited before the next. Add all stops at the first refusal; Un-hide all +// goes on and lists every refusal (N4); neither re-enters (R6). import { describe, expect, it, vi } from 'vitest' import type { NodeMappingResult } from '@shared/nodes' import { nodeModel } from '../../test-fixtures/nodes' -import { addAllInTurn, unhideInTurn } from './mapping-sequence' +import { addAllInTurn, MappingSequences, unhideInTurn } from './mapping-sequence' const OK: NodeMappingResult = { ok: true, model: nodeModel([]) } const spec = { name: 'X', console: 'Left', pduId: null, kind: 'panel' } as const @@ -37,7 +38,13 @@ describe('addAllInTurn', () => { [2, 7, 9].map((nodeId) => ({ nodeId, spec })), adopt ) - expect(r).toEqual({ ok: false, done: [2], nodeId: 7, error: 'Name cannot be empty' }) + expect(r).toEqual({ + ok: false, + done: [2], + nodeId: 7, + error: 'Name cannot be empty', + failed: [{ nodeId: 7, error: 'Name cannot be empty' }] + }) expect(adopt.mock.calls.map(([id]) => id)).toEqual([2, 7]) }) @@ -50,21 +57,75 @@ describe('addAllInTurn', () => { }) describe('unhideInTurn', () => { - it('un-hides each in order and stops at the first refusal', async () => { + // N4 (#133): it used to stop at the first refusal, with no word about the rest. + it('un-hides each in order, goes on past a refusal and lists every one refused', async () => { const hide = vi.fn( (id: number): NodeMappingResult => - id === 30 ? { ok: false, error: 'Node mapping is read-only' } : OK + id === 30 || id === 52 ? { ok: false, error: `#${id} could not be saved` } : OK ) - expect(await unhideInTurn([8, 30, 41], hide)).toEqual({ + expect(await unhideInTurn([8, 30, 41, 52], hide)).toEqual({ ok: false, - done: [8], + done: [8, 41], nodeId: 30, - error: 'Node mapping is read-only' + error: '#30 could not be saved', + failed: [ + { nodeId: 30, error: '#30 could not be saved' }, + { nodeId: 52, error: '#52 could not be saved' } + ] }) expect(hide.mock.calls).toEqual([ [8, false], - [30, false] + [30, false], + [41, false], + [52, false] ]) expect(await unhideInTurn([8], hide)).toEqual({ ok: true, done: [8] }) }) }) + +describe('MappingSequences (R6: no re-entry)', () => { + /** An answer the test releases, so the first call is still running when the second comes. */ + function gate() { + let open!: () => void + const p = new Promise((r) => (open = () => r(OK))) + return { p, open } + } + + it('a second Add all while the first runs is ignored, not run twice', async () => { + const seq = new MappingSequences() + const g = gate() + const adopt = vi.fn((_id: number) => g.p) + const bulk = [2, 7].map((nodeId) => ({ nodeId, spec })) + const first = seq.addAll(bulk, adopt) + const second = await seq.addAll(bulk, adopt) + expect(second).toEqual({ ok: true, done: [] }) + g.open() + expect(await first).toEqual({ ok: true, done: [2, 7] }) + expect(adopt.mock.calls.map(([id]) => id)).toEqual([2, 7]) + // Once it has finished, it runs again. + expect(await seq.addAll(bulk, () => OK)).toEqual({ ok: true, done: [2, 7] }) + }) + + it('a second Un-hide all while the first runs is ignored; a refusal still frees it', async () => { + const seq = new MappingSequences() + const g = gate() + const hide = vi.fn(() => g.p) + const first = seq.unhide([8, 30], hide) + expect(await seq.unhide([8, 30], hide)).toEqual({ ok: true, done: [] }) + g.open() + expect(await first).toEqual({ ok: true, done: [8, 30] }) + expect(hide).toHaveBeenCalledTimes(2) + const refused = await seq.unhide([8], () => ({ ok: false, error: 'read-only' })) + expect(refused.ok).toBe(false) + expect(await seq.unhide([8], () => OK)).toEqual({ ok: true, done: [8] }) + }) + + it('Add all and Un-hide all are separate: one running doesn’t block the other', async () => { + const seq = new MappingSequences() + const g = gate() + const first = seq.addAll([{ nodeId: 2, spec }], () => g.p) + expect(await seq.unhide([8], () => OK)).toEqual({ ok: true, done: [8] }) + g.open() + expect(await first).toEqual({ ok: true, done: [2] }) + }) +}) diff --git a/src/main/nodes/mapping-sequence.ts b/src/main/nodes/mapping-sequence.ts index e232125..4764bbe 100644 --- a/src/main/nodes/mapping-sequence.ts +++ b/src/main/nodes/mapping-sequence.ts @@ -1,16 +1,19 @@ // Mapping changes one board at a time (R3.2b): the Adopt dialog's "Add all" and Settings' "Un-hide -// all". The renderer ran these loops itself, one invoke per board (AdoptDialog `addAll`, -// CockpitSection `unhide`); NodeHub runs them now, the same way: in the order given, each change -// awaited before the next, stopping at the first refusal. Each change pushes the model as it goes -// (NodeHub.update), just as each invoke did. +// all". NodeHub runs them in the order given, each change awaited before the next. Each change +// pushes the model as it goes (NodeHub.update), just as each invoke did. // -// Today's defects stay as they are (fix track): Add all adds what it can before a refusal and the -// rest are not tried; Un-hide all stops at the first board it can't un-hide. +// - Add all stops at the first refusal: the boards after it are not tried. +// - Un-hide all (N4, #133) goes on past a board it can't un-hide and reports every one that failed. +// - Neither re-enters (R6, #133): a second call while one is still running is ignored — it answers +// that it did nothing — so a double click never runs it twice. import type { AdoptRequest, AdoptSpec, NodeBatchResult, NodeMappingResult } from '@shared/nodes' /** One change, as NodeHub (or an invoke) answers it. undefined = no answer (counts as done). */ type Answer = NodeMappingResult | undefined | Promise +/** What a call made while the same sequence is still running answers: nothing was done. */ +const IGNORED: NodeBatchResult = { ok: true, done: [] } + /** "Add all": adopt each board in turn; the first refusal stops it. */ export async function addAllInTurn( bulk: AdoptRequest[], @@ -19,22 +22,67 @@ export async function addAllInTurn( const added: number[] = [] for (const b of bulk) { const r = await adopt(b.nodeId, b.spec) - if (r && !r.ok) return { ok: false, done: added, nodeId: b.nodeId, error: r.error } + if (r && !r.ok) { + const failed = [{ nodeId: b.nodeId, error: r.error }] + return { ok: false, done: added, nodeId: b.nodeId, error: r.error, failed } + } added.push(b.nodeId) } return { ok: true, done: added } } -/** "Un-hide" / "Un-hide all": show each board again in turn; the first refusal stops it. */ +/** + * "Un-hide" / "Un-hide all": show each board again in turn. A refusal doesn't stop it: every board + * is tried, and the answer lists the ones that failed (the first also as `nodeId` / `error`). + */ export async function unhideInTurn( ids: number[], hide: (nodeId: number, on: boolean) => Answer ): Promise { const done: number[] = [] + const failed: { nodeId: number; error: string }[] = [] for (const id of ids) { const r = await hide(id, false) - if (r && !r.ok) return { ok: false, done, nodeId: id, error: r.error } - done.push(id) + if (r && !r.ok) failed.push({ nodeId: id, error: r.error }) + else done.push(id) + } + const first = failed[0] + return first + ? { ok: false, done, nodeId: first.nodeId, error: first.error, failed } + : { ok: true, done } +} + +/** + * The two sequences as NodeHub (and the component tests' mock) runs them: each one at a time. A + * call made while the same sequence is running is ignored (`{ ok: true, done: [] }`), not run. + */ +export class MappingSequences { + private adding = false + private unhiding = false + + async addAll( + bulk: AdoptRequest[], + adopt: (nodeId: number, spec: AdoptSpec) => Answer + ): Promise { + if (this.adding) return IGNORED + this.adding = true + try { + return await addAllInTurn(bulk, adopt) + } finally { + this.adding = false + } + } + + async unhide( + ids: number[], + hide: (nodeId: number, on: boolean) => Answer + ): Promise { + if (this.unhiding) return IGNORED + this.unhiding = true + try { + return await unhideInTurn(ids, hide) + } finally { + this.unhiding = false + } } - return { ok: true, done } } diff --git a/src/main/nodes/mapping-sequences.seam.test.ts b/src/main/nodes/mapping-sequences.seam.test.ts new file mode 100644 index 0000000..c666838 --- /dev/null +++ b/src/main/nodes/mapping-sequences.seam.test.ts @@ -0,0 +1,59 @@ +// SESSION-seam tests for "Add all" and "Un-hide all" (#133: R6, N4), through the services harness: +// the renderer's intents in, pushes and node-mapping.json out. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import type { NodeMappingFile } from '@shared/nodes' +import { statusLine } from '../../test-fixtures/nodes' +import { makeServices } from '../test-harness/services' + +const spec = (name: string) => ({ name, console: 'Left', pduId: null, kind: 'panel' }) as const +const mapping = (app: ReturnType) => + app.files.json('node-mapping.json')!.nodes + +describe('Add all and Un-hide all (R6, N4)', () => { + it('a second Add all while the first is still running is ignored: each board is added once', async () => { + const app = makeServices() + app.serial.open() + // Two unlisted panel groups on the bus, waiting to be adopted. + app.serial.receive(statusLine(41) + statusLine(42) + '_NODE_STATUS_END\n') + const bulk = [ + { nodeId: 41, spec: spec('Left_A') }, + { nodeId: 42, spec: spec('Left_B') } + ] + const before = app.pushesOf('nodes:model').length + // A double click: the second invoke lands while the first is still adopting. + const first = app.invoke(CTRL.nodeMappingAddAll, bulk) + const second = app.invoke(CTRL.nodeMappingAddAll, bulk) + expect(await second).toEqual({ ok: true, done: [] }) + expect(await first).toEqual({ ok: true, done: [41, 42] }) + // One adopt (and one model push) per board, not two. + expect(app.pushesOf('nodes:model').length - before).toBe(2) + expect(mapping(app)[41]).toMatchObject({ added: true, name: 'Left_A' }) + expect(mapping(app)[42]).toMatchObject({ added: true, name: 'Left_B' }) + }) + + it('Un-hide all goes on past a board it can’t un-hide and lists it; the others are shown', async () => { + const app = makeServices({ + paths: { + files: { + 'node-mapping.json': { + version: 4, + nodes: { 1: { added: true, hidden: true }, 2: { added: true, hidden: true } } + } + } + } + }) + // 999 is no NODE_ID: main refuses it, between the two it can un-hide. + expect(await app.invoke(CTRL.nodeMappingUnhide, [1, 999, 2])).toEqual({ + ok: false, + done: [1, 2], + nodeId: 999, + error: 'Not a NODE_ID: 999', + failed: [{ nodeId: 999, error: 'Not a NODE_ID: 999' }] + }) + expect(mapping(app)).toMatchObject({ 1: { added: true }, 2: { added: true } }) + expect(mapping(app)[1]?.hidden).toBeUndefined() + expect(mapping(app)[2]?.hidden).toBeUndefined() + expect(app.lastPush('nodes:model')?.hidden).toEqual([]) + }) +}) diff --git a/src/main/nodes/node-events.ts b/src/main/nodes/node-events.ts index 20121d8..7b35857 100644 --- a/src/main/nodes/node-events.ts +++ b/src/main/nodes/node-events.ts @@ -3,8 +3,8 @@ // threshold, so every alert here is a flag a board raised itself. import type { EventSeverity, LogEventInput } from '@shared/events' import type { NodeStatus, NodeTelem } from '@shared/nodes' -import { faultLabel, STATUS_FLAGS } from '../events' -import { PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' +import { faultLabel } from '../events' +import { NODE_FLAGS, PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './node-status-wire' import { TELEM_PAGE } from './node-telem' @@ -173,7 +173,8 @@ export class NodeEventTracker { out: LogEventInput[] ): LogEventInput[] { if (!n.present) return out - for (const f of STATUS_FLAGS) { + // In the Cockpit's order (faults.ts NODE_FLAG_ORDER), so the Log names them as it does. + for (const f of NODE_FLAGS) { const before = was?.[f.key] ?? false const on = n[f.key] if (f.key === 'degraded' && on && before && was!.faultId !== n.faultId) { diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 9bf8027..6c7fa7b 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { NodeMappingFile } from '@shared/nodes' +import type { NodeMappingFile, NodeModel } from '@shared/nodes' import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' import { importMapping } from './mapping-rules' @@ -189,6 +189,21 @@ describe('NodeHub', () => { expect(disk.saves).toBe(before) }) + it('every model pushed carries a rising rev; nodes:get answers the last push’s (C5)', () => { + const { h, pushes } = hub() + const revs = () => + pushes.filter(([c]) => c === 'nodes:model').map(([, m]) => (m as NodeModel).rev) + h.setMapping(1, { name: 'Armament' }) + h.setMapping(1, { name: 'Armament_2' }) + const [a, b] = revs() + expect(b).toBe(a! + 1) + expect(h.snapshot().model.rev).toBe(b) + // A refused change pushes nothing and answers the same rev. + h.setMapping(1, { name: ' ' }) + expect(revs()).toHaveLength(2) + expect(h.snapshot().model.rev).toBe(b) + }) + it('adopting a new node takes it out of "new"; a nameless adopt is refused and changes nothing', () => { const { h, roster } = hub() roster.applyMessage(NODE_MSG, '2901000000000000008000000000', 0) // node 41: kind not reported yet @@ -269,7 +284,8 @@ describe('NodeHub', () => { ok: false, done: [41], nodeId: 43, - error: 'Its board type is not known yet' + error: 'Its board type is not known yet', + failed: [{ nodeId: 43, error: 'Its board type is not known yet' }] }) expect(Object.keys(disk.file.nodes).map(Number)).toEqual([1, 2, 3, 4, 5, 41]) // One adopt's pushes, as one node-mapping:adopt invoke made them; 42 was not tried. @@ -280,6 +296,7 @@ describe('NodeHub', () => { expect(pushes.map(([c]) => c)).toEqual(['cockpit:snapshot', 'nodes:model']) }) + // N4 (#133): a refusal no longer stops it — every board is tried, each refusal listed. it('Un-hide all (R3.2b) un-hides each board in turn, each pushed as it lands', async () => { disk.file = { version: 4, @@ -297,11 +314,14 @@ describe('NodeHub', () => { disk.file = { version: 4, nodes: { 1: { added: true, hidden: true } } } disk.readOnly = 'node-mapping.json was written by a newer client' const ro = hub().h + const notSaved = 'Not saved: node-mapping.json was written by a newer client' + // #1 can't be saved; #2 is tried after it (already shown, so nothing to save). expect(await ro.unhide([1, 2])).toEqual({ ok: false, - done: [], + done: [2], nodeId: 1, - error: 'Not saved: node-mapping.json was written by a newer client' + error: notSaved, + failed: [{ nodeId: 1, error: notSaved }] }) }) diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index f0f3808..2d305f6 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -76,7 +76,7 @@ import { type MappingFileIO } from './mapping-store' import { CockpitSnapshotHost } from './cockpit-host' -import { addAllInTurn, unhideInTurn } from './mapping-sequence' +import { MappingSequences } from './mapping-sequence' /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void @@ -160,6 +160,8 @@ export class NodeHub implements NodeLineSink { private readonly defaults = registryDefaults(NODE_NAMES) private telemPushedAt = 0 private modelDirty = true + /** Rises with every `nodes:model` push; every model main hands out carries it (C5). */ + private modelRev = 0 /** Present NODE_IDs at the last roster change, to tell "came back" from "still listed". */ private wasPresent = new Set() /** Node changes for the event log (#64), named by the mapping. */ @@ -177,6 +179,8 @@ export class NodeHub implements NodeLineSink { private mappedCache = new Map() /** The node mapping and its file. */ private readonly store: MappingStore + /** Add all / Un-hide all, each one at a time (R6): a call while it runs is ignored. */ + private readonly sequences = new MappingSequences() /** The Cockpit tab's picture (R3.2), pushed on `cockpit:snapshot` with each node push. */ private readonly cockpit: CockpitSnapshotHost @@ -440,15 +444,19 @@ export class NodeHub implements NodeLineSink { /** * "Add all" (R3.2b, the Adopt dialog's loop until then): adopt each board in turn, as one adopt - * each — every one pushes the model as it lands — stopping at the first refusal. + * each — every one pushes the model as it lands — stopping at the first refusal. A second call + * while one runs is ignored (R6). */ addAll(bulk: AdoptRequest[]): Promise { - return addAllInTurn(bulk, (nodeId, spec) => this.adopt(nodeId, spec)) + return this.sequences.addAll(bulk, (nodeId, spec) => this.adopt(nodeId, spec)) } - /** "Un-hide" / "Un-hide all" (R3.2b, Settings' loop until then): the same, one un-hide each. */ + /** + * "Un-hide" / "Un-hide all" (R3.2b, Settings' loop until then): one un-hide each, every board + * tried, the ones refused listed (N4). A second call while one runs is ignored (R6). + */ unhide(ids: number[]): Promise { - return unhideInTurn(ids, (nodeId, on) => this.hide(nodeId, on)) + return this.sequences.unhide(ids, (nodeId, on) => this.hide(nodeId, on)) } /** One registry board back to its NODE_IDS.md defaults. */ @@ -559,8 +567,9 @@ export class NodeHub implements NodeLineSink { return this.update((f) => setHidden(f, this.defaults, nodeId, on)) } + /** The model as it stands, stamped with the rev of the last push (a newer push has a higher one). */ private model(now: number): NodeModel { - return buildNodeModel({ + const model = buildNodeModel({ roster: this.roster.snapshot().map(withFaultInfo), seen: this.roster.seenInfo(), rosterCompleteAt: this.roster.rosterCompleteAt(), @@ -569,9 +578,11 @@ export class NodeHub implements NodeLineSink { file: this.mapping(), now }) + return { ...model, rev: this.modelRev } } private pushModel(now = Date.now()): NodeModel { + this.modelRev++ const model = this.model(now) this.modelDirty = false this.cockpit.modelPushed(model) // just ahead of the push it mirrors diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 03c3f5c..5da928a 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -19,7 +19,7 @@ import { onTestFinished, vi } from 'vitest' import { CTRL, type IpcListener, type PushChannel, type PushChannels } from '@shared/app' import type { AppConfig, ConfigEdit } from '@shared/config' import type { RecordState, ReplayLoad } from '@shared/dcs' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' +import type { AdoptRequest, AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' // ── the outside world, faked ───────────────────────────────────────────────── @@ -265,6 +265,8 @@ function launch(opts: ServicesOptions) { [CTRL.nodeMappingResetPreview]: () => services.nodes.previewReset(), [CTRL.nodeRegistry]: () => services.nodes.registry(), [CTRL.nodeMappingHide]: (id: number, on: boolean) => services.nodes.hide(id, on), + [CTRL.nodeMappingAddAll]: (bulk: AdoptRequest[]) => services.nodes.addAll(bulk), + [CTRL.nodeMappingUnhide]: (ids: number[]) => services.nodes.unhide(ids), [CTRL.serialMonitor]: (on: boolean) => services.serialMonitor.setSerialMonitor(on), [CTRL.eventsGet]: () => services.journal.snapshot(), [CTRL.eventsClear]: () => services.journal.clear() diff --git a/src/renderer/src/app/ipc-adapter.test.ts b/src/renderer/src/app/ipc-adapter.test.ts index 63519f0..3d25969 100644 --- a/src/renderer/src/app/ipc-adapter.test.ts +++ b/src/renderer/src/app/ipc-adapter.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CALIBRATION_IDLE, type CalibrationState, type CalSnapshot } from '@shared/hid/calibration' -import type { NodeModel, NodeSnapshot, NodeTelemSnapshot } from '@shared/nodes' +import { + COCKPIT_IDLE, + type NodeModel, + type NodeSnapshot, + type NodeTelemSnapshot +} from '@shared/nodes' import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' import type { LogEvent } from '@shared/events' import type { DeviceStatus, DeviceStatusPush } from '@shared/gateway' @@ -51,6 +56,8 @@ const EMPTY_MODEL: NodeModel = { rosterComplete: false } let nodesSnap: NodeSnapshot = { model: EMPTY_MODEL, telemetry: [] } +/** When set, `nodes:get` answers only once the test releases it (a reply racing a push). */ +let nodesAnswer: Promise | undefined let startResult: { ok: boolean; error?: string } = { ok: true } let eventsSnap: LogEvent[] = [] @@ -79,7 +86,7 @@ function fakeApi() { }) }) }, - getNodes: () => Promise.resolve(nodesSnap), + getNodes: () => nodesAnswer ?? Promise.resolve(nodesSnap), getEvents: () => Promise.resolve(eventsSnap), clearEvents: () => { eventsCleared++ @@ -351,6 +358,7 @@ describe('node state (#63)', () => { beforeEach(() => { status = { running: false, relay: { running: false }, device: { state: 'connected' } } nodesSnap = { model: EMPTY_MODEL, telemetry: [] } + nodesAnswer = undefined }) it('rehydrates the model on a renderer reload', async () => { @@ -372,6 +380,35 @@ describe('node state (#63)', () => { push('device:status', { state: 'connected' }) expect(s.getState().linkDownAt).toBeUndefined() }) + + it('an older model never wins: a pull answered behind a newer push is dropped (C5)', async () => { + let answer!: (snap: NodeSnapshot) => void + nodesAnswer = new Promise((r) => (answer = r)) + const s = await freshStore() + const newer = { ...pduModel, rev: 5 } + push('nodes:model', newer) + answer({ model: { ...EMPTY_MODEL, rev: 4 }, telemetry: [] }) + await nodesAnswer + await Promise.resolve() + expect(s.getState().nodeModel).toBe(newer) + // A pull as new as the last push (or newer) is taken, and so is every newer push. + const pushed = { ...EMPTY_MODEL, rev: 6 } + push('nodes:model', pushed) + expect(s.getState().nodeModel).toBe(pushed) + push('nodes:model', { ...pduModel, rev: 5 }) + expect(s.getState().nodeModel).toBe(pushed) + }) + + it('an older Cockpit snapshot never wins (C5)', async () => { + const s = await freshStore() + const newer = { ...COCKPIT_IDLE, rev: 8, live: true } + push('cockpit:snapshot', newer) + push('cockpit:snapshot', { ...COCKPIT_IDLE, rev: 7 }) + expect(s.getState().cockpit).toBe(newer) + const next = { ...COCKPIT_IDLE, rev: 9 } + push('cockpit:snapshot', next) + expect(s.getState().cockpit).toBe(next) + }) }) describe('event log (#64)', () => { diff --git a/src/renderer/src/app/settings/CockpitSection.tsx b/src/renderer/src/app/settings/CockpitSection.tsx index 4bfacf3..ca7b194 100644 --- a/src/renderer/src/app/settings/CockpitSection.tsx +++ b/src/renderer/src/app/settings/CockpitSection.tsx @@ -5,7 +5,7 @@ import { useConfigStore } from '../../config' import { useNodesStore } from '../../nodes' import { Row, SectionHead } from '../kit' import { ResetDialog } from './ResetDialog' -import { hiddenRow, resetRowDesc } from './settings-view' +import { hiddenRow, resetPreviewKey, resetRowDesc, unhideError } from './settings-view' import { useNodesPulled } from './useNodesPulled' type LimitKey = keyof PsuLimits @@ -100,11 +100,12 @@ function HiddenNodes() { const [error, setError] = useState() const hidden = pulled ? nodes.hidden : [] - // Main un-hides them one at a time and stops at the first it can't (R3.2b). + // Main un-hides them one at a time, goes on past one it can't and lists each it couldn't (N4). const unhide = async (ids: number[]) => { setError(undefined) + const listed = hidden const r = await useNodesStore.getState().unhideNodes(ids) - if (r && !r.ok) setError(r.error) + if (r) setError(unhideError(r, ids.length, listed)) } return ( @@ -158,8 +159,8 @@ function HiddenNodes() { export function CockpitSection() { const pulled = useNodesPulled() const nodes = useNodesStore((s) => s.cockpit.settings) - // The review asks main for a fresh preview each time the model is pushed. - const model = useNodesStore((s) => s.nodeModel) + // The review asks main for a fresh preview when the mapping it is built from changes (R9). + const previewKey = useNodesStore((s) => resetPreviewKey(s.cockpit)) const attentionBanner = useConfigStore((s) => s.attentionBanner) const setConfigField = useConfigStore((s) => s.setConfigField) const [resetOpen, setResetOpen] = useState(false) @@ -194,7 +195,7 @@ export function CockpitSection() { - {resetOpen && setResetOpen(false)} />} + {resetOpen && setResetOpen(false)} />} ) } diff --git a/src/renderer/src/app/settings/ResetDialog.tsx b/src/renderer/src/app/settings/ResetDialog.tsx index 299ce42..313ec3f 100644 --- a/src/renderer/src/app/settings/ResetDialog.tsx +++ b/src/renderer/src/app/settings/ResetDialog.tsx @@ -1,23 +1,33 @@ import { useEffect, useState } from 'react' -import type { NodeModel, ResetPreview } from '@shared/nodes' +import type { ResetPreview } from '@shared/nodes' import { useNodesStore } from '../../nodes' import { Dialog } from '../kit' import { resetButtonLabel } from './settings-view' /** * Reset node mapping — a review, never a one-click reset. Lists every field the reset changes, - * and offers to forget adopted nodes too (off by default). `model` re-fetches the preview when - * the mapping changes underneath. + * and offers to forget adopted nodes too (off by default). `inputs` (settings-view + * resetPreviewKey) re-fetches the preview when the mapping changes underneath, and only then (R9); + * an answer that a newer fetch superseded is dropped. */ -export function ResetDialog({ model, onClose }: { model?: NodeModel; onClose: () => void }) { +export function ResetDialog({ inputs, onClose }: { inputs: string; onClose: () => void }) { const [preview, setPreview] = useState() const [forget, setForget] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState() useEffect(() => { - void useNodesStore.getState().previewNodeReset()?.then(setPreview) - }, [model]) + let current = true + void useNodesStore + .getState() + .previewNodeReset() + ?.then((p) => { + if (current) setPreview(p) + }) + return () => { + current = false + } + }, [inputs]) const nodes = preview?.nodeCount ?? 0 const adopted = preview?.adopted ?? [] diff --git a/src/renderer/src/app/settings/settings-view.ts b/src/renderer/src/app/settings/settings-view.ts index 80efc63..6f2dbef 100644 --- a/src/renderer/src/app/settings/settings-view.ts +++ b/src/renderer/src/app/settings/settings-view.ts @@ -2,7 +2,7 @@ // and Reset…'s gate are main's (cockpit:snapshot settings, R3.2b). The Import rows' wording is // config/import-view.ts. import { plural } from '@shared/format' -import type { HiddenNode } from '@shared/nodes' +import type { CockpitSnapshot, HiddenNode, NodeBatchResult } from '@shared/nodes' import { consoleLabel } from '@shared/nodes' /** "Reset node mapping" row description. */ @@ -38,3 +38,34 @@ export function hiddenRow(n: HiddenNode): ListRow { : 'returns when it reports again' } } + +/** + * What Hidden boards says when main refused an un-hide (N4): one board's refusal as main worded it; + * from Un-hide all, every board it couldn't un-hide — by #id and the name it was listed under — + * with its reason. The others were un-hidden. undefined = nothing refused. + */ +export function unhideError( + r: NodeBatchResult, + asked: number, + listed: HiddenNode[] +): string | undefined { + if (r.ok) return undefined + if (asked === 1) return r.error + const named = (id: number) => { + const name = listed.find((n) => n.nodeId === id)?.name + return name ? `#${id} ${name}` : `#${id}` + } + return `Couldn’t un-hide ${r.failed.map((f) => `${named(f.nodeId)}: ${f.error}`).join('; ')}` +} + +/** + * When Reset…'s review asks main for its preview again (R9): the preview is built from the node + * mapping, so this is the mapping as the snapshot shows it — every board's name, console, PDU and + * where it came from, the hidden ones included, and how many differ. A push that changes none of + * it (a roster poll, a PDU page) gives the same key, and the review keeps the preview it has. + */ +export function resetPreviewKey(s: CockpitSnapshot): string { + const boards = s.views.map(({ entry: e }) => [e.nodeId, e.source, e.name, e.console, e.pduId]) + const hidden = s.settings.hidden.map((h) => [h.nodeId, h.source, h.name, h.console, h.pduId]) + return JSON.stringify([s.settings.differing, boards, hidden]) +} diff --git a/src/renderer/src/nodes/AdoptDialog.tsx b/src/renderer/src/nodes/AdoptDialog.tsx index 89e1353..2151a26 100644 --- a/src/renderer/src/nodes/AdoptDialog.tsx +++ b/src/renderer/src/nodes/AdoptDialog.tsx @@ -14,6 +14,9 @@ import { useModalFocus } from '../app/kit' import { useNodesStore } from './store' import { Dot } from './Dot' +/** A change run under the dialog's one busy flag: its answer, or null if another was running. */ +type RunBusy = (call: () => Promise | undefined) => Promise<{ r: T | undefined } | null> + /** * New on the bus (#77): name and place each NODE_ID the registry does not know, or leave it for * later. A board that can't be adopted is listed so you can see what it is; it stays in Attention @@ -24,6 +27,8 @@ export function AdoptDialog(props: { /** Main's queue: new NODE_IDs, oldest first seen first. */ rows: AdoptRow[] zones: ZoneChoices + /** "New on the bus · N", as the Attention button that opens this shows it (R10: one count). */ + count: number initial?: number onClose: () => void }) { @@ -35,6 +40,26 @@ export function AdoptDialog(props: { const shown = useRef(new Map()) for (const r of live) shown.current.set(r.view.entry.nodeId, r) const [done, setDone] = useState>(new Set()) + /** A board added this turn leaves the queue (functional, so one landing mid-Add all is kept). */ + const markDone = (ids: number[]) => setDone((d) => new Set([...d, ...ids])) + // One busy flag for the whole dialog (R6): while an Adopt or Add all runs, nothing else starts. + // The ref answers at once, so a double click's second click is refused before React re-renders. + const [busy, setBusyState] = useState(false) + const busyRef = useRef(false) + const setBusy = (on: boolean) => { + busyRef.current = on + setBusyState(on) + } + /** One change under the busy flag: its answer, or null when refused (another is running). */ + const run: RunBusy = async (call) => { + if (busyRef.current) return null + setBusy(true) + try { + return { r: await call() } + } finally { + setBusy(false) + } + } const queue = useMemo(() => keptQueue(live, shown.current, done), [live, done]) const ids = queue.map((r) => r.view.entry.nodeId) // The queue as the dialog showed it, so "next" keeps its place as nodes leave it. @@ -69,15 +94,18 @@ export function AdoptDialog(props: { // Main adopts them one at a time and stops at the first it refuses (R3.2b); the ones it added // leave the queue. const addAll = async () => { + if (busyRef.current) return setBulkError('') - const r = await useNodesStore.getState().addAllNodes(bulk) - setDone(new Set([...done, ...(r ? r.done : bulk.map((b) => b.nodeId))])) + const ran = await run(() => useNodesStore.getState().addAllNodes(bulk)) + if (!ran) return + const { r } = ran + markDone(r ? r.done : bulk.map((b) => b.nodeId)) if (r && !r.ok) setBulkError(`NODE_ID ${r.nodeId}: ${r.error}`) } if (!current) return null const move = (how: 'done' | 'later') => { - if (how === 'done') setDone(new Set([...done, current.view.entry.nodeId])) + if (how === 'done') markDone([current.view.entry.nodeId]) const next = nextSelection(order.current, ids, current.view.entry.nodeId, how) if (next === null && how === 'later') return props.onClose() setSel(next) @@ -101,7 +129,7 @@ export function AdoptDialog(props: {
New on the bus
- {queue.length} board{queue.length === 1 ? '' : 's'} this computer hasn’t added yet. + {props.count} board{props.count === 1 ? '' : 's'} this computer hasn’t added yet. Boards in NODE_IDS.md come pre-filled — check them and add.
@@ -146,6 +174,8 @@ export function AdoptDialog(props: { onForm={(f) => setEdits(new Map(edits).set(current.view.entry.nodeId, f))} bulk={bulk.length > 1 ? bulk.length : 0} bulkError={bulkError} + busy={busy} + run={run} onAddAll={() => void addAll()} onDone={() => move('done')} onLater={() => move('later')} @@ -168,6 +198,9 @@ function AdoptForm(props: { form?: FormValues onForm: (f: FormValues) => void bulkError: string + /** The dialog's one busy flag (R6), and how a change runs under it. */ + busy: boolean + run: RunBusy onAddAll: () => void onDone: () => void onLater: () => void @@ -181,7 +214,7 @@ function AdoptForm(props: { const { name, console: con, pduId } = form const setName = (v: string) => props.onForm({ ...form, name: v }) const setPduId = (v: number | null | '') => props.onForm({ ...form, pduId: v }) - const [busy, setBusy] = useState(false) + const { busy } = props const [error, setError] = useState('') const ev = row.evidence // #93: the PDU is explicit, and only ever one in your cockpit (D7): one on another console is @@ -191,10 +224,11 @@ function AdoptForm(props: { const ident = identState(e.availability === 'online', props.dropped) const run = async (call: () => Promise | undefined) => { - setBusy(true) + if (busy) return setError('') - const r = await call() - setBusy(false) + const ran = await props.run(call) + if (!ran) return // the dialog is busy (R6): this click starts nothing + const { r } = ran if (!r) return setError('Not connected to the client process.') if (r.ok) props.onDone() else setError(r.error) diff --git a/src/renderer/src/nodes/Cockpit.tsx b/src/renderer/src/nodes/Cockpit.tsx index 831291c..722689a 100644 --- a/src/renderer/src/nodes/Cockpit.tsx +++ b/src/renderer/src/nodes/Cockpit.tsx @@ -32,6 +32,11 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { const [adopting, setAdopting] = useState(false) /** The Adopt dialog opened on this NODE_ID, else on the first in its queue. */ const [adoptAt, setAdoptAt] = useState() + /** + * Each request to open the Adopt dialog (R10): a new one starts it over on the board asked for, as + * the latest push has it, instead of keeping a dialog already open on another board. + */ + const [adoptOpen, setAdoptOpen] = useState(0) /** * Every way into a board lands here — the plan, Attention, Powered by, Feeds and other tabs' * requests — and goes where main says (D6, `opens`): its panel while it is in your cockpit, else @@ -43,11 +48,13 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { if (to !== 'adopt') return setOpen(null) setAdoptAt(nodeId) + setAdoptOpen((n) => n + 1) setAdopting(true) }, []) /** The Adopt dialog on the first board in its queue ("New on the bus", the Overview's notice). */ const adoptFirst = useCallback(() => { setAdoptAt(undefined) + setAdoptOpen((n) => n + 1) setAdopting(true) }, []) /** A node another tab asked to open here (Connection's "›"). */ @@ -174,8 +181,10 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { {adopting && ( setAdopting(false)} /> diff --git a/src/renderer/src/nodes/store.ts b/src/renderer/src/nodes/store.ts index 6cd4ed4..829f255 100644 --- a/src/renderer/src/nodes/store.ts +++ b/src/renderer/src/nodes/store.ts @@ -58,14 +58,25 @@ export const useNodesStore = create(() => ({ resetNodeMapping: (opts) => api()?.resetNodeMapping(opts) })) +/** + * An older model or snapshot never wins (C5): main's revs only rise, so one that arrives behind + * what the store holds (a pull's answer landing after a newer push) is dropped. A model without a + * rev (not from main) counts as 0. + */ +const olderModel = (m: NodeModel | undefined, held: NodeModel | undefined): boolean => + !!m && !!held && (m.rev ?? 0) < (held.rev ?? 0) + /** What main sends this slice; the IPC adapter calls each as its push or pull lands. */ export const nodesFromMain = { /** `nodes:get`: node pushes only fire on change, so a reloaded renderer asks for the current picture. */ - nodes: (model: NodeModel | undefined): void => useNodesStore.setState({ nodeModel: model }), + nodes: (model: NodeModel | undefined): void => + useNodesStore.setState((s) => (olderModel(model, s.nodeModel) ? s : { nodeModel: model })), /** `nodes:model`. */ - model: (m: NodeModel): void => useNodesStore.setState({ nodeModel: m }), + model: (m: NodeModel): void => + useNodesStore.setState((s) => (olderModel(m, s.nodeModel) ? s : { nodeModel: m })), /** `cockpit:snapshot`. */ - cockpit: (c: CockpitSnapshot): void => useNodesStore.setState({ cockpit: c }) + cockpit: (c: CockpitSnapshot): void => + useNodesStore.setState((s) => (c.rev < s.cockpit.rev ? s : { cockpit: c })) } diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 674e2aa..2d12b5d 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -30,8 +30,9 @@ // `getNodeRegistry` answers it. // // "Add all" and "Un-hide all" run main's own sequence (R3.2b): `addAllNodes` / `unhideNodes` go -// through NodeHub's loop (mapping-sequence), one `adoptNode` / `hideNode` per board — the same -// spies, so a test still sees (and can refuse) each board's call. +// through NodeHub's loop (mapping-sequence, guarded against re-entry as NodeHub's is), one +// `adoptNode` / `hideNode` per board — the same spies, so a test still sees (and can refuse) each +// board's call. // // Calibration runs main's own workflow (R3.1): the mock hosts a real CalibrationWorkflowHost — the // workflow, the dialog's start/close sequencing, the restore offer and the derived CalibrationState @@ -103,7 +104,7 @@ import { shownStatus } from '../../main/gateway/gateway-health' import { captureView, relayFacts, replayView } from '../../main/dcs/relay-rules' import { savedEdit } from '../../main/config/settings-form' import { resolvePsu } from '../../main/nodes/psu-limits' -import { addAllInTurn, unhideInTurn } from '../../main/nodes/mapping-sequence' +import { MappingSequences } from '../../main/nodes/mapping-sequence' import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes' import { deliverPushes, @@ -253,6 +254,10 @@ export function installSkyhawkMock(): SkyhawkMock { let state: Scenario = {} const resolve = (v: T) => Promise.resolve(v) const calFail = { ok: false, kind: 'offline', message: 'mock' } as const + /** NodeHub's Add all / Un-hide all, one at a time each (R6). */ + const sequences = new MappingSequences() + /** NodeHub's model rev: one per `nodes:model` push (C5). */ + let modelRev = 0 const fns: Fns = { on: vi.fn((ch, cb) => { @@ -357,8 +362,8 @@ export function installSkyhawkMock(): SkyhawkMock { previewNodeReset: vi.fn(() => resolve({ rows: [], nodeCount: 0, adopted: [] })), hideNode: vi.fn(() => resolve(mapped())), // ── node-mapping sequences: main's loop, over the per-board intents above ── - addAllNodes: vi.fn((bulk) => addAllInTurn(bulk, (id, spec) => fns.adoptNode(id, spec))), - unhideNodes: vi.fn((ids) => unhideInTurn(ids, (id, on) => fns.hideNode(id, on))), + addAllNodes: vi.fn((bulk) => sequences.addAll(bulk, (id, spec) => fns.adoptNode(id, spec))), + unhideNodes: vi.fn((ids) => sequences.unhide(ids, (id, on) => fns.hideNode(id, on))), // Main's SerialTrafficMonitor: the page's flag comes back on `device:status`. setSerialMonitor: vi.fn((on: boolean) => { monitorShown = on @@ -657,7 +662,12 @@ export function installSkyhawkMock(): SkyhawkMock { // and pushes it, unless the scenario says what main pushed. const device = given.calDevice ?? state.calDevice const opened = device && given.device?.state === 'connected' - const s = opened && given.cal === undefined ? { ...given, cal: snapshotOf(device) } : given + const withCal = + opened && given.cal === undefined ? { ...given, cal: snapshotOf(device) } : given + // NodeHub stamps each model it pushes with a rising rev, and `getNodes` answers the last (C5). + const s = withCal.model + ? { ...withCal, model: { ...withCal.model, rev: ++modelRev } } + : withCal if (opened) lastCal = s.cal state = { ...state, ...s } turn(() => { diff --git a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx index 1f54db8..07a8833 100644 --- a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx +++ b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx @@ -1,7 +1,8 @@ // T10 — the Adopt dialog: its queue, the badges, and the form for each kind of new board. import { describe, expect, it, vi } from 'vitest' -import { screen, within } from '@testing-library/react' +import { act, screen, within } from '@testing-library/react' import { Cockpit } from '../src/app/cockpit/Cockpit' +import { useNav } from '../src/app/kit' import { renderScenario, settle } from './render' import { connected, model, newBoard, pdu } from './scenarios' @@ -57,13 +58,29 @@ describe('T10 Adopt dialog: queue and form (B74, B21, B22)', () => { 'NODE_ID 10BRIDGEfirst seen just now', 'NODE_ID 13UNKNOWN TYPEfirst seen just now' ]) + // R10 (#133): the count is the "New on the bus · N" button's (the boards that can be added), + // not every row listed — it was 6 here, against the button's 3. + expect(screen.getByText('New on the bus · 3')).toBeInTheDocument() expect( within(dlg).getByText( - /^6 boards this computer hasn’t added yet\. Boards in NODE_IDS\.md come pre-filled/ + /^3 boards this computer hasn’t added yet\. Boards in NODE_IDS\.md come pre-filled/ ) ).toBeInTheDocument() }) + it('a request to open another board while the dialog is open lands on that board (R10)', async () => { + const { pick, selected } = await openAdopt() + await pick(12) + expect(selected()).toHaveTextContent('NODE_ID 12') + // Another way into a board (Connection's "›", a cog) asks for #41: the dialog starts over on + // it, as the latest push has it, rather than staying on the board it was showing. + act(() => useNav.getState().openInCockpit(41)) + const [dlg] = screen.getAllByRole('dialog', { name: 'Adopt new devices' }) + expect(screen.getAllByRole('dialog', { name: 'Adopt new devices' })).toHaveLength(1) + const queue = within(dlg!).getByRole('listbox', { name: 'New NODE_IDs' }) + expect(within(queue).getByRole('option', { selected: true })).toHaveTextContent('NODE_ID 41') + }) + it('a registry board comes pre-filled from NODE_IDS.md and is one click to add', async () => { const { dlg, sky, user, selected, button } = await openAdopt() expect(selected()).toHaveTextContent('NODE_ID 8') diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx index 6b333dc..625cd3e 100644 --- a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -68,6 +68,29 @@ describe('T11 Adopt dialog: Add all, PDU choices, power-cycle (B74, F15, F24, X3 expect(options().map((o) => o.textContent)).toEqual(['NODE_ID 6PDUPDU_Center · in NODE_IDS.md']) }) + it('a double click on Add all runs it once; the dialog is busy until it lands (R6)', async () => { + const { dlg, sky, user } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN, PDU_CENTER])) + // Main answers each board a turn later, so the first run is still going on the second click. + const answers: (() => void)[] = [] + sky.adoptNode.mockImplementation( + () => new Promise((r) => answers.push(() => r({ ok: true, model: sky.scenario.model! }))) + ) + const addAll = within(dlg).getByRole('button', { + name: 'Add all 2 panel groups from NODE_IDS.md' + }) + await user.dblClick(addAll) + // One run in flight: the whole dialog is busy. + expect(addAll).toBeDisabled() + expect(within(dlg).getByRole('button', { name: 'Later' })).toBeDisabled() + expect(within(dlg).getByRole('button', { name: 'Add to cockpit' })).toBeDisabled() + while (answers.length) { + answers.shift()!() + await settle() + } + expect(sky.addAllNodes).toHaveBeenCalledTimes(1) + expect(sky.adoptNode.mock.calls.map(([id]) => id)).toEqual([2, 7]) + }) + it('an untouched form keeps its default PDU only when that PDU is in your cockpit, else None', async () => { const { sky, addAll } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN])) await addAll(2) diff --git a/src/renderer/test/t12-settings-hidden-boards.test.tsx b/src/renderer/test/t12-settings-hidden-boards.test.tsx index 0949929..01efcee 100644 --- a/src/renderer/test/t12-settings-hidden-boards.test.tsx +++ b/src/renderer/test/t12-settings-hidden-boards.test.tsx @@ -58,6 +58,35 @@ describe('T12 Settings › Cockpit › Hidden boards (B88)', () => { ]) }) + it('Un-hide all with one board failing un-hides the rest and lists the failure (N4)', async () => { + // Three hidden; main can't save #8. It used to stop there: #30 and #41 were never tried. + const { sky, user, unhideAll } = await openCockpitSection( + model([panel(1, 'Center_Armament')], [], { + hidden: [8, 30, 41], + hiddenNodes: [ + { nodeId: 8, name: 'Right_Radar', console: 'Right', kind: 'panel', source: 'registry' }, + { nodeId: 30, name: 'Left_Spare', console: '', kind: 'panel', source: 'adopted' }, + { nodeId: 41, name: 'Left_Lights', console: 'Left', kind: 'panel', source: 'adopted' } + ] + }) + ) + sky.hideNode.mockImplementation(async (id: number) => + id === 8 + ? { ok: false as const, error: 'permission denied in userData' } + : { ok: true as const, model: sky.scenario.model! } + ) + await user.click(unhideAll()) + await settle() + expect(sky.hideNode.mock.calls).toEqual([ + [8, false], + [30, false], + [41, false] + ]) + expect( + screen.getByText('Couldn’t un-hide #8 Right_Radar: permission denied in userData') + ).toBeInTheDocument() + }) + it('a refused un-hide shows main’s error', async () => { const { sky, user, unhide } = await openCockpitSection(twoHidden()) sky.hideNode.mockResolvedValueOnce({ ok: false, error: 'Node mapping is read-only' }) diff --git a/src/renderer/test/t30-settings-reset.test.tsx b/src/renderer/test/t30-settings-reset.test.tsx index 0aea798..29c2678 100644 --- a/src/renderer/test/t30-settings-reset.test.tsx +++ b/src/renderer/test/t30-settings-reset.test.tsx @@ -159,6 +159,38 @@ describe('T30 Settings › Cockpit › Reset… (B89, B24)', () => { expect(screen.getByRole('dialog', { name: 'Reset node mapping' })).toBeInTheDocument() }) + it('the review asks main again only when the mapping changes, not on every push (R9)', async () => { + const { sky, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(1) + // Roster polls and PDU pages push the model and the snapshot again; the mapping is the same. + sky.emitScenario({ model: fileModel() }) + sky.emitScenario({ + model: model( + [ + panel(2, 'Nav', { pduId: 3, differs: true, availability: 'offline' }), + pdu(3, 'PDU_Left'), + adoptedTrim() + ], + [], + { differingCount: 1 } + ) + }) + await settle() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(1) + // The mapping changes underneath (Nav renamed elsewhere): the review asks again. + sky.emitScenario({ + model: model( + [panel(2, 'Nav_2', { pduId: 3, differs: true }), pdu(3, 'PDU_Left'), adoptedTrim()], + [], + { differingCount: 1 } + ) + }) + await settle() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(2) + expect(within(dlg).getByRole('button', { name: 'Reset 1 node' })).toBeEnabled() + }) + it('Cancel closes the review without asking main', async () => { const { sky, user, openReview } = await openCockpit(fileModel()) const dlg = await openReview() diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index d3ef279..1cd257d 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -99,6 +99,11 @@ // flag as main holds them (no type change there). `CalibrationTabState` (`cal:workflow`) gained // `badgeOpens`: the badges follow the Calibrate button's link rule (a type change only). // +// Fix track #133 (N4, Un-hide all): a type change only — no channel, intent or method was added, +// removed or renamed. `NodeBatchResult`'s refused case (`addAllNodes`, `unhideNodes`) gained +// `failed`: every board refused, in order. Un-hide all now tries every board and lists each one +// it couldn't un-hide; Add all still stops at its first refusal (one entry). +// // 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/nodes/ipc.ts b/src/shared/nodes/ipc.ts index 3eff230..0a3bb3f 100644 --- a/src/shared/nodes/ipc.ts +++ b/src/shared/nodes/ipc.ts @@ -317,6 +317,12 @@ export interface NodeModel { * 'unknown', since there is no roster to be missing from. */ rosterComplete: boolean + /** + * Rises with every `nodes:model` push (C5). A `nodes:get` answer carries the rev of the last push + * it is at least as new as, so the renderer never lets an older model replace a newer one. Absent + * only in a model not from main (a test's fixture). + */ + rev?: number } /** Rail limits as printed on a PSU label. Absent = unknown: the Cockpit shows plain totals. */ @@ -396,11 +402,19 @@ export interface AdoptRequest { /** * Mapping changes run one board at a time ("Add all", "Un-hide all"; R3.2): the NODE_IDs done in - * order, and — when one was refused — that one and why. The boards after it were not tried. + * order, and — when any was refused — the first one and why, and every refusal in `failed`. Add all + * stops at its first refusal (the boards after it were not tried); Un-hide all tries every board + * (N4). */ export type NodeBatchResult = | { ok: true; done: number[] } - | { ok: false; done: number[]; nodeId: number; error: string } + | { + ok: false + done: number[] + nodeId: number + error: string + failed: { nodeId: number; error: string }[] + } // ── consoles ────────────────────────────────────────────────────────────────