From 1884ab515957255e6c377b0288c2487b6445ba4d Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:01:50 +0200 Subject: [PATCH 001/137] refactor: the two components the conformance walker could not see `meme` is `memo` with `deepEqual`, and the conformance suite asserts that every component in the renderer is wrapped in it. It collects a declaration when the name is rendered as a JSX tag or exported as its file's default. A component handed to MUI as a prop is neither, so the walk never reached one. The walk reads both spellings MUI has now, `slots={{ footer: X }}` and `inputComponent: X`, and keeps them in two sets rather than one. Each set is asserted to have found something, so a walk that stopped matching one spelling cannot pass on the other's count. That adds ten names the first two sets do not reach: `AddressInput`, `CustomFooter`, `Footer`, `IntervalInput`, `MaxInput`, `MinInput`, `PortInput`, `RegisterLengthInput`, `TimeoutInput` and `ValueInput`. Eight of the ten were already wrapped. `TransactionGrid`'s `CustomFooter` and `ServerConfig`'s `PortInput` were not, and they are what this wraps. Each rule was mutated on its own and turned exactly its own test red: the `slots` key, the `inputComponent` key, the cast peel that `PortInput as unknown as ElementType` needs, the JSX half, and the two wraps themselves. --- src/__tests__/conformance.test.ts | 79 ++++++++++++++++++- .../TransactionGrid/TransactionGrid.tsx | 4 +- .../server/ServerConfig/ServerConfig.tsx | 6 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index 0667faf8..e20d65b0 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -65,8 +65,13 @@ const importedFrom = (node: ts.Node): string | null => // ─── Every component is wrapped in meme ────────────────────────────────────── // // The rule the checkpoint settled: every component, props or not. A declaration -// counts as a component when it is rendered as JSX somewhere in the renderer or -// exported as its file's default, which is what makes the count reproducible. +// counts as a component when it is rendered as JSX somewhere in the renderer, +// exported as its file's default, or handed to MUI as a slot, which is what +// makes the count reproducible. +// +// A component handed over as a prop carries no JSX tag and need not be a default +// export, so the first two sets alone leave it unchecked. MUI spells that two +// ways and both are read below, `slots={{ footer: X }}` and `inputComponent: X`. describe('every component is wrapped in meme', () => { const files = sourceFiles(rendererRoot) @@ -74,6 +79,18 @@ describe('every component is wrapped in meme', () => { const renderedAsJsx = new Set() const defaultExported = new Set() + // Two sets rather than one, because each is asserted to have found something. + // Merged, a walk that stopped matching one spelling would pass on the other's + // count. + const handedToSlots = new Set() + const handedToInputComponent = new Set() + + /** The identifier behind a slot's value, past the casts MUI's typings need. */ + const slottedName = (expression: ts.Expression): string | null => { + let node: ts.Expression = expression + while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) node = node.expression + return ts.isIdentifier(node) ? node.text : null + } for (const { source } of parsed) { eachNode(source, (node) => { @@ -92,6 +109,35 @@ describe('every component is wrapped in meme', () => { ) { defaultExported.add(node.name.text) } + // `slots={{ footer: CustomFooter }}`. Every value in that object literal + // is a component by the prop's own contract, so the key is not read. + if ( + ts.isJsxAttribute(node) && + ts.isIdentifier(node.name) && + node.name.text === 'slots' && + node.initializer && + ts.isJsxExpression(node.initializer) && + node.initializer.expression && + ts.isObjectLiteralExpression(node.initializer.expression) + ) { + for (const property of node.initializer.expression.properties) { + if (!ts.isPropertyAssignment(property)) continue + const name = slottedName(property.initializer) + if (name) handedToSlots.add(name) + } + } + // `inputComponent: PortInput as unknown as ElementType<...>`, which sits + // inside `slotProps` a level or two down. The key is read here rather + // than the path, because `inputComponent` names a component wherever it + // appears and the nesting is MUI's to change. + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === 'inputComponent' + ) { + const name = slottedName(node.initializer) + if (name) handedToInputComponent.add(name) + } }) } @@ -156,7 +202,13 @@ describe('every component is wrapped in meme', () => { if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue const name = declaration.name.text if (!/^[A-Z]/.test(name)) continue - if (!renderedAsJsx.has(name) && !defaultExported.has(name)) continue + if ( + !renderedAsJsx.has(name) && + !defaultExported.has(name) && + !handedToSlots.has(name) && + !handedToInputComponent.has(name) + ) + continue const { isComponent, wrapped } = classify(declaration.initializer) if (isComponent) components.push({ name, file: at(file), wrapped }) } @@ -167,6 +219,27 @@ describe('every component is wrapped in meme', () => { expect(components.length).toBeGreaterThan(150) }) + // Each spelling is asserted on the names only it reaches, so a walk that + // stopped matching reads as a rule with nothing to check rather than as a + // green one. + const onlyFrom = (found: Set): string[] => + components + .filter( + (component) => + found.has(component.name) && + !renderedAsJsx.has(component.name) && + !defaultExported.has(component.name) + ) + .map((component) => component.name) + + it('finds a component handed to a slots prop', () => { + expect(onlyFrom(handedToSlots).length).toBeGreaterThan(0) + }) + + it('finds components handed as an inputComponent', () => { + expect(onlyFrom(handedToInputComponent).length).toBeGreaterThan(5) + }) + it('leaves none of them bare', () => { const bare = components.filter((c) => !c.wrapped).map((c) => `${c.file}\t${c.name}`) expect(bare).toEqual([]) diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index 16f3824e..4aadf17c 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -62,7 +62,7 @@ const ClearButton = meme((): JSX.Element => { // // // Custom footer with export and clear buttons -const CustomFooter = (): JSX.Element => { +const CustomFooter = meme((): JSX.Element => { return ( @@ -71,7 +71,7 @@ const CustomFooter = (): JSX.Element => { ) -} +}) // // diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index ee622610..d2885411 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -154,7 +154,7 @@ const UnitId = meme(() => { ) }) -const PortInput = forwardRef((props, ref) => { +const PortInputForward = forwardRef((props, ref) => { const { set, ...other } = props const portFromStore = useServerZustand((z) => z.port[z.selectedUuid] ?? '') const [localPort, setLocalPort] = useState(portFromStore) @@ -187,7 +187,9 @@ const PortInput = forwardRef((props, ref) => { ) }) -PortInput.displayName = 'PortInput' +PortInputForward.displayName = 'PortInput' + +const PortInput = meme(PortInputForward) // // From 127846e6ab846718e092fd6e4f28c2008aaf71b3 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:11:28 +0200 Subject: [PATCH 002/137] test: the slot walk reads a third spelling, and CONTRIBUTING says what it reads Two findings from `/code-review` on the commit before this one, both about the rule rather than about what runs. The walk read `inputComponent` only as a property inside `slotProps`, so `inputComponent={X}` handed straight to an element was silent. A component reaching the tree that way would have been left out of the population, and both new meters would have gone on passing on the names the nested form already reaches. The collector is one exported function now, and it reads all three spellings. The shorthand of either prop needs no branch and gets none. `slots={{ footer }}` names its local for the slot, so the local is lowercase, and the population takes capitalised declarations only. A branch for it would be one no input can reach. `CONTRIBUTING.md` still described the population as JSX or a default export, which is what the rule read before the previous commit. A contributor writing a new `inputComponent` input would have learned the rest from a red suite. The three spellings get four tests over synthetic sources, because the renderer uses two of them and the third would otherwise be a branch nothing exercises. Each branch was disabled in turn and turned exactly its own test red. --- CONTRIBUTING.md | 13 ++- src/__tests__/conformance.test.ts | 156 +++++++++++++++++++++++------- 2 files changed, 129 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f2117ca..597f3e97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,9 +92,16 @@ with nothing to read. **Every component is wrapped in `meme`.** Props or not, one rule with no exception to remember. A declaration counts as a component when it is rendered -as JSX somewhere or exported as its file's default. React's bare `memo` does not -satisfy it: `meme` is `memo` with `deepEqual`, and the shallow comparator is what -a mutated row defeats. +as JSX somewhere, exported as its file's default, or handed to MUI as a slot. +React's bare `memo` does not satisfy it: `meme` is `memo` with `deepEqual`, and +the shallow comparator is what a mutated row defeats. + +A component handed over as a prop carries no JSX tag and need not be a default +export, so the first two alone would leave it unchecked. Three spellings count: +`slots={{ footer: X }}`, `inputComponent: X` nested inside `slotProps`, and +`inputComponent={X}` straight on the element. The shorthand of the first names +its local for the slot, which is lowercase, and the rule takes capitalised +declarations only. **A local store is named after its component.** `.zustand.ts`, matching the global stores in `context/`, and named after the component rather than the diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index e20d65b0..773af337 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -71,7 +71,122 @@ const importedFrom = (node: ts.Node): string | null => // // A component handed over as a prop carries no JSX tag and need not be a default // export, so the first two sets alone leave it unchecked. MUI spells that two -// ways and both are read below, `slots={{ footer: X }}` and `inputComponent: X`. +// ways, `slots` and `inputComponent`, and each of those has a long form and a +// short one. `slottedComponents` reads all four, and its own tests below are +// where a fifth spelling would be caught. + +/** + * The components a source file hands to MUI as a prop, by the prop that took it. + * + * Three spellings, because the rule's claim is that it has no exception to + * remember and a spelling it cannot see is one: + * + * slots={{ footer: CustomFooter }} + * inputComponent: PortInput + * inputComponent={PortInput} + * + * The shorthand of either, `slots={{ footer }}`, is not read and needs no + * branch: its local is named for the slot, so it is lowercase, and the + * population below takes capitalised declarations only. + * + * The value is read past the casts MUI's own typings need, and the key inside + * `slots` is not read at all: every value in that object is a component by the + * prop's contract. `inputComponent` is matched wherever it appears rather than + * by its path, because it sits a level or two inside `slotProps` and the nesting + * is MUI's to change. + */ +export const slottedComponents = ( + source: ts.SourceFile +): { slots: Set; inputComponent: Set } => { + const slots = new Set() + const inputComponent = new Set() + + const named = (expression: ts.Expression): string | null => { + let node: ts.Expression = expression + while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) node = node.expression + return ts.isIdentifier(node) ? node.text : null + } + + /** The expression a JSX attribute carries, or null for a bare or string one. */ + const attributeValue = (node: ts.JsxAttribute): ts.Expression | null => + node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression + ? node.initializer.expression + : null + + eachNode(source, (node) => { + if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) { + const value = attributeValue(node) + if (node.name.text === 'slots' && value && ts.isObjectLiteralExpression(value)) { + for (const property of value.properties) { + if (!ts.isPropertyAssignment(property)) continue + const name = named(property.initializer) + if (name) slots.add(name) + } + } + if (node.name.text === 'inputComponent' && value) { + const name = named(value) + if (name) inputComponent.add(name) + } + } + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === 'inputComponent' + ) { + const name = named(node.initializer) + if (name) inputComponent.add(name) + } + }) + + return { slots, inputComponent } +} + +// The collector's own population, because the renderer uses two of the three +// spellings and the third would otherwise be a branch nothing exercises. These +// read synthetic sources, so a spelling that stops being read goes red here +// whatever the renderer happens to be written in that week. + +describe('slottedComponents reads every spelling', () => { + const read = (text: string): { slots: string[]; inputComponent: string[] } => { + const source = ts.createSourceFile( + 'probe.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ) + const found = slottedComponents(source) + return { slots: [...found.slots].sort(), inputComponent: [...found.inputComponent].sort() } + } + + it('reads a component out of a slots object', () => { + expect(read('').slots).toEqual([ + 'BitMapRow', + 'CustomFooter' + ]) + }) + + it('reads inputComponent as a nested property, past its casts', () => { + expect( + read( + '' + ).inputComponent + ).toEqual(['PortInput']) + }) + + it('reads inputComponent handed straight to an element', () => { + expect(read('').inputComponent).toEqual(['PortInput']) + }) + + it('reads nothing out of a slot whose value is not a name', () => { + expect( + read(' null }} slotProps={{ footer: { sx: {} } }} />') + ).toEqual({ + slots: [], + inputComponent: [] + }) + }) +}) describe('every component is wrapped in meme', () => { const files = sourceFiles(rendererRoot) @@ -85,13 +200,6 @@ describe('every component is wrapped in meme', () => { const handedToSlots = new Set() const handedToInputComponent = new Set() - /** The identifier behind a slot's value, past the casts MUI's typings need. */ - const slottedName = (expression: ts.Expression): string | null => { - let node: ts.Expression = expression - while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) node = node.expression - return ts.isIdentifier(node) ? node.text : null - } - for (const { source } of parsed) { eachNode(source, (node) => { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { @@ -109,36 +217,10 @@ describe('every component is wrapped in meme', () => { ) { defaultExported.add(node.name.text) } - // `slots={{ footer: CustomFooter }}`. Every value in that object literal - // is a component by the prop's own contract, so the key is not read. - if ( - ts.isJsxAttribute(node) && - ts.isIdentifier(node.name) && - node.name.text === 'slots' && - node.initializer && - ts.isJsxExpression(node.initializer) && - node.initializer.expression && - ts.isObjectLiteralExpression(node.initializer.expression) - ) { - for (const property of node.initializer.expression.properties) { - if (!ts.isPropertyAssignment(property)) continue - const name = slottedName(property.initializer) - if (name) handedToSlots.add(name) - } - } - // `inputComponent: PortInput as unknown as ElementType<...>`, which sits - // inside `slotProps` a level or two down. The key is read here rather - // than the path, because `inputComponent` names a component wherever it - // appears and the nesting is MUI's to change. - if ( - ts.isPropertyAssignment(node) && - ts.isIdentifier(node.name) && - node.name.text === 'inputComponent' - ) { - const name = slottedName(node.initializer) - if (name) handedToInputComponent.add(name) - } }) + const slotted = slottedComponents(source) + for (const name of slotted.slots) handedToSlots.add(name) + for (const name of slotted.inputComponent) handedToInputComponent.add(name) } /** Peel the wrappers a component declaration can sit under. */ From 9b0b0ec5f9f03a1cc9c8fe7ffc54a9e66f4fec3c Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:24:48 +0200 Subject: [PATCH 003/137] docs: a mutation that never applied reads as a mutant that survived The skill already says to edit by a unique string rather than by the first match, and names the symptom of a botched edit as vitest reporting no tests. There is a second symptom and it is the dangerous one: a replace that matches nothing leaves the file alone, every test passes, and that is indistinguishable from a rule the test does not discriminate. Hit once while proving R9's third branch. The reply to it was a second test nobody needed until the file was diffed against the copy. --- .claude/skills/test/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index 21dc642c..7091d9a6 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -76,7 +76,10 @@ git status --porcelain # empty, or the restore did not take - **Edit by line number or by a unique string, not by the first match.** `sed`, `replace(old, new, 1)` and a first-hit search all take the first one, and a codebase repeats lines. If a mutation reports _no tests_ rather than a failure, - it broke the file: that is your quoting, not the code. + it broke the file: that is your quoting, not the code. **A mutation that never + applied reports every test green**, which reads exactly like a mutant that + survived, and the wrong answer to that is to write a second test. Diff the file + against the copy before believing a green run. - **Assert what the code did, not what it said.** The value in the store, the cell in the grid, the bytes on the wire. Not that a handler was called. - **Assert what must appear, not what must be absent.** A thing never produced From 76659cb3d1b35524f7062d6f33254743a22ba97d Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:42:20 +0200 Subject: [PATCH 004/137] fix: a throw out of the vector reaches the caller that catches it `servertcp_handler.js` calls a three-argument accessor inside try/catch and discards what it returns, so a throw out of one is answered exception 4, slave device failure. `_get` and `_set` were `async`, which turns that throw into a rejection nothing holds: no frame is written and the client waits out its timeout instead. `serverserial.js` requires the same handlers, so RTU read the vector the same way. Neither body throws on a shipped path today, which is why this gets no CHANGELOG line. What the two new tests throw from is the caller's own code, the callback the library hands the accessor and `windows.send` under `_set`, and both were red before with the unhandled rejection printed beside the failure. `ValueGenerator._updateValue` is the same construction from the other side. The constructor calls it and `setInterval` calls it, neither awaits it, so a throw inside it was an unhandled rejection that left the generator ticking. It throws out of both callers now and a test names each. Fifty `await vector.x(...)` calls in `modbusServer.test.ts` awaited the promise that is gone, and nineteen test bodies were `async` for that await alone. Both are dropped, because a call that reads as awaitable is what the next test would copy. --- .../modules/__tests__/modbusServer.test.ts | 164 ++++++++++-------- .../modules/__tests__/valueGenerator.test.ts | 44 +++++ src/main/modules/modbusServer.ts | 9 +- .../modules/modbusServer/valueGenerator.ts | 6 +- 4 files changed, 151 insertions(+), 72 deletions(-) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 5621d936..4b5a23ba 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -433,7 +433,7 @@ describe('ModbusServer', () => { await server.createServer({ uuid, port: 5020 }) const vector = lastVector(ServerTCP) hostUnit() - await vector.setRegister(0, 1234, unitIdNumber, vi.fn()) + vector.setRegister(0, 1234, unitIdNumber, vi.fn()) server.addRegister({ uuid, @@ -451,7 +451,7 @@ describe('ModbusServer', () => { }) const cb = vi.fn() - await vector.getHoldingRegister(0, unitIdNumber, cb) + vector.getHoldingRegister(0, unitIdNumber, cb) expect(cb).toHaveBeenCalledWith(null, 1234) }) @@ -459,7 +459,7 @@ describe('ModbusServer', () => { await server.createServer({ uuid, port: 5020 }) const vector = lastVector(ServerTCP) hostUnit() - await vector.setRegister(0, 1234, unitIdNumber, vi.fn()) + vector.setRegister(0, 1234, unitIdNumber, vi.fn()) server.addRegister({ uuid, @@ -478,7 +478,7 @@ describe('ModbusServer', () => { vi.advanceTimersByTime(5000) const cb = vi.fn() - await vector.getHoldingRegister(0, unitIdNumber, cb) + vector.getHoldingRegister(0, unitIdNumber, cb) expect(cb).toHaveBeenCalledWith(null, 1234) }) @@ -790,11 +790,11 @@ describe('ModbusServer', () => { // Verify both registers are set const cb0 = vi.fn() - await vector.getHoldingRegister(0, 1, cb0) + vector.getHoldingRegister(0, 1, cb0) expect(cb0).toHaveBeenCalledWith(null, 1) // high word const cb1 = vi.fn() - await vector.getHoldingRegister(1, 1, cb1) + vector.getHoldingRegister(1, 1, cb1) expect(cb1).toHaveBeenCalledWith(null, 4464) // low word // Remove the register @@ -808,11 +808,11 @@ describe('ModbusServer', () => { // Both addresses should be reset to 0 const cb0After = vi.fn() - await vector.getHoldingRegister(0, 1, cb0After) + vector.getHoldingRegister(0, 1, cb0After) expect(cb0After).toHaveBeenCalledWith(null, 0) const cb1After = vi.fn() - await vector.getHoldingRegister(1, 1, cb1After) + vector.getHoldingRegister(1, 1, cb1After) expect(cb1After).toHaveBeenCalledWith(null, 0) }) @@ -848,7 +848,7 @@ describe('ModbusServer', () => { // All 4 addresses should be reset to 0 for (let i = 0; i < 4; i++) { const cb = vi.fn() - await vector.getHoldingRegister(i, 1, cb) + vector.getHoldingRegister(i, 1, cb) expect(cb).toHaveBeenCalledWith(null, 0) } }) @@ -1794,7 +1794,7 @@ describe('ModbusServer', () => { // The vector passed to ServerSerial should read the same data const vector = lastVector(ServerSerial) const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) @@ -2026,7 +2026,7 @@ describe('ModbusServer', () => { const vector = lastVector(ServerSerial) const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) @@ -2063,32 +2063,32 @@ describe('ModbusServer', () => { }) describe('getCoil', () => { - it('returns coil value for valid address and unitId', async () => { + it('returns coil value for valid address and unitId', () => { server.setBool({ uuid, unitId, registerType: 'coils', address: 5, state: true }) const cb = vi.fn() - await vector.getCoil(5, 1, cb) + vector.getCoil(5, 1, cb) expect(cb).toHaveBeenCalledWith(null, true) }) - it('returns false for unset coil address', async () => { + it('returns false for unset coil address', () => { server.setBool({ uuid, unitId, registerType: 'coils', address: 0, state: false }) const cb = vi.fn() - await vector.getCoil(0, 1, cb) + vector.getCoil(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, false) }) - it('returns error for invalid unitId (>255)', async () => { + it('returns error for invalid unitId (>255)', () => { const cb = vi.fn() - await vector.getCoil(0, 300, cb) + vector.getCoil(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), false ) }) - it('refuses a unit id it does not host', async () => { + it('refuses a unit id it does not host', () => { const cb = vi.fn() - await vector.getCoil(0, 1, cb) + vector.getCoil(0, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false @@ -2097,25 +2097,25 @@ describe('ModbusServer', () => { }) describe('getDiscreteInput', () => { - it('returns discrete input value', async () => { + it('returns discrete input value', () => { server.setBool({ uuid, unitId, registerType: 'discrete_inputs', address: 3, state: true }) const cb = vi.fn() - await vector.getDiscreteInput(3, 1, cb) + vector.getDiscreteInput(3, 1, cb) expect(cb).toHaveBeenCalledWith(null, true) }) - it('returns error for invalid unitId', async () => { + it('returns error for invalid unitId', () => { const cb = vi.fn() - await vector.getDiscreteInput(0, 300, cb) + vector.getDiscreteInput(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), false ) }) - it('refuses a unit id it does not host', async () => { + it('refuses a unit id it does not host', () => { const cb = vi.fn() - await vector.getDiscreteInput(0, 1, cb) + vector.getDiscreteInput(0, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false @@ -2124,7 +2124,7 @@ describe('ModbusServer', () => { }) describe('getInputRegister', () => { - it('returns input register value', async () => { + it('returns input register value', () => { server.addRegister({ uuid, unitId, @@ -2141,22 +2141,22 @@ describe('ModbusServer', () => { } }) const cb = vi.fn() - await vector.getInputRegister(10, 1, cb) + vector.getInputRegister(10, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) - it('returns error for invalid unitId', async () => { + it('returns error for invalid unitId', () => { const cb = vi.fn() - await vector.getInputRegister(0, 300, cb) + vector.getInputRegister(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 ) }) - it('refuses a unit id it does not host', async () => { + it('refuses a unit id it does not host', () => { const cb = vi.fn() - await vector.getInputRegister(0, 1, cb) + vector.getInputRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 @@ -2165,7 +2165,7 @@ describe('ModbusServer', () => { }) describe('getHoldingRegister', () => { - it('returns holding register value', async () => { + it('returns holding register value', () => { server.addRegister({ uuid, unitId, @@ -2182,22 +2182,22 @@ describe('ModbusServer', () => { } }) const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 999) }) - it('returns error for invalid unitId', async () => { + it('returns error for invalid unitId', () => { const cb = vi.fn() - await vector.getHoldingRegister(0, 300, cb) + vector.getHoldingRegister(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 ) }) - it('refuses a unit id it does not host', async () => { + it('refuses a unit id it does not host', () => { const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 @@ -2206,13 +2206,13 @@ describe('ModbusServer', () => { }) describe('setCoil', () => { - it('sets coil value and emits event', async () => { + it('sets coil value and emits event', () => { // First ensure data exists server.setBool({ uuid, unitId, registerType: 'coils', address: 0, state: false }) clearWindowCalls() const cb = vi.fn() - await vector.setCoil(10, true, 1, cb) + vector.setCoil(10, true, 1, cb) expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( 'register_value', @@ -2227,9 +2227,9 @@ describe('ModbusServer', () => { ) }) - it('refuses a unit id it does not host and leaves it unhosted', async () => { + it('refuses a unit id it does not host and leaves it unhosted', () => { const cb = vi.fn() - await vector.setCoil(5, true, 1, cb) + vector.setCoil(5, true, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 @@ -2237,16 +2237,16 @@ describe('ModbusServer', () => { // The write must not have created the unit it was refused for. const getCb = vi.fn() - await vector.getCoil(5, 1, getCb) + vector.getCoil(5, 1, getCb) expect(getCb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false ) }) - it('returns error for invalid unitId', async () => { + it('returns error for invalid unitId', () => { const cb = vi.fn() - await vector.setCoil(0, true, 300, cb) + vector.setCoil(0, true, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 @@ -2255,7 +2255,7 @@ describe('ModbusServer', () => { }) describe('setHoldingRegister', () => { - it('sets register value and emits event', async () => { + it('sets register value and emits event', () => { // First ensure data exists server.addRegister({ uuid, @@ -2275,7 +2275,7 @@ describe('ModbusServer', () => { clearWindowCalls() const cb = vi.fn() - await vector.setRegister(20, 12345, 1, cb) + vector.setRegister(20, 12345, 1, cb) expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( 'register_value', @@ -2290,9 +2290,9 @@ describe('ModbusServer', () => { ) }) - it('refuses a unit id it does not host and leaves it unhosted', async () => { + it('refuses a unit id it does not host and leaves it unhosted', () => { const cb = vi.fn() - await vector.setRegister(0, 500, 1, cb) + vector.setRegister(0, 500, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 @@ -2300,22 +2300,48 @@ describe('ModbusServer', () => { // The write must not have created the unit it was refused for. const getCb = vi.fn() - await vector.getHoldingRegister(0, 1, getCb) + vector.getHoldingRegister(0, 1, getCb) expect(getCb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 ) }) - it('returns error for invalid unitId', async () => { + it('returns error for invalid unitId', () => { const cb = vi.fn() - await vector.setRegister(0, 100, 300, cb) + vector.setRegister(0, 100, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 ) }) }) + + // `servertcp_handler.js` calls a three-argument accessor inside try/catch + // and discards what it returns, so a throw out of one is answered exception + // 4, slave device failure. A throw out of an `async` accessor is a rejection + // that try/catch never sees, and the client waits out its timeout instead. + describe('a throw out of a vector method', () => { + it('hands a throw out of getHoldingRegister to the caller', () => { + hostUnit() + const cb = vi.fn(() => { + throw new Error('the frame could not be written') + }) + expect(() => vector.getHoldingRegister(100, unitIdNumber, cb)).toThrow( + 'the frame could not be written' + ) + }) + + it('hands a throw out of setRegister to the caller', () => { + hostUnit() + vi.mocked(windows.send).mockImplementationOnce(() => { + throw new Error('the window is gone') + }) + expect(() => vector.setRegister(100, 7, unitIdNumber, vi.fn())).toThrow( + 'the window is gone' + ) + }) + }) }) // ─── C5: which unit ids the server answers for ──────────────────────────── @@ -2360,7 +2386,7 @@ describe('ModbusServer', () => { const vector = await tcpVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) @@ -2369,7 +2395,7 @@ describe('ModbusServer', () => { const vector = await tcpVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 5, cb) + vector.getHoldingRegister(0, 5, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 @@ -2381,7 +2407,7 @@ describe('ModbusServer', () => { const vector = await tcpVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 0, cb) + vector.getHoldingRegister(0, 0, cb) expect(cb).toHaveBeenCalledWith(null, 7) }) @@ -2391,15 +2417,15 @@ describe('ModbusServer', () => { const vector = await tcpVector() const setCb = vi.fn() - await vector.setRegister(9, 111, 0, setCb) + vector.setRegister(9, 111, 0, setCb) expect(setCb).toHaveBeenCalledWith(null) const zeroCb = vi.fn() - await vector.getHoldingRegister(9, 0, zeroCb) + vector.getHoldingRegister(9, 0, zeroCb) expect(zeroCb).toHaveBeenCalledWith(null, 111) const oneCb = vi.fn() - await vector.getHoldingRegister(9, 1, oneCb) + vector.getHoldingRegister(9, 1, oneCb) expect(oneCb).toHaveBeenCalledWith(null, 0) }) @@ -2408,7 +2434,7 @@ describe('ModbusServer', () => { const vector = await tcpVector() const cb = vi.fn() - await vector.getHoldingRegister(70000, 1, cb) + vector.getHoldingRegister(70000, 1, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), 0 @@ -2422,7 +2448,7 @@ describe('ModbusServer', () => { const vector = await rtuVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 1, cb) + vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) @@ -2431,7 +2457,7 @@ describe('ModbusServer', () => { const vector = await rtuVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 5, cb) + vector.getHoldingRegister(0, 5, cb) expect(cb).not.toHaveBeenCalled() }) @@ -2440,7 +2466,7 @@ describe('ModbusServer', () => { const vector = await rtuVector() const cb = vi.fn() - await vector.getCoil(0, 5, cb) + vector.getCoil(0, 5, cb) expect(cb).not.toHaveBeenCalled() }) @@ -2449,7 +2475,7 @@ describe('ModbusServer', () => { const vector = await rtuVector() const cb = vi.fn() - await vector.getHoldingRegister(0, 0, cb) + vector.getHoldingRegister(0, 0, cb) expect(cb).not.toHaveBeenCalled() }) @@ -2458,14 +2484,14 @@ describe('ModbusServer', () => { hostUnit('2', 0, 43) const vector = await rtuVector() - await vector.setRegister(9, 4242, 0, vi.fn()) + vector.setRegister(9, 4242, 0, vi.fn()) const oneCb = vi.fn() - await vector.getHoldingRegister(9, 1, oneCb) + vector.getHoldingRegister(9, 1, oneCb) expect(oneCb).toHaveBeenCalledWith(null, 4242) const twoCb = vi.fn() - await vector.getHoldingRegister(9, 2, twoCb) + vector.getHoldingRegister(9, 2, twoCb) expect(twoCb).toHaveBeenCalledWith(null, 4242) }) @@ -2474,7 +2500,7 @@ describe('ModbusServer', () => { const vector = await rtuVector() const cb = vi.fn() - await vector.setRegister(9, 4242, 0, cb) + vector.setRegister(9, 4242, 0, cb) expect(cb).not.toHaveBeenCalled() }) @@ -2483,14 +2509,14 @@ describe('ModbusServer', () => { hostUnit('2', 0, 43) const vector = await rtuVector() - await vector.setCoil(3, true, 0, vi.fn()) + vector.setCoil(3, true, 0, vi.fn()) const oneCb = vi.fn() - await vector.getCoil(3, 1, oneCb) + vector.getCoil(3, 1, oneCb) expect(oneCb).toHaveBeenCalledWith(null, true) const twoCb = vi.fn() - await vector.getCoil(3, 2, twoCb) + vector.getCoil(3, 2, twoCb) expect(twoCb).toHaveBeenCalledWith(null, true) }) diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index 876a55be..f7e260e5 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -344,6 +344,50 @@ describe('ValueGenerator', () => { comment: 'temperature' }) + gen.dispose() + }) + }) + // `_updateValue` is called by the constructor and by `setInterval`, and + // neither awaits it. An `async` one turns a throw into a rejection with no + // handler, so the generator keeps ticking and nothing says what went wrong. + describe('a throw out of an update', () => { + const failingWindows = (): Windows => + ({ + send: vi.fn(() => { + throw new Error('the window is gone') + }) + }) as unknown as Windows + + type Params = ConstructorParameters[0] + + const params = (windows: Windows): Params => ({ + uuid: 'test-uuid', + unitId: '1' as UnitIdString, + windows, + serverData: createServerData(), + registerType: 'holding_registers' as const, + address: 0, + dataType: 'uint16' as const, + min: 1, + max: 1, + littleEndian: false, + interval: 1000, + comment: '' + }) + + it('leaves the constructor by throwing', () => { + expect(() => new ValueGenerator(params(failingWindows()))).toThrow('the window is gone') + }) + + it('leaves the tick by throwing', () => { + const windows = createMockWindows() + const gen = new ValueGenerator(params(windows)) + vi.mocked(windows.send).mockImplementationOnce(() => { + throw new Error('the window is gone') + }) + + expect(() => vi.advanceTimersByTime(1000)).toThrow('the window is gone') + gen.dispose() }) }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 247ff822..2e516d3f 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -953,6 +953,11 @@ export class ModbusServer { /** * Returns the value of a register type for a given address and unitId. * Calls the callback with the value or a Modbus error. + * + * Synchronous, like `_set`: `servertcp_handler.js` calls a three-argument + * accessor inside try/catch and discards what it returns, so a throw from + * here is answered exception 4. An `async` accessor throws into a rejection + * nothing holds, and the client waits out its timeout instead. */ private _get: ( registerType: K, @@ -960,7 +965,7 @@ export class ModbusServer { transport: ServerTransport, fallback: ServerData[K][number] ) => IServiceVectorGet = - (registerType, uuid, transport, fallback) => async (address, unitIdNumber, cb) => { + (registerType, uuid, transport, fallback) => (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, fallback) // A broadcast is never acknowledged, so there is nothing to read from one. @@ -1009,7 +1014,7 @@ export class ModbusServer { uuid: string, transport: ServerTransport ) => IServiceVectorSet = - (registerType, uuid, transport) => async (address, value, unitIdNumber, cb) => { + (registerType, uuid, transport) => (address, value, unitIdNumber, cb) => { const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) const unitId = unitIdSafe.data diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 395eea68..35447366 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -128,8 +128,12 @@ export class ValueGenerator implements RegisterValueGenerator { /** * Generates a new value and updates the server data. + * + * Synchronous: the constructor and `setInterval` both call this without + * awaiting it, so an `async` one turns a throw into a rejection nothing + * holds. */ - private _updateValue = async (): Promise => { + private _updateValue = (): void => { switch (this._dataType) { case 'unix': { // Generator: current system time (seconds since epoch) From d0082c45bdb7691d9129b45dd71ec3b6a1acd86c Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:47:35 +0200 Subject: [PATCH 005/137] fix: the port walk answers a port it bound, or nothing `createServer` walked ten thousand ports with no upper bound and then returned the port it had stopped on, whether or not anything was listening there. The store writes that answer into the port field, so the field named a port no socket was on. The near way out is the port range rather than the count. `PortSchema` caps a configured port at 65535, so a server asked for 65535 with that port taken increments to 65536, and `net.createServer().listen(65536, '0.0.0.0')` throws there: sync throw: ERR_SOCKET_BAD_PORT | options.port should be >= 0 and < 65536. Received type number (65536). That throw is inside `_isPortAvailable`'s executor, so the promise rejects and the invoke rejects with it. At startup `openServer` catches that into `console.error`, which leaves the server neither up nor reported. `if (!isPort(actualPort)) break` ends the walk on the message that was already there, and the answer on that path is `undefined`. Both callers in `server.zustand.ts` already return on `undefined`, because the channel has a schema and a refused payload answers the same way. Two tests, both red first: the walk from 65535 constructed a `ServerTCP` on 65536 and answered it, and the exhausted walk answered 15020. --- CHANGELOG.md | 7 +++++++ src/main/modules/__tests__/modbusServer.test.ts | 17 +++++++++++++++-- src/main/modules/modbusServer.ts | 12 ++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df0f987a..44cda08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -286,6 +286,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Read, and a 0 you have not read looks exactly like a 0 the device holds. The values are now there as soon as the rows are. Disconnected it still shows the mapping alone, and it asks for nothing while a poll or a scan is running. +- **A server that gets no port now says so instead of showing one it never + bound.** A server whose port is taken moves up to the next free one and the + port field follows it. When the search ran out the field filled in anyway, + with a port nothing was listening on. A server configured on 65535 with that + port taken was worse: the search stepped to 65536, which is not a port, and + the server came up neither working nor reporting anything. Both now report + that no port was available and leave the field alone. ### Changed diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 4b5a23ba..0c562889 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1406,10 +1406,23 @@ describe('ModbusServer', () => { expect(await pending).toBe(5021) }) - it('emits error and returns port when no port available after max attempts', async () => { + it('emits error and answers nothing when no port is available after max attempts', async () => { portAvailableResults = new Array(10000).fill(false) const port = await server.createServer({ uuid, port: 5020 }) - expect(port).toBe(15020) // 5020 + 10000 + expect(port).toBeUndefined() + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'No available port found')).toBe(true) + }) + + // `net.createServer().listen(65536, '0.0.0.0')` throws `ERR_SOCKET_BAD_PORT` + // from inside `_isPortAvailable`'s executor, which rejects the invoke rather + // than answering it. The walk ends on the port range instead. + it('stops at 65535 rather than walking past it', async () => { + portAvailableResults = [false] + const port = await server.createServer({ uuid, port: 65535 }) + + expect(port).toBeUndefined() + expect(ServerTCP).not.toHaveBeenCalled() const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'No available port found')).toBe(true) }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 2e516d3f..0fdc0df9 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -383,8 +383,11 @@ export class ModbusServer { * built, so nothing about the register data needs a fresh listener, and a * port change is `setPort`'s job. Rebinding drops every connected master, so * it happens only where it buys something. + * + * `undefined` is "no listener, and a message is on its way". The walk ends on + * either bound, and the far one is ten thousand consecutive busy ports. */ - public createServer = async ({ uuid, port }: CreateServerParams): Promise => { + public createServer = async ({ uuid, port }: CreateServerParams): Promise => { // A stored 0 from before this was refused would send the server to a port // nobody can name, so it starts where it would have started without one. let actualPort = port !== undefined && isPort(port) ? port : DEFAULT_MOBUS_PORT @@ -395,6 +398,11 @@ export class ModbusServer { await this._closeAndForget(uuid) for (let i = 0; i < maxAttempts; i++) { + // 65535 taken increments to 65536, where `listen` throws + // `ERR_SOCKET_BAD_PORT` inside `_isPortAvailable`'s executor and the + // invoke rejects instead of answering. + if (!isPort(actualPort)) break + const result = await this._isPortAvailable(actualPort) if (result.available) { const bind = await this._bindServer(uuid, actualPort) @@ -407,7 +415,7 @@ export class ModbusServer { variant: 'error', error: undefined }) - return actualPort + return undefined } /** From 74337624b74e5ed015102977e48211438cdb52d7 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 17:56:11 +0200 Subject: [PATCH 006/137] test: the collaborator that really throws, and three stale comments Five findings from `/code-review` on the commit before this one. Both new vector tests threw from `windows.send`, which cannot throw: `Windows.send` wraps every `contents.send` in a try/catch, and the comment there says why. The callback the library hands the accessor is what does throw, at `responseBuffer.writeUInt16BE(value, ...)` in FC3's answer, so both tests throw from there now. `ValueGenerator`'s test threw from the same place. `createRegisters` is what throws inside an update: `createRegisters('uint16', 70000, false)` answers `The value of "value" is out of range. It must be >= 0 and <= 65535. Received 70000`. The constructor test drives that, and the tick test is gone, because a generator whose range constructs cannot draw a value the type refuses. Three comments described the old construction and its consequence: - `server.ts` gave the refine's reason as a quiet unhandled rejection every interval. The throw now leaves `addRegister`, which is the loud half and a stronger reason to keep the refine, and it names what a `setInterval` draw does instead. - `addRegister`'s docblock said the generator branch cannot be answered for. It can throw out of this method now, and the reason it gets no guard is that `RegisterParamsSchema` refuses the range, so no input reaches it. - `schemas.test.ts` named the same consequence over the same rule. Two `await`s on vector calls escaped the sweep, both in `creates nothing from a write to a unit it does not host`. --- .../modules/__tests__/modbusServer.test.ts | 15 ++++---- .../modules/__tests__/valueGenerator.test.ts | 36 ++++++------------- src/main/modules/modbusServer.ts | 10 +++--- src/shared/__tests__/schemas.test.ts | 6 ++-- src/shared/types/server.ts | 11 +++--- 5 files changed, 35 insertions(+), 43 deletions(-) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 0c562889..322fc916 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -2334,6 +2334,9 @@ describe('ModbusServer', () => { // and discards what it returns, so a throw out of one is answered exception // 4, slave device failure. A throw out of an `async` accessor is a rejection // that try/catch never sees, and the client waits out its timeout instead. + // + // What throws in both is the callback the library hands the accessor, which + // is where FC3's answer runs `responseBuffer.writeUInt16BE(value, ...)`. describe('a throw out of a vector method', () => { it('hands a throw out of getHoldingRegister to the caller', () => { hostUnit() @@ -2347,11 +2350,11 @@ describe('ModbusServer', () => { it('hands a throw out of setRegister to the caller', () => { hostUnit() - vi.mocked(windows.send).mockImplementationOnce(() => { - throw new Error('the window is gone') + const cb = vi.fn(() => { + throw new Error('the frame could not be written') }) - expect(() => vector.setRegister(100, 7, unitIdNumber, vi.fn())).toThrow( - 'the window is gone' + expect(() => vector.setRegister(100, 7, unitIdNumber, cb)).toThrow( + 'the frame could not be written' ) }) }) @@ -2538,11 +2541,11 @@ describe('ModbusServer', () => { const rtu = await rtuVector() const tcp = await tcpVector() - await rtu.setRegister(0, 500, 5, vi.fn()) + rtu.setRegister(0, 500, 5, vi.fn()) // The same uuid over TCP is the only way to ask whether unit 5 now exists. const cb = vi.fn() - await tcp.getHoldingRegister(0, 5, cb) + tcp.getHoldingRegister(0, 5, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index f7e260e5..e37d8cbe 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -349,46 +349,32 @@ describe('ValueGenerator', () => { }) // `_updateValue` is called by the constructor and by `setInterval`, and // neither awaits it. An `async` one turns a throw into a rejection with no - // handler, so the generator keeps ticking and nothing says what went wrong. + // handler, so the caller is told nothing and the generator keeps ticking. + // + // `createRegisters` is what throws: `createRegisters('uint16', 70000, false)` + // answers `The value of "value" is out of range. It must be >= 0 and <= 65535. + // Received 70000`. `RegisterParamsSchema` refuses that range at the boundary, + // so this is reached by constructing the generator directly. describe('a throw out of an update', () => { - const failingWindows = (): Windows => - ({ - send: vi.fn(() => { - throw new Error('the window is gone') - }) - }) as unknown as Windows - type Params = ConstructorParameters[0] - const params = (windows: Windows): Params => ({ + const unencodable = (): Params => ({ uuid: 'test-uuid', unitId: '1' as UnitIdString, - windows, + windows: createMockWindows(), serverData: createServerData(), registerType: 'holding_registers' as const, address: 0, dataType: 'uint16' as const, - min: 1, - max: 1, + min: 70000, + max: 70000, littleEndian: false, interval: 1000, comment: '' }) it('leaves the constructor by throwing', () => { - expect(() => new ValueGenerator(params(failingWindows()))).toThrow('the window is gone') - }) - - it('leaves the tick by throwing', () => { - const windows = createMockWindows() - const gen = new ValueGenerator(params(windows)) - vi.mocked(windows.send).mockImplementationOnce(() => { - throw new Error('the window is gone') - }) - - expect(() => vi.advanceTimersByTime(1000)).toThrow('the window is gone') - - gen.dispose() + expect(() => new ValueGenerator(unencodable())).toThrow('out of range') }) }) }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 0fdc0df9..ace89a42 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -521,10 +521,12 @@ export class ModbusServer { * in a bare loop, so the throw took every register after it in that unit * with it. One register is refused now and the rest of the unit stands. * - * The generator branch cannot be answered for here. `ValueGenerator` encodes - * inside an unawaited async tick, so a range its data type cannot take writes - * nothing and warns once per interval instead of throwing, which is `M-02` - * and is a change to that class. + * The generator branch throws the same way and gets no guard. + * `ValueGenerator` writes its first value from its own constructor, and since + * `_updateValue` stopped being `async` that throw leaves this method rather + * than becoming a rejection. `RegisterParamsSchema` holds `min` and `max` to + * their own data type, so no payload reaches it, and a branch here would be + * one no input turns red. */ public addRegister = ({ uuid, unitId, params }: AddRegisterParams): number[] | undefined => { const littleEndian = this._littleEndian.get(uuid) ?? false diff --git a/src/shared/__tests__/schemas.test.ts b/src/shared/__tests__/schemas.test.ts index 410be37c..a3250ee9 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -452,9 +452,9 @@ describe('Server RegisterParamsSchema — the width and the value', () => { }) // The generator draws between `min` and `max` and hands the draw to -// `createRegisters`, inside an `async` tick nobody awaits, so a range the data -// type cannot take answers 0 for as long as the generator runs and warns once -// per interval. The dialog masks both fields to `getMinMaxValues(dataType)` +// `createRegisters`, which throws on a value its type cannot hold. The draw +// runs in the constructor, so a range the data type cannot take throws out of +// `addRegister`. The dialog masks both fields to `getMinMaxValues(dataType)` // already, so the rule refuses nothing the Add button sends. describe('Server RegisterParamsSchema — what a generator draws between', () => { const generator = (overrides: Record): unknown => ({ diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index 90271429..a4c26a37 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -53,11 +53,12 @@ export type StartRtuServerParams = z.infer * * `min` and `max` are each held to their own data type, in the refine below, * because the generator draws between them and hands the draw to - * `createRegisters`. `ValueGenerator._updateValue` is `async` and nobody awaits - * it, so a draw the type cannot encode is not a throw the caller sees: the - * register answers 0 for as long as the generator runs and main takes an - * unhandled rejection every interval. Electron 43 stays alive through that, - * which is why it is quiet rather than loud. + * `createRegisters`, which throws on a value its type cannot hold. + * `ValueGenerator` writes its first value from its own constructor and + * `_updateValue` is synchronous, so that throw leaves `addRegister` and takes + * every register after it in the unit `syncServerRegisters` was restoring. The + * draws after the first are `setInterval`'s, where the same throw is an + * uncaught exception in main. * * The reason they were bare was that a rule here would refuse what the Add * button sends and cost the whole persisted register map, and both halves were From 072e76ed2f1947e54bdcfef13832ec6adf15f78d Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 18:00:18 +0200 Subject: [PATCH 007/137] fix: a walk that finds nothing puts the listener back One finding from `/code-review` on the commit before this one. `createServer` closes the listener a uuid has before it starts walking, so a walk that ends in `undefined` leaves that uuid with none and nothing puts it back. Loading a config that moves a running server onto a taken 65535 reaches it: the listener that was working is gone, `ready` stays false, and one message is the whole trace. `setPort` already answers this situation by rebinding the port it came from, and this reads the port before the close and does the same. The answer is that port, which is the one a listener is on. A restore that fails says so and answers `undefined`. Two tests, each turned red by taking out exactly the branch it names. --- CHANGELOG.md | 4 ++- .../modules/__tests__/modbusServer.test.ts | 34 +++++++++++++++++++ src/main/modules/modbusServer.ts | 14 ++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cda08e..debdf541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -292,7 +292,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with a port nothing was listening on. A server configured on 65535 with that port taken was worse: the search stepped to 65536, which is not a port, and the server came up neither working nor reporting anything. Both now report - that no port was available and leave the field alone. + that no port was available and leave the field alone. A server that was + already running keeps the port it had, rather than losing the one it was on to + a search that found nothing. ### Changed diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 322fc916..703c0582 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1414,6 +1414,40 @@ describe('ModbusServer', () => { expect(messages.some((m) => m[1].message === 'No available port found')).toBe(true) }) + // The listener that was up is closed before the walk starts, so a walk that + // finds nothing leaves the uuid with none. `setPort` puts the old one back + // and this is the same situation. + it('puts the listener back on the port it had when the walk finds nothing', async () => { + await server.createServer({ uuid, port: 5020 }) + clearWindowCalls() + portAvailableResults = [false] + + const port = await server.createServer({ uuid, port: 65535 }) + + expect(port).toBe(5020) + expect(ServerTCP).toHaveBeenLastCalledWith(expect.any(Object), { + host: '0.0.0.0', + port: 5020 + }) + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'No available port found')).toBe(true) + }) + + it('says so when the listener cannot go back on the port it had', async () => { + await server.createServer({ uuid, port: 5020 }) + clearWindowCalls() + portAvailableResults = [false] + bindResults = ['EADDRINUSE'] + + const port = await server.createServer({ uuid, port: 65535 }) + + expect(port).toBeUndefined() + const messages = getWindowCalls('backend_message') + expect( + messages.some((m) => m[1].message === 'The server could not be restarted on port 5020') + ).toBe(true) + }) + // `net.createServer().listen(65536, '0.0.0.0')` throws `ERR_SOCKET_BAD_PORT` // from inside `_isPortAvailable`'s executor, which rejects the invoke rather // than answering it. The walk ends on the port range instead. diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index ace89a42..4a849bd1 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -395,6 +395,8 @@ export class ModbusServer { if (this._servers.has(uuid) && this._port.get(uuid) === actualPort) return actualPort + // Read before the close, because that is what forgets it. + const previousPort = this._port.get(uuid) await this._closeAndForget(uuid) for (let i = 0; i < maxAttempts; i++) { @@ -415,6 +417,18 @@ export class ModbusServer { variant: 'error', error: undefined }) + + // The listener that was up was closed before the walk, so put it back + // rather than leave the uuid with none. `setPort` answers the same way. + if (previousPort === undefined) return undefined + + const restored = await this._bindServer(uuid, previousPort) + if (restored.ok) return previousPort + + this._emitMessage({ + message: `The server could not be restarted on port ${previousPort}`, + variant: 'error' + }) return undefined } From 48a306e0c1f02d1fedd6a0f060406ceb552977a7 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 20:02:57 +0200 Subject: [PATCH 008/137] fix: three channels that said one thing and did another `set_register_mapping` answered `void` where `update_connection_config` and `update_register_config` answer `true | undefined`, and the spec states that convention in the doc comment above the first of them. It answers now, and `ipc.test.ts` reads all three through `initIpc`: `true` for a payload taken, `undefined` for one refused. The renderer bails on the refusal where a caller leans on main holding the mapping. `flushRegisterMappingToMain` takes the mapping and answers whether main took it, `replaceRegisterMapping` writes the store after that answer rather than before it, and the Read Configuration toggle stops before `showMapping` and before it turns the flag on. The debounced cell edit reads no answer and says why: nothing waits on it, a refusal reports itself as a `backend_message`, and the next edit sends the whole mapping again. Neither the Clear button nor the Load button grows a branch for it. No shipped payload reaches the refusal, so a branch there is one no test could turn red. `read`'s comment said it returns `RegisterData[] | undefined` above `return: void`. The rows go out as a `register_data` event, and the comment says that. `rtu_server_status` carried `{ active: boolean }` while `get_rtu_server_status` answered `boolean`, and the spec's comment on the second called that the same shape as `get_client_state`, which carries one type on both halves. The event carries `boolean` now, three send sites and one listener, and `scan_progress` was already a bare primitive. Two tests were red first: the store keeping the mapping it had when main refuses the new one, and the toggle staying off. `stubRenderer` answers the new channel with the schema `main/ipc.ts` guards it with, because a stub answering `undefined` would make every store test read a refusal. --- src/main/__tests__/ipc.test.ts | 13 ++++++ src/main/ipc.ts | 5 ++- .../modules/__tests__/modbusServer.test.ts | 14 +++--- src/main/modules/modbusServer.ts | 6 +-- .../RegisterGridToolbar/ClearConfigButton.tsx | 2 +- .../RegisterGridToolbar/LoadButton.tsx | 2 +- .../client/RegisterConfig/RegisterConfig.tsx | 10 +++-- .../__tests__/RegisterConfig.test.tsx | 43 +++++++++++++++++- .../context/__tests__/client.zustand.test.ts | 44 ++++++++++++++++++- .../__tests__/registerMappingToMain.test.ts | 20 ++++----- .../src/context/__tests__/stubRenderer.ts | 4 +- src/renderer/src/context/client.zustand.ts | 29 +++++++++--- .../src/context/client.zustand.types.ts | 5 ++- src/renderer/src/context/server.zustand.ts | 2 +- src/shared/types/ipc.ts | 11 ++--- 15 files changed, 165 insertions(+), 45 deletions(-) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index c6beb6f2..4e8e5ac8 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -586,6 +586,19 @@ describe('each guarded channel got its own schema', () => { expect(guarded.sort()).toEqual(Object.keys(validPayloads).sort()) }) + // The convention `update_connection_config`'s doc comment states: a channel + // whose payload can be refused says whether it took it, so the store can read + // the refusal rather than diverge from main in silence. + it.each(['update_connection_config', 'update_register_config', 'set_register_mapping'])( + '%s answers true for a payload it took and undefined for one it refused', + async (channel) => { + start() + + expect(await invoke(channel, validPayloads[channel])).toBe(true) + expect(await invoke(channel, undefined)).toBeUndefined() + } + ) + it.each(Object.keys(validPayloads))('lets a valid %s payload through', async (channel) => { const { sent } = start() await invoke(channel, validPayloads[channel]) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e3888c49..68d45af4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -194,7 +194,10 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { // Register mapping ipcHandle( 'set_register_mapping', - (_, mapping) => state.setRegisterMapping(mapping), + (_, mapping) => { + state.setRegisterMapping(mapping) + return true + }, RegisterMappingSchema ) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 703c0582..f12874c8 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1727,7 +1727,7 @@ describe('ModbusServer', () => { instance._handlers['initialized']() const statusCalls = getWindowCalls('rtu_server_status') - expect(statusCalls.some((c) => c[1].active === true)).toBe(true) + expect(statusCalls).toContainEqual(['rtu_server_status', true, 'serverView']) const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message.includes('/dev/ttyUSB0'))).toBe(true) @@ -1768,7 +1768,7 @@ describe('ModbusServer', () => { instance._handlers['socketError'](new Error('port gone')) const statusCalls = getWindowCalls('rtu_server_status') - expect(statusCalls.some((c) => c[1].active === false)).toBe(true) + expect(statusCalls).toContainEqual(['rtu_server_status', false, 'serverView']) const messageCalls = getWindowCalls('backend_message') expect(messageCalls.some((c) => c[1].message.includes('port gone'))).toBe(true) @@ -1780,7 +1780,7 @@ describe('ModbusServer', () => { fireOpenCallback(new Error('cannot open /dev/ttyUSB0')) - expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) + expect(getWindowCalls('rtu_server_status').at(-1)?.[1]).toBe(false) expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server error: cannot open /dev/ttyUSB0' ]) @@ -1795,7 +1795,7 @@ describe('ModbusServer', () => { const instance = lastInstance(ServerSerial) instance._handlers['initialized']() - expect(getWindowCalls('rtu_server_status').map((c) => c[1].active)).toEqual([true]) + expect(getWindowCalls('rtu_server_status').map((c) => c[1])).toEqual([true]) expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server started on /dev/ttyUSB0' ]) @@ -1853,7 +1853,7 @@ describe('ModbusServer', () => { fireSerialPathEvent(instance, 'close', new Error('Disconnected')) - expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) + expect(getWindowCalls('rtu_server_status').at(-1)?.[1]).toBe(false) const messageCalls = getWindowCalls('backend_message') expect( messageCalls.some((c) => c[1].message === 'RTU server disconnected from /dev/ttyUSB0') @@ -1934,7 +1934,7 @@ describe('ModbusServer', () => { expect(getWindowCalls('backend_message').map((c) => c[1].message)).toEqual([ 'RTU server started on /dev/ttyUSB0' ]) - expect(getWindowCalls('rtu_server_status').map((c) => c[1].active)).toEqual([false, true]) + expect(getWindowCalls('rtu_server_status').map((c) => c[1])).toEqual([false, true]) }) it('a stopped server does not report its failed open', async () => { @@ -1986,7 +1986,7 @@ describe('ModbusServer', () => { expect(instance.close).toHaveBeenCalled() const statusCalls = getWindowCalls('rtu_server_status') - expect(statusCalls.at(-1)?.[1].active).toBe(false) + expect(statusCalls.at(-1)?.[1]).toBe(false) }) it('emits warning message only when server was active', async () => { diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 4a849bd1..5b4782ee 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -780,7 +780,7 @@ export class ModbusServer { if (generation !== this._rtuGeneration) return this._rtuActive = false this._emitMessage({ message, variant: 'error', error }) - this._windows.send('rtu_server_status', { active: false }, 'serverView') + this._windows.send('rtu_server_status', false, 'serverView') } /** @@ -850,7 +850,7 @@ export class ModbusServer { message: `RTU server started on ${serialConfig.com}`, variant: 'success' }) - this._windows.send('rtu_server_status', { active: true }, 'serverView') + this._windows.send('rtu_server_status', true, 'serverView') this._warnBroadcastUnit(uuid) }) @@ -882,7 +882,7 @@ export class ModbusServer { this._rtuActive = false this._rtuGeneration++ this._broadcastWarningSent = false - this._windows.send('rtu_server_status', { active: false }, 'serverView') + this._windows.send('rtu_server_status', false, 'serverView') if (wasActive) { this._emitMessage({ message: 'RTU server stopped', variant: 'warning' }) } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx index 0a7fbcca..4e023349 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx @@ -10,7 +10,7 @@ const ClearConfigButton = meme((): JSX.Element => { const handleClick = useCallback(() => { useClientZustand.getState().setName('') - useClientZustand.getState().clearRegisterMapping() + void useClientZustand.getState().clearRegisterMapping() }, []) return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx index ab66cac4..864a8b13 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx @@ -33,7 +33,7 @@ const LoadButton = meme((): JSX.Element => { // Set name, endianness and register mapping if (config.name) clientZustand.setName(config.name) clientZustand.setLittleEndian(config.littleEndian) - clientZustand.replaceRegisterMapping(config.registerMapping) + await clientZustand.replaceRegisterMapping(config.registerMapping) // Show success notification if (migrated) { diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index b7dc4735..03eceb84 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -103,13 +103,15 @@ const Length = meme(() => { const ReadConfiguration = meme(() => { const readConfiguration = useClientZustand((z) => !!z.readConfiguration) - const handleChange = useCallback((_: React.MouseEvent, v: boolean | null) => { + const handleChange = useCallback(async (_: React.MouseEvent, v: boolean | null) => { const toggleState = !!v - // When read configuration is enabled, send the configuration to the backend API - // and immediately show the configured registers in the grid + // Turning it on hands main the mapping and then asks it to read out of that + // mapping, so a refusal here would read out of the one before it. Turning it + // off asks main for nothing. if (toggleState) { - flushRegisterMappingToMain() + const { registerMapping } = useClientZustand.getState() + if (!(await flushRegisterMappingToMain(registerMapping))) return showMapping() } useClientZustand.getState().setReadConfiguration(toggleState) diff --git a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx index b0903e0c..c32fcef4 100644 --- a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx +++ b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx @@ -13,7 +13,7 @@ vi.hoisted(() => { w.api = new Proxy({}, { get: () => () => Promise.resolve(undefined) }) }) -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { useClientZustand } from '@renderer/context/client.zustand' import { defaultClientState, RegisterType } from '@shared' import RegisterConfig from '../RegisterConfig' @@ -121,3 +121,44 @@ describe('RegisterConfig read configuration', () => { expect(useClientZustand.getState().readConfiguration).toBe(true) }) }) + +// Turning it on hands main the mapping and then asks it to read out of that +// mapping, so a refused mapping would read out of the one before it. +describe('RegisterConfig turning read configuration on', () => { + // The stub above is a Proxy with a `get` trap and no keys, so spreading it + // answers nothing: every channel it does not name would be gone. + const answerWith = (answer: true | undefined): void => { + const stubbed = window.api as unknown as Record + const named: Record = { + setRegisterMapping: vi.fn(() => Promise.resolve(answer)), + setReadConfiguration: vi.fn(), + read: vi.fn() + } + window.api = new Proxy( + {}, + { get: (_target, method: string) => named[method] ?? stubbed[method] } + ) as never + } + + it('turns on once main has the mapping', async () => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + answerWith(true) + + render() + fireEvent.click(screen.getByTestId('reg-read-config-btn')) + + await waitFor(() => expect(useClientZustand.getState().readConfiguration).toBe(true)) + }) + + it('stays off when main refuses the mapping', async () => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + answerWith(undefined) + + render() + fireEvent.click(screen.getByTestId('reg-read-config-btn')) + + await waitFor(() => expect(window.api.setRegisterMapping).toHaveBeenCalled()) + expect(useClientZustand.getState().readConfiguration).toBe(false) + expect(window.api.setReadConfiguration).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/context/__tests__/client.zustand.test.ts b/src/renderer/src/context/__tests__/client.zustand.test.ts index 24940856..638670ff 100644 --- a/src/renderer/src/context/__tests__/client.zustand.test.ts +++ b/src/renderer/src/context/__tests__/client.zustand.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { ClientState } from '@shared' +import type { ClientState, RegisterMapping } from '@shared' /** * The store registers its IPC listeners and calls `init()` at import time, so @@ -138,3 +138,45 @@ describe('the client_state listener', () => { expect(useClientZustand.getState().clientState).toEqual(connectedAndPolling) }) }) + +describe('replacing the register mapping', () => { + const mapping = (comment: string): RegisterMapping => ({ + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { 0: { comment } } + }) + + const answerWith = (answer: true | undefined): ReturnType => { + const setRegisterMapping = vi.fn(() => Promise.resolve(answer)) + window.api = { ...window.api, setRegisterMapping } as never + return setRegisterMapping + } + + beforeEach(() => { + useClientZustand.setState({ registerMapping: mapping('the one it had') } as never) + }) + + it('writes the new one when main took it', async () => { + answerWith(true) + + await useClientZustand.getState().replaceRegisterMapping(mapping('the new one')) + + expect(useClientZustand.getState().registerMapping.holding_registers[0]?.comment).toBe( + 'the new one' + ) + }) + + // Main keeps the mapping it had when it refuses one, so writing here would + // leave the two reading different registers with nothing to say so. + it('keeps the one it had when main refused the new one', async () => { + const setRegisterMapping = answerWith(undefined) + + await useClientZustand.getState().replaceRegisterMapping(mapping('the new one')) + + expect(setRegisterMapping).toHaveBeenCalled() + expect(useClientZustand.getState().registerMapping.holding_registers[0]?.comment).toBe( + 'the one it had' + ) + }) +}) diff --git a/src/renderer/src/context/__tests__/registerMappingToMain.test.ts b/src/renderer/src/context/__tests__/registerMappingToMain.test.ts index 21c148bd..4df8ac16 100644 --- a/src/renderer/src/context/__tests__/registerMappingToMain.test.ts +++ b/src/renderer/src/context/__tests__/registerMappingToMain.test.ts @@ -51,7 +51,7 @@ describe('a mapping that replaces the whole of the previous one', () => { it('reaches main, types the current one included and not', async () => { const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) expect(lastPayload('setRegisterMapping')).toEqual(loaded) }) @@ -60,7 +60,7 @@ describe('a mapping that replaces the whole of the previous one', () => { vi.useFakeTimers() const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) expect(methods()).toContain('setRegisterMapping') }) @@ -70,7 +70,7 @@ describe('a mapping that replaces the whole of the previous one', () => { useClientZustand.getState().setReadConfiguration(true) calls.length = 0 - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) expect(methods()).toEqual(['setReadConfiguration', 'setRegisterMapping']) expect(lastPayload('setReadConfiguration')).toBe(false) @@ -80,7 +80,7 @@ describe('a mapping that replaces the whole of the previous one', () => { it('is what the store holds', async () => { const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) expect(useClientZustand.getState().registerMapping).toEqual(loaded) }) @@ -89,10 +89,10 @@ describe('a mapping that replaces the whole of the previous one', () => { describe('a cleared mapping', () => { it('reaches main empty, for all four types', async () => { const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) calls.length = 0 - useClientZustand.getState().clearRegisterMapping() + await useClientZustand.getState().clearRegisterMapping() expect(lastPayload('setRegisterMapping')).toEqual({ coils: {}, @@ -104,11 +104,11 @@ describe('a cleared mapping', () => { it('turns read configuration off first', async () => { const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) useClientZustand.getState().setReadConfiguration(true) calls.length = 0 - useClientZustand.getState().clearRegisterMapping() + await useClientZustand.getState().clearRegisterMapping() expect(methods()).toEqual(['setReadConfiguration', 'setRegisterMapping']) expect(useClientZustand.getState().readConfiguration).toBe(false) @@ -116,9 +116,9 @@ describe('a cleared mapping', () => { it('leaves the store holding four empty records', async () => { const { useClientZustand } = await loadStore() - useClientZustand.getState().replaceRegisterMapping(loaded) + await useClientZustand.getState().replaceRegisterMapping(loaded) - useClientZustand.getState().clearRegisterMapping() + await useClientZustand.getState().clearRegisterMapping() expect(useClientZustand.getState().registerMapping).toEqual({ coils: {}, diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts index 617b413b..1ce91ab6 100644 --- a/src/renderer/src/context/__tests__/stubRenderer.ts +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -2,7 +2,8 @@ import { AddRegisterParamsSchema, ClientState, ConnectionConfigSchema, - RegisterConfigSchema + RegisterConfigSchema, + RegisterMappingSchema } from '@shared' /** @@ -39,6 +40,7 @@ const disconnected: ClientState = { const answers: Record Promise> = { updateConnectionConfig: answerConfig(ConnectionConfigSchema.deepPartial()), updateRegisterConfig: answerConfig(RegisterConfigSchema.deepPartial()), + setRegisterMapping: answerConfig(RegisterMappingSchema), // The words main answers with, which for a payload it refuses is nothing at // all rather than an empty list. addReplaceServerRegister: (payload: unknown): Promise => diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 95f6c908..82adab7d 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -15,7 +15,8 @@ import { migrateClientState, carryFormerClientState, CLIENT_ZUSTAND_STORAGE_KEY, - emptyRegisterMapping + emptyRegisterMapping, + RegisterMapping } from '@shared' import { useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' @@ -35,21 +36,31 @@ let _ipcTimer: ReturnType | null = null function syncRegisterMappingToMain(): void { if (_ipcTimer) clearTimeout(_ipcTimer) _ipcTimer = setTimeout(() => { - window.api.setRegisterMapping(useClientZustand.getState().registerMapping) + _ipcTimer = null + // Nothing waits on a cell edit reaching main, so the answer has nobody to + // stop. A refusal reports itself as a `backend_message` and costs main the + // edit, and the next edit sends the whole mapping again. + void window.api.setRegisterMapping(useClientZustand.getState().registerMapping) }, 150) } /** - * Sends the mapping now instead of in 150 ms. + * Sends `registerMapping` now instead of in 150 ms, and answers whether main + * took it. * * For a caller that needs the backend to hold the mapping before its next * request. Turning on read configuration reads straight afterwards, and the * debounce would let that read go out against the mapping from before. + * + * It takes the mapping rather than reading the store, because a caller that + * writes only once main has it has nothing in the store to send yet. */ -export const flushRegisterMappingToMain = (): void => { +export const flushRegisterMappingToMain = async ( + registerMapping: RegisterMapping +): Promise => { if (_ipcTimer) clearTimeout(_ipcTimer) _ipcTimer = null - window.api.setRegisterMapping(useClientZustand.getState().registerMapping) + return (await window.api.setRegisterMapping(registerMapping)) ?? false } /** @@ -167,16 +178,20 @@ export const useClientZustand = create< syncRegisterMappingToMain() }, - replaceRegisterMapping: (registerMapping) => { + replaceRegisterMapping: async (registerMapping) => { // Read configuration is the one thing that makes main read the mapping, // so turning it off first leaves no read answering out of the mapping // this call throws away. `syncRegisterMappingToMain` debounces for // rapid cell edits, and a whole new mapping is not one. get().setReadConfiguration(false) + + // Main keeps the mapping it had when it refuses one, so a write here + // would leave the grid showing registers main is not holding. + if (!(await flushRegisterMappingToMain(registerMapping))) return + set((state) => { state.registerMapping = registerMapping }) - flushRegisterMappingToMain() }, clearRegisterMapping: () => get().replaceRegisterMapping(emptyRegisterMapping()), transactions: [], diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 824b92c5..deb24514 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -49,8 +49,9 @@ export type ClientZustand = { key: K, value: V ) => void - replaceRegisterMapping: (registerMapping: RegisterMapping) => void - clearRegisterMapping: () => void + /** Answers once main has the mapping, because the store writes it after that. */ + replaceRegisterMapping: (registerMapping: RegisterMapping) => Promise + clearRegisterMapping: () => Promise /** What the persisted config lost on the way in, or undefined when it lost nothing. */ configReset: ConfigReset | undefined /** Called once the reset has been reported, so it is reported once. */ diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index c15abe42..b71b12c9 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -808,7 +808,7 @@ export const applyRegisterValue = (payload: RegisterValue): void => { onEvent('register_value', applyRegisterValue) // RTU server status -onEvent('rtu_server_status', ({ active }) => { +onEvent('rtu_server_status', (active) => { useServerZustand.setState({ rtuServerActive: active }) }) diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 00a00c1e..622e4aef 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -141,10 +141,10 @@ export interface IpcHandlerSpec { return: ClientState } - /** Set the RegisterMapping */ + /** Set the RegisterMapping, and say whether it was taken. */ ['set_register_mapping']: { args: [RegisterMapping] - return: void + return: true | undefined } /** Connect the Modbus client */ @@ -159,7 +159,7 @@ export interface IpcHandlerSpec { return: void } - /** Read registers (returns RegisterData[] or undefined) */ + /** Read the configured registers. The rows come back as a `register_data` event. */ ['read']: { args: [] return: void @@ -323,7 +323,8 @@ export interface IpcHandlerSpec { * * `rtu_server_status` is addressed to the window showing the server, so a * window that was not that window when the last one went out has nothing to - * catch up on. This is how it asks, the same shape as `get_client_state`. + * catch up on. This is how it asks, and it answers what that event carries, + * the way `get_client_state` answers what `client_state` carries. */ ['get_rtu_server_status']: { args: [] @@ -401,7 +402,7 @@ export interface IpcEventPayloadMap { ['window_update']: [WindowsOpen] ['open_server_window']: [] ['address_groups']: [AddressGroup[]] - ['rtu_server_status']: [{ active: boolean }] + ['rtu_server_status']: [boolean] } export interface BackendMessage { From f5d1892b9b41436e36daa1cc36d83cc6027bbed7 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 20:06:49 +0200 Subject: [PATCH 009/137] fix: the update notice belongs to the window that can open a link `App.tsx` renders `` above the view switch and both windows load the same bundle, so the split out server window asked GitHub for the latest release too and drew the same banner. `setWindowOpenHandler` was on the main window alone, so the release link opened a second Modbux window on the release page instead of the browser. The banner asks nothing and draws nothing in the split out window, which is one request per launch rather than two. The server window gets the handler the main window has, because both windows load the same bundle and a link that opens in the browser from one has to from the other. `FORCE_SHOW_BANNER` was `false` beside two reads of it, so `!FORCE_SHOW_BANNER` was always true and `FORCE_SHOW_BANNER ||` always fell through. Neither branch could turn a test red, and both are gone. --- CHANGELOG.md | 5 ++++ src/main/index.ts | 7 +++++ src/renderer/src/components/UpdateBanner.tsx | 11 +++++--- .../__tests__/UpdateBanner.test.tsx | 28 +++++++++++++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index debdf541..a60630db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -295,6 +295,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that no port was available and leave the field alone. A server that was already running keeps the port it had, rather than losing the one it was on to a search that found nothing. +- **The update notice no longer opens GitHub in a Modbux window.** The split out + server window showed the same notice as the main one, and its Download latest + release link opened the release page in a second Modbux window rather than in + the browser. The notice is the main window's alone now, and a link from either + window opens in the browser. ### Changed diff --git a/src/main/index.ts b/src/main/index.ts index a9c6bd4b..521b9b8f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,6 +145,13 @@ onIpcEvent('open_server_window', () => { backgroundColor: '#181818' }) + // The same handler the main window has. Both windows load the same bundle, so + // a link that opens in the browser from one of them has to from the other. + windows.server.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 6b9bf8ee..3422f037 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -7,8 +7,6 @@ import CloseIcon from '@mui/icons-material/Close' import { meme } from '@renderer/components/shared/inputs/meme' import { useEffect, useState } from 'react' -const FORCE_SHOW_BANNER = false // Set to true for testing - interface GitHubRelease { tag_name: string html_url: string @@ -22,9 +20,14 @@ const UpdateBanner = meme((): JSX.Element | null => { useEffect(() => { const checkForUpdates = async (): Promise => { try { + // The split out window loads the same bundle, so it asked GitHub for the + // same release and drew the same banner. `setWindowOpenHandler` is on the + // main window alone, so the release link opened inside Electron there. + if (window.api.isServerWindow) return + // Check if banner was dismissed in this session const dismissed = sessionStorage.getItem('updateBannerDismissed') - if (dismissed && !FORCE_SHOW_BANNER) return + if (dismissed) return // Fetch latest release from GitHub API const response = await fetch('https://api.github.com/repos/ploxc/modbux/releases/latest', { @@ -46,7 +49,7 @@ const UpdateBanner = meme((): JSX.Element | null => { const currentVersion = await window.api.getAppVersion() // Compare versions - if (FORCE_SHOW_BANNER || isNewerVersion(latestTag, currentVersion)) { + if (isNewerVersion(latestTag, currentVersion)) { setLatestVersion(latestTag) setReleaseUrl(release.html_url) setShowBanner(true) diff --git a/src/renderer/src/components/__tests__/UpdateBanner.test.tsx b/src/renderer/src/components/__tests__/UpdateBanner.test.tsx index abd17f1e..6ea6c5c4 100644 --- a/src/renderer/src/components/__tests__/UpdateBanner.test.tsx +++ b/src/renderer/src/components/__tests__/UpdateBanner.test.tsx @@ -7,10 +7,11 @@ import UpdateBanner from '../UpdateBanner' // Mock window.api const mockGetAppVersion = vi.fn() -// @ts-expect-error - Mocking window.api for tests -global.window.api = { - getAppVersion: mockGetAppVersion +const asServerWindow = (isServerWindow: boolean): void => { + // @ts-expect-error - Mocking window.api for tests + global.window.api = { getAppVersion: mockGetAppVersion, isServerWindow } } +asServerWindow(false) // Mock fetch const mockFetch = vi.fn() as ReturnType @@ -20,6 +21,7 @@ describe('UpdateBanner', () => { beforeEach(() => { vi.clearAllMocks() sessionStorage.clear() + asServerWindow(false) }) afterEach(() => { @@ -211,6 +213,26 @@ describe('UpdateBanner', () => { consoleSpy.mockRestore() }) + // `setWindowOpenHandler` is on the main window alone, so the release link + // opened inside Electron in the split out one. Both windows load the same + // bundle, so both asked GitHub for the latest release as well. + it('asks nothing and shows nothing in the split out window', async () => { + asServerWindow(true) + mockGetAppVersion.mockResolvedValue('1.4.0') + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + tag_name: 'v1.5.0', + html_url: 'https://github.com/ploxc/modbux/releases/tag/v1.5.0' + }) + }) + + render() + + await waitFor(() => expect(mockFetch).not.toHaveBeenCalled()) + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument() + }) + it('should open link in new tab with security attributes', async () => { mockGetAppVersion.mockResolvedValue('1.4.0') mockFetch.mockResolvedValue({ From c1268cef2f0fc48382f7a4efc5afe7391fd5b979 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 20:12:27 +0200 Subject: [PATCH 010/137] docs: three comments the commit before them made false `/code-review` on that commit found no defect and three claims. Two of them gave the banner's guard its reason as `setWindowOpenHandler` being on the main window alone, which the same commit stopped being true. The reason is one notice per launch, in the window Modbux opened with. The third called the server window's new handler the answer to a link that window has. It has none: `appType` is pinned to `server` there, so `Home`'s two external links never render, and the banner's link left with the banner. The handler is what a window without one does instead, which is to answer `window.open` with a Modbux window on whatever the link points at. --- src/main/index.ts | 5 +++-- src/renderer/src/components/UpdateBanner.tsx | 6 +++--- src/renderer/src/components/__tests__/UpdateBanner.test.tsx | 5 ++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 521b9b8f..16f2e9a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,8 +145,9 @@ onIpcEvent('open_server_window', () => { backgroundColor: '#181818' }) - // The same handler the main window has. Both windows load the same bundle, so - // a link that opens in the browser from one of them has to from the other. + // The handler the main window has. Nothing in the server view opens a link + // today, and a window without one answers `window.open` with a Modbux window + // on whatever the link points at. windows.server.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 3422f037..ae10a367 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -20,9 +20,9 @@ const UpdateBanner = meme((): JSX.Element | null => { useEffect(() => { const checkForUpdates = async (): Promise => { try { - // The split out window loads the same bundle, so it asked GitHub for the - // same release and drew the same banner. `setWindowOpenHandler` is on the - // main window alone, so the release link opened inside Electron there. + // The split out window loads the same bundle, so it asked GitHub for + // the same release and drew a second copy of the same banner. One + // notice per launch, in the window Modbux opened with. if (window.api.isServerWindow) return // Check if banner was dismissed in this session diff --git a/src/renderer/src/components/__tests__/UpdateBanner.test.tsx b/src/renderer/src/components/__tests__/UpdateBanner.test.tsx index 6ea6c5c4..ad8e7d36 100644 --- a/src/renderer/src/components/__tests__/UpdateBanner.test.tsx +++ b/src/renderer/src/components/__tests__/UpdateBanner.test.tsx @@ -213,9 +213,8 @@ describe('UpdateBanner', () => { consoleSpy.mockRestore() }) - // `setWindowOpenHandler` is on the main window alone, so the release link - // opened inside Electron in the split out one. Both windows load the same - // bundle, so both asked GitHub for the latest release as well. + // Both windows load the same bundle, so both asked GitHub for the latest + // release and both drew the banner. it('asks nothing and shows nothing in the split out window', async () => { asServerWindow(true) mockGetAppVersion.mockResolvedValue('1.4.0') From 33ee46b8fa0d96e6f15655d7a6d50781b77eb067 Mon Sep 17 00:00:00 2001 From: Harted Date: Thu, 17 Sep 2026 20:15:36 +0200 Subject: [PATCH 011/137] fix: a second press inside the round trip the store now waits for Five findings from `/code-review` on `afd5748`, the commit that made the Read Configuration toggle wait for main. The store is written after that round trip, so between the press and the answer the toggle still read off and a second press arrived as one more turn-on: a second flush, a second `showMapping` and a second read, which main refuses with the warning the `disabled` gate exists to avoid. One press is taken while the first is in flight, and a test presses twice against an answer it holds open. Two comments and two tests, all about what a refusal costs: - `replaceRegisterMapping` turns read configuration off before it asks and does not turn it back on. That is the price of a refusal and the comment says so: both sides keep the mapping from before, and main's message names the channel. - Both new store tests ran with `ready` false, where the action returns at `setReadConfiguration`'s own guard, so they asserted the accept and refuse gate and nothing else. They run the whole action now and assert read configuration went off. `ipc.test.ts` read the answer for three channels named by hand. The refusal half holds for every guarded channel and is read from the handlers instead, so a channel that starts answering a value is held to `undefined` on a refusal without joining a list. The Load and Clear buttons still grow no branch. `migrateClientConfig` validates the mapping against the schema main guards the channel with, so the refusal is unreachable from either. --- src/main/__tests__/ipc.test.ts | 14 +++++++-- .../client/RegisterConfig/RegisterConfig.tsx | 20 +++++++++--- .../__tests__/RegisterConfig.test.tsx | 31 +++++++++++++++++++ .../context/__tests__/client.zustand.test.ts | 13 +++++++- src/renderer/src/context/client.zustand.ts | 5 ++- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index 4e8e5ac8..5b3f2f23 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -589,13 +589,16 @@ describe('each guarded channel got its own schema', () => { // The convention `update_connection_config`'s doc comment states: a channel // whose payload can be refused says whether it took it, so the store can read // the refusal rather than diverge from main in silence. + // + // Written by hand, because what a channel answers for a payload it took is + // its own decision and not something the handler list tells apart. The + // refusal half of the same rule is read from the handlers below. it.each(['update_connection_config', 'update_register_config', 'set_register_mapping'])( - '%s answers true for a payload it took and undefined for one it refused', + '%s answers true for a payload it took', async (channel) => { start() expect(await invoke(channel, validPayloads[channel])).toBe(true) - expect(await invoke(channel, undefined)).toBeUndefined() } ) @@ -607,11 +610,16 @@ describe('each guarded channel got its own schema', () => { // `undefined` is the payload every schema here refuses. A string used to be, // until `delete_server` and `reset_server` took one. + // + // A refused payload answers `undefined` whatever the channel answers when it + // takes one, which is the rule `PayloadSchema` holds a channel to. Read here + // rather than from a list, so a channel that starts answering a value is held + // to it without anyone adding it anywhere. it.each(Object.keys(validPayloads))( 'guards %s against a payload that is not one', async (channel) => { const { sent } = start() - await invoke(channel, undefined) + expect(await invoke(channel, undefined)).toBeUndefined() expect(sent.map(({ message }) => String(message.error).split(':')[0])).toEqual([channel]) } ) diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 03eceb84..84ac3a04 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -16,7 +16,7 @@ import { useDataZustand } from '@renderer/context/data.zustand' import { flushRegisterMappingToMain, useClientZustand } from '@renderer/context/client.zustand' import { RegisterType } from '@shared' import { showMapping } from '@renderer/context/data.zustand' -import { ElementType, useCallback, useEffect } from 'react' +import { ElementType, useCallback, useEffect, useRef } from 'react' // Protocol const TypeSelect = meme(() => { @@ -103,6 +103,12 @@ const Length = meme(() => { const ReadConfiguration = meme(() => { const readConfiguration = useClientZustand((z) => !!z.readConfiguration) + + // The store is written once main has the mapping, so between the press and + // that answer the toggle still reads off and a second press arrives as one + // more turn-on: a second flush, a second `showMapping` and a second read. + const handingOver = useRef(false) + const handleChange = useCallback(async (_: React.MouseEvent, v: boolean | null) => { const toggleState = !!v @@ -110,9 +116,15 @@ const ReadConfiguration = meme(() => { // mapping, so a refusal here would read out of the one before it. Turning it // off asks main for nothing. if (toggleState) { - const { registerMapping } = useClientZustand.getState() - if (!(await flushRegisterMappingToMain(registerMapping))) return - showMapping() + if (handingOver.current) return + handingOver.current = true + try { + const { registerMapping } = useClientZustand.getState() + if (!(await flushRegisterMappingToMain(registerMapping))) return + showMapping() + } finally { + handingOver.current = false + } } useClientZustand.getState().setReadConfiguration(toggleState) }, []) diff --git a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx index c32fcef4..58c7775b 100644 --- a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx +++ b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx @@ -150,6 +150,37 @@ describe('RegisterConfig turning read configuration on', () => { await waitFor(() => expect(useClientZustand.getState().readConfiguration).toBe(true)) }) + // The store is written after the round trip, so a second press inside it + // still reads the toggle as off and would flush, draw and read a second time. + it('takes one press while the first is still in flight', async () => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + let take: (answer: true) => void = () => {} + const setRegisterMapping = vi.fn( + () => + new Promise((resolve) => { + take = resolve + }) + ) + const stubbed = window.api as unknown as Record + const named: Record = { + setRegisterMapping, + setReadConfiguration: vi.fn(), + read: vi.fn() + } + window.api = new Proxy( + {}, + { get: (_target, method: string) => named[method] ?? stubbed[method] } + ) as never + + render() + fireEvent.click(screen.getByTestId('reg-read-config-btn')) + fireEvent.click(screen.getByTestId('reg-read-config-btn')) + take(true) + + await waitFor(() => expect(useClientZustand.getState().readConfiguration).toBe(true)) + expect(setRegisterMapping).toHaveBeenCalledTimes(1) + }) + it('stays off when main refuses the mapping', async () => { seed('holding_registers', { 0: { dataType: 'int16' } }) answerWith(undefined) diff --git a/src/renderer/src/context/__tests__/client.zustand.test.ts b/src/renderer/src/context/__tests__/client.zustand.test.ts index 638670ff..99d08a92 100644 --- a/src/renderer/src/context/__tests__/client.zustand.test.ts +++ b/src/renderer/src/context/__tests__/client.zustand.test.ts @@ -153,8 +153,15 @@ describe('replacing the register mapping', () => { return setRegisterMapping } + // `ready` is what `setReadConfiguration` returns on, and the action turns read + // configuration off before it asks. False here would leave that half of it out + // of both tests. beforeEach(() => { - useClientZustand.setState({ registerMapping: mapping('the one it had') } as never) + useClientZustand.setState({ + ready: true, + readConfiguration: true, + registerMapping: mapping('the one it had') + } as never) }) it('writes the new one when main took it', async () => { @@ -165,6 +172,7 @@ describe('replacing the register mapping', () => { expect(useClientZustand.getState().registerMapping.holding_registers[0]?.comment).toBe( 'the new one' ) + expect(useClientZustand.getState().readConfiguration).toBe(false) }) // Main keeps the mapping it had when it refuses one, so writing here would @@ -178,5 +186,8 @@ describe('replacing the register mapping', () => { expect(useClientZustand.getState().registerMapping.holding_registers[0]?.comment).toBe( 'the one it had' ) + // What the refusal costs: main and the store both hold the mapping from + // before, and read configuration is off. + expect(useClientZustand.getState().readConfiguration).toBe(false) }) }) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 82adab7d..1ecc8b49 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -186,7 +186,10 @@ export const useClientZustand = create< get().setReadConfiguration(false) // Main keeps the mapping it had when it refuses one, so a write here - // would leave the grid showing registers main is not holding. + // would leave the grid showing registers main is not holding. What a + // refusal costs is read configuration, which is off by the line above + // and stays off: both sides hold the mapping from before, and the + // message main sends names the channel. if (!(await flushRegisterMappingToMain(registerMapping))) return set((state) => { From 7d2c9e03f4bd9fa0eecd9f1fb9cecd26eecfc6a7 Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 08:53:36 +0200 Subject: [PATCH 012/137] refactor: the branches, exports and declarations nothing reaches Round B of the audit's non-blocking half. Five items whose whole proof is that they came out, so typecheck and the unit suite are the meter and nothing here changes what the app does. `applyRegisterValue` held five branches no input reaches. `registerWidth` answers 1, 2 or 4 for the eleven types that get that far, `byteLength` follows from it, and `byteOffset + 2 <= byteLength` follows from the guard above it, so the three `if`s were arithmetic restating itself. Both `default` arms went with them and the switches narrow their union now: `newComposite` is declared without a value, so a fourteenth `DataType` answers TS2454 three times instead of returning a silent zero. The two `try`/`catch` blocks around those switches went too. What was recorded as their reason had moved: `BigInt(1.5)` throws inside `toExact64Bits`, which catches it and answers `undefined`. Measured over nineteen stored values, among them 1.5, NaN, both infinities, `'abc'`, a 32 digit decimal string and `2n ** 70n`, through all eight setters, the eight getters over a buffer of 0xFF, and seven `numberValue`s through the one word overwrite: zero throws. `getMinMaxValues` cased twelve of the thirteen `DataType` members and answered the thirteenth out of `default:`. `case 'none':` is where that belongs. The test that reached the arm was titled for an unknown type; it names the two types that hold no number and asserts both. Nineteen names were exported from the file that is the only file naming them. Twelve keep the declaration and lose the keyword. Seven had no reader anywhere and are gone: `onceEvent`, `SetUnitIdParams`, whose channel `set_unit_id` returns nothing from `grep -rn src e2e`, and five aliases over a schema that is used without them. Five declarations were read and could not run. `AppState`'s constructor was empty above the four field initialisers that do the construction. `valueColumn`'s third parameter is passed by none of its eight call sites. `registerValueToString` opened a `let` and overwrote it on the next line. `layout.zustand` kept a `setShowLog` with no caller, where `toggleShowLog` is the one the toolbar uses. And the client store's migration held an empty `if (version < 2)`: that step wrote `readLocalTime` into `registerConfig`, which `79fa174` took out of the client, so a line says a v1 store has nothing to carry. `ScanUnitIDResultSchema.refusedRegisterTypes` lost `.default([])`. The schema parses nothing, being read once by the type it infers while `modbusClient` builds the object literally, so the default read as a guard on a payload that never arrives at a boundary. `types/ipc.ts` imported `SharedProps` from notistack as a value, in the one module all three processes compile against. It is `import type` now. esbuild dropped it either way: `grep -c notistack out/main/index.js out/preload/index.js` answers 0 for both, measured before the change and after it. The two pkexec helpers each held their own copy of what a fix answers: two unions `diff` calls identical, and two interfaces differing by one optional field. `PkexecFixResult` in `types/pkexec.ts` is the one shape. The port fix extends it with the value it re-reads from the kernel, and the group fix takes it as it stands. --- src/main/modules/modbusClient.ts | 2 +- src/main/modules/modbusServer.ts | 9 +- src/main/state.ts | 4 - .../columns/WriteModal/writeModal.zustand.ts | 2 +- .../RegisterGrid/columns/value.tsx | 12 +- .../client/ScanProgress/ScanProgress.tsx | 2 +- .../AddRegister/addRegister.zustand.ts | 2 +- .../shared/inputs/SerialPortInputs.tsx | 2 +- src/renderer/src/context/layout.zustand.ts | 4 - .../src/context/layout.zustand.types.ts | 1 - .../src/context/server.zustand.helpers.ts | 2 +- src/renderer/src/context/server.zustand.ts | 180 ++++++++---------- src/renderer/src/events/index.ts | 17 -- src/shared/__tests__/utils.test.ts | 3 +- src/shared/migrations/client/zustand.ts | 6 +- src/shared/types/bitmap.ts | 1 - src/shared/types/client.ts | 3 - src/shared/types/index.ts | 1 + src/shared/types/ipc.ts | 7 +- src/shared/types/pkexec.ts | 22 +++ src/shared/types/privilegedPort.ts | 11 +- src/shared/types/scan.ts | 2 +- src/shared/types/serialGroup.ts | 13 +- src/shared/types/server.ts | 6 - src/shared/utils.ts | 9 +- 25 files changed, 142 insertions(+), 181 deletions(-) create mode 100644 src/shared/types/pkexec.ts diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 684b352b..b89d9ddd 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -97,7 +97,7 @@ interface ModbusRTUInternals extends ModbusRTU { _port: { _transactionIdWrite?: number } | undefined } -export interface ClientParams { +interface ClientParams { appState: AppState windows: Windows } diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 5b4782ee..01699348 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -62,14 +62,13 @@ export const DEFAULT_MOBUS_PORT = 502 * request for a unit id this server does not host cannot get the same answer on * both. */ -export type ServerTransport = 'tcp' | 'rtu' +type ServerTransport = 'tcp' | 'rtu' /** Unit 0 addresses every device on an RTU bus at once. */ -export const BROADCAST_UNIT_ID: UnitIdString = '0' +const BROADCAST_UNIT_ID: UnitIdString = '0' /** 0 is a port number the way "any" is a name: the kernel picks, and it listens. */ -export const isPort = (port: number): boolean => - Number.isInteger(port) && port >= 1 && port <= 65535 +const isPort = (port: number): boolean => Number.isInteger(port) && port >= 1 && port <= 65535 /** * How long a bind may take before the listener is treated as failed. @@ -131,7 +130,7 @@ type ServerSerialConstructor = new ( ...args: [...ConstructorParameters, ServerSerialPortOptions] ) => ServerSerial -export interface ServerParams { +interface ServerParams { windows: Windows } diff --git a/src/main/state.ts b/src/main/state.ts index 015f7193..8cad4210 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -40,10 +40,6 @@ export class AppState { private _registerMapping?: RegisterMapping private _readConfiguration = false - constructor() { - /** No Construction */ - } - public updateConnectionConfig(config: DeepPartial): void { this._connectionConfig = merge>( this._connectionConfig, diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts index e579d479..b564dfc7 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/writeModal.zustand.ts @@ -5,7 +5,7 @@ import { create } from 'zustand' import { mutative } from 'zustand-mutative' /** What the dialog writes with when the address does not name a type. */ -export const DEFAULT_WRITE_DATA_TYPE: BaseDataType = 'int16' +const DEFAULT_WRITE_DATA_TYPE: BaseDataType = 'int16' /** * The data type the dialog opens with for an address. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx index 8c867fc3..bbf23aa3 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/value.tsx @@ -5,20 +5,16 @@ import { DataType, RegisterData, RegisterDataWords } from '@shared' const registerValueToString = ( value: number | bigint ): { numberString: string; irrelevant: boolean } => { - let numberString: string = '0' - numberString = value.toString(10) + const numberString = value.toString(10) - const irrelevant = numberString === '0' - - return { numberString, irrelevant } + return { numberString, irrelevant: numberString === '0' } } export const valueColumn = ( key: DataType, - width: number, - type?: GridColDef['type'] + width: number ): GridColDef => ({ - type: type ?? 'number', + type: 'number', field: `word_${key}`, headerName: key.toUpperCase(), width, diff --git a/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx index ad87ab6a..a281ce28 100644 --- a/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx @@ -65,7 +65,7 @@ const TimeoutInputForward = forwardRef((props, }) TimeoutInputForward.displayName = 'TimeoutInput' -export const TimeoutInput = meme(TimeoutInputForward) +const TimeoutInput = meme(TimeoutInputForward) interface TimeoutFieldProps { disabled: boolean diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index b731409f..b1327a81 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -32,7 +32,7 @@ type GetAddressInUseFn = ( length?: number ) => boolean -export const getAddressInUse: GetAddressInUseFn = ( +const getAddressInUse: GetAddressInUseFn = ( uuid, unitId, registerType, diff --git a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx index cf70a736..90983ec7 100644 --- a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx +++ b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx @@ -19,7 +19,7 @@ import { } from '@shared' import React, { useMemo } from 'react' -export const measureTextWidth = ( +const measureTextWidth = ( text: string, font: string = '400 0.85rem Roboto, sans-serif' ): number => { diff --git a/src/renderer/src/context/layout.zustand.ts b/src/renderer/src/context/layout.zustand.ts index 8f8cea5d..7ee5b8d6 100644 --- a/src/renderer/src/context/layout.zustand.ts +++ b/src/renderer/src/context/layout.zustand.ts @@ -35,10 +35,6 @@ export const useLayoutZustand = create - set((state) => { - state.showLog = show - }), appType: isServerWindow ? 'server' : undefined, setAppType: (appType: AppType | undefined) => set((state) => { diff --git a/src/renderer/src/context/layout.zustand.types.ts b/src/renderer/src/context/layout.zustand.types.ts index 097384b7..0cd578da 100644 --- a/src/renderer/src/context/layout.zustand.types.ts +++ b/src/renderer/src/context/layout.zustand.types.ts @@ -14,6 +14,5 @@ export interface LayoutZustand { setHideHomeButton: (hide: boolean) => void toggleShowLog: () => void toggleShowGridWhileScanning: () => void - setShowLog: (show: boolean) => void setAppType: (appType: AppType | undefined) => void } diff --git a/src/renderer/src/context/server.zustand.helpers.ts b/src/renderer/src/context/server.zustand.helpers.ts index a19e439f..6e7f62c3 100644 --- a/src/renderer/src/context/server.zustand.helpers.ts +++ b/src/renderer/src/context/server.zustand.helpers.ts @@ -25,7 +25,7 @@ export const extractUnitIdsWithData = (serverRegisters: DefinedServerRegisters): return unitIdsWithData } -export const syncBoolsWithBackend = async ( +const syncBoolsWithBackend = async ( serverRegisters: DefinedServerRegisters, unitId: UnitIdString, syncUuid: string diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index b71b12c9..6ec1b2f1 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -665,7 +665,6 @@ export const applyRegisterValue = (payload: RegisterValue): void => { // 2) Calculate how many registers this DataType spans const registersCount = registerWidth(dataType) - if (registersCount < 1 || registersCount > 4) return // Defensive: only support 1-4 registers // 3) Determine which register‐offset was written const offsetRegisters = address - entryAddress @@ -676,108 +675,97 @@ export const applyRegisterValue = (payload: RegisterValue): void => { // 4) Serialize the current composite value into a byte buffer const byteLength = registersCount * 2 - if (byteLength > 8) return // Defensive: DataView only supports up to 8 bytes for 64-bit types const buffer = new ArrayBuffer(byteLength) const view = new DataView(buffer) - - // Defensive: Clamp offset to buffer size const byteOffset = offsetRegisters * 2 - if (byteOffset < 0 || byteOffset + 2 > byteLength) return - - // Defensive: Only write if currentValue is a valid number (or bigint for 64-bit) - try { - switch (dataType) { - case 'int16': - view.setInt16(0, Number(currentValue) || 0, littleEndian) - break - case 'uint16': - case 'bitmap': - view.setUint16(0, Number(currentValue) || 0, littleEndian) - break - case 'int32': - view.setInt32(0, Number(currentValue) || 0, littleEndian) - break - case 'uint32': - case 'unix': - view.setUint32(0, Number(currentValue) || 0, littleEndian) - break - case 'float': - view.setFloat32(0, Number(currentValue) || 0, littleEndian) - break - // `undefined` is a stored value no composite can be read out of, which a - // hand-edited config reaches because `ServerRegisterEntrySchema` takes a - // fractional number for these types. Aborting leaves the entry alone; - // merging the one word into a composite of zero would cost the other - // three registers. - case 'int64': { - const composite = toExact64Bits(currentValue) - if (composite === undefined) return - view.setBigInt64(0, composite, littleEndian) - break - } - case 'uint64': - case 'datetime': { - const composite = toExact64Bits(currentValue) - if (composite === undefined) return - view.setBigUint64(0, composite, littleEndian) - break - } - case 'double': - view.setFloat64(0, Number(currentValue) || 0, littleEndian) - break - default: - return + + // Neither switch is wrapped in a `try`: nothing below throws for any value an + // entry can hold. Measured over nineteen stored values, among them 1.5, NaN, + // both infinities, `'abc'`, a 32 digit decimal string and `2n ** 70n`, all + // eight setters, the eight getters over a buffer of 0xFF, and seven + // `numberValue`s through the one word overwrite: zero throws. A `DataView` + // converts what it is handed, and `setBigUint64` takes its argument modulo + // 2 ** 64 rather than refusing it. + // + // Both switches case all eleven types that reach here, and the two the + // compositing cannot use returned above, so neither carries a `default`. + // `newComposite` is declared without a value, which is what makes a + // fourteenth `DataType` a type error rather than a silent zero: adding + // `probe14` to `BaseDataTypeSchema` answered TS2454 three times. + switch (dataType) { + case 'int16': + view.setInt16(0, Number(currentValue) || 0, littleEndian) + break + case 'uint16': + case 'bitmap': + view.setUint16(0, Number(currentValue) || 0, littleEndian) + break + case 'int32': + view.setInt32(0, Number(currentValue) || 0, littleEndian) + break + case 'uint32': + case 'unix': + view.setUint32(0, Number(currentValue) || 0, littleEndian) + break + case 'float': + view.setFloat32(0, Number(currentValue) || 0, littleEndian) + break + // `undefined` is a stored value no composite can be read out of, which a + // hand-edited config reaches because `ServerRegisterEntrySchema` takes a + // fractional number for these types. Aborting leaves the entry alone; + // merging the one word into a composite of zero would cost the other + // three registers. + case 'int64': { + const composite = toExact64Bits(currentValue) + if (composite === undefined) return + view.setBigInt64(0, composite, littleEndian) + break } - // 5) Overwrite just the one 16-bit register that the client wrote - view.setUint16(byteOffset, numberValue, littleEndian) - } catch (e) { - // Defensive: If any DataView error occurs, abort - console.error('register_value DataView error', e, { - dataType, - currentValue, - byteOffset, - byteLength - }) - return + case 'uint64': + case 'datetime': { + const composite = toExact64Bits(currentValue) + if (composite === undefined) return + view.setBigUint64(0, composite, littleEndian) + break + } + case 'double': + view.setFloat64(0, Number(currentValue) || 0, littleEndian) + break } + // 5) Overwrite just the one 16-bit register that the client wrote + view.setUint16(byteOffset, numberValue, littleEndian) + // 6) Read back the full composite value from the buffer - let newComposite: number | bigint = 0 - try { - switch (dataType) { - case 'int16': - newComposite = view.getInt16(0, littleEndian) - break - case 'uint16': - case 'bitmap': - newComposite = view.getUint16(0, littleEndian) - break - case 'int32': - newComposite = view.getInt32(0, littleEndian) - break - case 'uint32': - case 'unix': - newComposite = view.getUint32(0, littleEndian) - break - case 'float': - newComposite = view.getFloat32(0, littleEndian) - break - case 'int64': - newComposite = view.getBigInt64(0, littleEndian) - break - case 'uint64': - case 'datetime': - newComposite = view.getBigUint64(0, littleEndian) - break - case 'double': - newComposite = view.getFloat64(0, littleEndian) - break - default: - newComposite = 0 - } - } catch (e) { - console.error('register_value DataView read error', e, { dataType, byteLength }) - return + let newComposite: number | bigint + switch (dataType) { + case 'int16': + newComposite = view.getInt16(0, littleEndian) + break + case 'uint16': + case 'bitmap': + newComposite = view.getUint16(0, littleEndian) + break + case 'int32': + newComposite = view.getInt32(0, littleEndian) + break + case 'uint32': + case 'unix': + newComposite = view.getUint32(0, littleEndian) + break + case 'float': + newComposite = view.getFloat32(0, littleEndian) + break + case 'int64': + newComposite = view.getBigInt64(0, littleEndian) + break + case 'uint64': + case 'datetime': + newComposite = view.getBigUint64(0, littleEndian) + break + case 'double': + newComposite = view.getFloat64(0, littleEndian) + break } // A decimal string where the composite fills 64 bits as an integer, because diff --git a/src/renderer/src/events/index.ts b/src/renderer/src/events/index.ts index 038cf8ff..300e0dc1 100644 --- a/src/renderer/src/events/index.ts +++ b/src/renderer/src/events/index.ts @@ -28,20 +28,3 @@ export const onEvent = ( } return window.electron.ipcRenderer.on(event, wrapped) } - -/** - * Register a one-time listener for an IPC event. - * - E must be one of the keys in IpcEvent. - * - listener receives the payload tuple defined in IpcEventPayloadMap[E]. - * - * Returns a function that removes this listener if it hasn’t fired yet. - */ -export const onceEvent = ( - event: E, - listener: (...args: IpcEventPayloadMap[E]) => void -): (() => void) => { - const wrapped: IpcRendererListener = (_ev, ...args) => { - listener(...(args as IpcEventPayloadMap[E])) - } - return window.electron.ipcRenderer.once(event, wrapped) -} diff --git a/src/shared/__tests__/utils.test.ts b/src/shared/__tests__/utils.test.ts index 9359030a..a2cb17d0 100644 --- a/src/shared/__tests__/utils.test.ts +++ b/src/shared/__tests__/utils.test.ts @@ -405,8 +405,9 @@ describe('getMinMaxValues', () => { expect(max).toBe(Number.POSITIVE_INFINITY) }) - it('returns { min: 0, max: 0 } for unknown types', () => { + it('returns { min: 0, max: 0 } for the two types that hold no number', () => { expect(getMinMaxValues('none')).toEqual({ min: 0, max: 0 }) + expect(getMinMaxValues('utf8')).toEqual({ min: 0, max: 0 }) }) it('states the unix range in the seconds createRegisters writes', () => { diff --git a/src/shared/migrations/client/zustand.ts b/src/shared/migrations/client/zustand.ts index cf4a8b44..edde1959 100644 --- a/src/shared/migrations/client/zustand.ts +++ b/src/shared/migrations/client/zustand.ts @@ -18,9 +18,9 @@ export function migrateClientState( ): Record { const state = persistedState as Record - if (version < 2) { - // v1→v2: (reserved for future migrations) - } + // No v1→v2 step: it wrote `readLocalTime` into `registerConfig`, and + // `79fa174` took that field out of the client, so a v1 store has nothing to + // carry. `grep -rn readLocalTime src e2e` returns nothing. // v2→v3: the RTU parity the serial binding refuses if (version < 3) { diff --git a/src/shared/types/bitmap.ts b/src/shared/types/bitmap.ts index c081a4bc..9fe572ff 100644 --- a/src/shared/types/bitmap.ts +++ b/src/shared/types/bitmap.ts @@ -9,7 +9,6 @@ export const BitMapEntrySchema = z.object({ color: BitColorSchema.optional(), invert: z.boolean().optional() }) -export type BitMapEntry = z.infer /** Record keyed by bit-index string ("0" – "15"). */ export const BitMapConfigSchema = z.record( diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index b9f77c02..f7b3e8c2 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -119,13 +119,11 @@ export const ConnectionConfigTcpSchema = z.object({ host: z.string(), options: TcpPortOptionsSchema }) -export type ConnectionConfigTcp = z.infer export const ConnectionConfigRtuSchema = z.object({ com: z.string(), options: SerialPortOptionsSchema }) -export type ConnectionConfigRtu = z.infer /** * The unit id and the port the client sends, on the range the protocol fixes. @@ -180,7 +178,6 @@ export const ConnectStateSchema = z.enum([ 'connecting', 'disconnecting' ]) -export type ConnectState = z.infer export const ClientStateSchema = z.object({ connectState: ConnectStateSchema, diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 5a4c6ecf..22fd4c76 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -6,6 +6,7 @@ export * from './scan' export * from './client' export * from './ipc' export * from './bitmap' +export * from './pkexec' export * from './privilegedPort' export * from './serialGroup' export * from './serial' diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 622e4aef..5993e6dc 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -30,7 +30,12 @@ import type { SerialGroupStatus, SerialGroupFixResult } from '@shared' -import { SharedProps } from 'notistack' +// `import type`, because shared is the one module all three processes compile +// against and notistack is a renderer dependency. `variant` below is the only +// thing taken from it, in a type position. esbuild dropped the import either +// way: `grep -c notistack out/main/index.js out/preload/index.js` answers 0 for +// both, before and after this line changed. +import type { SharedProps } from 'notistack' /** * Which windows are open, as `window_update` carries it. diff --git a/src/shared/types/pkexec.ts b/src/shared/types/pkexec.ts new file mode 100644 index 00000000..3172c54b --- /dev/null +++ b/src/shared/types/pkexec.ts @@ -0,0 +1,22 @@ +/** + * What a pkexec fix answers, for both of the Linux helpers that run one. + * + * `privilegedPort.ts` and `serialGroup.ts` each held their own copy: two unions + * a `diff` called identical, and two interfaces that differed by one optional + * field. Neither said it was a copy. + * + * The port fix extends this with that field, because it re-reads what it wrote + * (`readUnprivilegedPortStart` at `privilegedPort.ts:160`, after the command). + * The group fix has nothing to re-read and takes the shape as it stands. + */ + +/** Why a fix attempt did not go through. Read through `PkexecFixResult`. */ +type PkexecFixFailure = 'cancelled' | 'unavailable' | 'failed' | 'unsupported' + +export interface PkexecFixResult { + ok: boolean + /** Absent when `ok` is true. */ + reason?: PkexecFixFailure + /** Human-readable outcome, safe to drop straight into a snackbar. */ + message: string +} diff --git a/src/shared/types/privilegedPort.ts b/src/shared/types/privilegedPort.ts index 7bf7be9d..e289efc4 100644 --- a/src/shared/types/privilegedPort.ts +++ b/src/shared/types/privilegedPort.ts @@ -1,4 +1,5 @@ import z from 'zod' +import type { PkexecFixResult } from './pkexec' /** * Linux privileged port types * @@ -45,15 +46,7 @@ export interface PrivilegedPortStatus { sandbox?: PrivilegedPortSandbox } -/** Why a fix attempt did not go through. */ -export type PrivilegedPortFixFailure = 'cancelled' | 'unavailable' | 'failed' | 'unsupported' - -export interface PrivilegedPortFixResult { - ok: boolean - /** Absent when `ok` is true. */ - reason?: PrivilegedPortFixFailure - /** Human-readable outcome, safe to drop straight into a snackbar. */ - message: string +export interface PrivilegedPortFixResult extends PkexecFixResult { /** The value in effect after the command ran, re-read from the kernel. */ unprivilegedPortStart?: number } diff --git a/src/shared/types/scan.ts b/src/shared/types/scan.ts index 00964585..1207e7a2 100644 --- a/src/shared/types/scan.ts +++ b/src/shared/types/scan.ts @@ -57,7 +57,7 @@ export const ScanUnitIDResultSchema = z.object({ * is there and talking, which is the opposite of the silence a unit ID that * is not on the bus gives back. */ - refusedRegisterTypes: z.array(RegisterTypeSchema).default([]), + refusedRegisterTypes: z.array(RegisterTypeSchema), requestedRegisterTypes: z.array(RegisterTypeSchema), errorMessage: ScanUnitIdErrorMessageSchema }) diff --git a/src/shared/types/serialGroup.ts b/src/shared/types/serialGroup.ts index 11e42663..8572fe2f 100644 --- a/src/shared/types/serialGroup.ts +++ b/src/shared/types/serialGroup.ts @@ -1,3 +1,5 @@ +import type { PkexecFixResult } from './pkexec' + /** * Linux serial group types * @@ -49,16 +51,7 @@ export interface SerialGroupStatus { sandbox?: 'flatpak' | 'snap' } -/** Why an attempt did not go through. */ -export type SerialGroupFixFailure = 'cancelled' | 'unavailable' | 'failed' | 'unsupported' - -export interface SerialGroupFixResult { - ok: boolean - /** Absent when `ok` is true. */ - reason?: SerialGroupFixFailure - /** Human-readable outcome, safe to drop straight into a snackbar. */ - message: string -} +export type SerialGroupFixResult = PkexecFixResult /** * The argv passed to pkexec. An array, so the main process never builds a diff --git a/src/shared/types/server.ts b/src/shared/types/server.ts index a4c26a37..afbade5f 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -86,7 +86,6 @@ const RegisterParamsStaticPartSchema = z.object({ max: z.undefined(), interval: z.undefined() }) -export type RegisterParamsStaticPart = z.infer export type RegisterValue = { [P in K]: { @@ -323,11 +322,6 @@ export const CreateServerParamsSchema = z.object({ }) export type CreateServerParams = z.infer -export interface SetUnitIdParams { - uuid: string - unitID: UnitIdString -} - export type ServerData = { [K in RegisterType]: K extends BooleanRegisters ? boolean[] : number[] } /** diff --git a/src/shared/utils.ts b/src/shared/utils.ts index f31097d7..b8eae277 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -163,8 +163,8 @@ export const createRegisters = ( * the mask and `encodeIEC870DateTime` clamps to them, and a mask wider than the * clamp accepts a date the register does not get. */ -export const IEC870_MIN_MS = Date.UTC(2000, 0, 1) -export const IEC870_MAX_MS = Date.UTC(2127, 11, 31, 23, 59, 59, 999) +const IEC870_MIN_MS = Date.UTC(2000, 0, 1) +const IEC870_MAX_MS = Date.UTC(2127, 11, 31, 23, 59, 59, 999) /** * Encode a timestamp (milliseconds) to IEC 870-5 datetime format (8 bytes / 4 registers). @@ -221,10 +221,9 @@ export const getMinMaxValues = (dataType: DataType): { min: number; max: number // Milliseconds, which is the unit `createRegisters` hands the encoder. // Stated in seconds, the whole range encoded to the clamp floor. return { min: IEC870_MIN_MS, max: IEC870_MAX_MS } + case 'none': case 'utf8': - return { min: 0, max: 0 } // N/A for strings - default: - return { min: 0, max: 0 } + return { min: 0, max: 0 } // Neither holds a number } } From 1e5fd1b48a0ea01c230a04936fbe75c8217ad37b Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 09:06:43 +0200 Subject: [PATCH 013/137] refactor: one shape, written out again in four places Round C of the audit's non-blocking half. Four items in main, with `modbusClient.test.ts` and `modbusServer.test.ts` as the meter. `syncServerRegisters` opened by disposing and clearing the generators of both register types, and then called `resetRegisters` for both, which disposes and clears the same generators before it replaces the data array. The prologue is gone and the two calls stand. Two tests titled for a branch inside that prologue name the branch in `resetRegisters` they still reach. `stopAllTcpServers` was `_closeAndForget` written out again, message included, ending in a `clear()` of both maps rather than a delete per uuid. It is the loop now. The key sets cannot differ: `_bindServer` writes `_servers` and `_port` on consecutive lines, `_closeAndForget` deletes both, and nothing else writes either. Four callback-to-promise wrappers had one body between them. FC5, FC6, FC15 and FC16 share a signature in `ModbusRTU.d.ts` apart from the value they carry, so `_awaitWrite` is that conversion once and each call site hands over its own method with the callback still open. Encoding a value, writing the words into `serverData` and sending one `register_value` per register was written twice, in `addRegister`'s fixed branch and in `ValueGenerator._updateServerData`. `modbusServer/registers.ts` holds both halves. What the copies did with a throw had drifted apart, and that difference is deliberate, so it stays where it can be seen: `_encode` is `encodeRegisters` inside a `try`, the generator calls it bare, and `addRegister`'s docblock says why. The transaction log's comment block said a serial transaction files under the string "undefined". `open` sets `_transactionIdWrite` to 1 for every transport (`index.js:679`), and only `tcpport.js`, `tcprtubufferedport.js` and `udpport.js` increment it, so a serial request files under 1 and the next one lands on top of it. The mock said the same wrong thing, and two tests wrote `undefined` by hand to reach what it claimed. The mock models the increment instead, and both tests assert two serial requests sharing the key 1. Setting the mock to increment on a serial port turns both red. `/code-review` on round B read this work in the tree beside it and found two things. `ValueGenerator` kept a literal 10 where `encodeRegisters` falls back to `DEFAULT_UTF8_LENGTH`, which is 10, so the copy was the thing that could drift. And the two optionals on `ModbusRTUInternals._port` read as guards nothing reaches. They describe modbus-serial and the docblock says so now: a client built and not connected has no `_port`, a serial port has no `_transactionIdWrite` until `open` sets it, and `_nextTransactionIdKey` runs behind `_requireConnected`, so it meets neither. --- .../modules/__tests__/modbusClient.test.ts | 49 ++++++----- .../modules/__tests__/modbusServer.test.ts | 9 ++- src/main/modules/modbusClient.ts | 76 +++++++++-------- src/main/modules/modbusServer.ts | 81 ++++++------------- src/main/modules/modbusServer/registers.ts | 74 +++++++++++++++++ .../modules/modbusServer/valueGenerator.ts | 39 ++++----- 6 files changed, 189 insertions(+), 139 deletions(-) create mode 100644 src/main/modules/modbusServer/registers.ts diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 7d2f375b..7be2584c 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -99,10 +99,10 @@ const createMockModbusRTU = () => ({ writeFC15: vi.fn(), writeFC16: vi.fn(), _transactions: {} as Record, - // The port modbus-serial files transactions against. A TCP port counts from - // 1, and `RTUBufferedPort` defines no such field, which is why every serial - // transaction files under the string "undefined". - _port: { _transactionIdWrite: 1 } as { _transactionIdWrite: number | undefined }, + // The port modbus-serial files transactions against. `open` sets this to 1 + // on every transport; only a TCP or UDP port increments it, which is what + // `serialPort()` below switches off. + _port: { _transactionIdWrite: 1 }, isDebugEnabled: false }) @@ -177,6 +177,7 @@ describe('ModbusClient', () => { ;(ModbusRTU as any).getPorts.mockResolvedValue([]) sentToWindows = [] addressedTo = [] + portIncrementsKey = true windows = createMockWindows() appState = new AppState() client = new ModbusClient({ appState, windows }) @@ -237,21 +238,32 @@ describe('ModbusClient', () => { responses: [Buffer.from([0x01, 0x03, length * 2, 0x00, 0x64])] }) + /** False once a test asks for a serial port, which does not move the key. */ + let portIncrementsKey = true + + /** + * Make the port a serial one, which is only ever the key standing still. + * + * `open` sets `_transactionIdWrite` to 1 for every transport, so the mock + * starts where a serial port stays. Only `tcpport.js`, + * `tcprtubufferedport.js` and `udpport.js` go on to increment it, and + * `rtubufferedport.js` never names the field. + */ + const serialPort = (): void => { + portIncrementsKey = false + } + /** * File a transaction the way modbus-serial does. * * `writeFCx` files under the port's current write id and the port increments * it once the buffer is out, so a request filed here takes the key the client - * read before the call. The increment is skipped on a serial port, which has - * no id to increment. + * read before the call. */ const fileTransaction = (transaction: RawTransaction = createMockTransaction()): string => { const key = String(mockModbusRTU._port._transactionIdWrite) mockModbusRTU._transactions = { ...mockModbusRTU._transactions, [key]: transaction } - const { _transactionIdWrite } = mockModbusRTU._port - if (_transactionIdWrite !== undefined) { - mockModbusRTU._port._transactionIdWrite = _transactionIdWrite + 1 - } + if (portIncrementsKey) mockModbusRTU._port._transactionIdWrite += 1 return key } @@ -1626,21 +1638,19 @@ describe('ModbusClient', () => { expect(txCalls[0]?.[1].responses).toEqual([]) }) - it('keeps the serial transaction key, which is not a number', async () => { + it('logs two serial reads under the one key that never moves', async () => { await connectClient() - // RTU has no transaction ids, so modbus-serial files every serial - // transaction under this one key. - mockModbusRTU._port._transactionIdWrite = undefined + serialPort() mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { fileTransaction() return { data: [0], buffer: Buffer.alloc(2) } }) + await client.read() await client.read() const txCalls = getWindowCalls('transaction') - expect(txCalls[0]?.[1].id).toContain('undefined__') - expect(txCalls[0]?.[1].id).not.toContain('NaN') + expect(txCalls.map((call) => call[1].id.split('__')[0])).toEqual(['1', '1']) }) // The two below discriminate a per-group error message from one that @@ -2608,7 +2618,7 @@ describe('ModbusClient', () => { it('logs every probe on a serial port, where they share one key', async () => { await connectClient() - mockModbusRTU._port._transactionIdWrite = undefined + serialPort() setupHoldingRegisterReadMock([0]) const scanPromise = client.scanUnitIds({ @@ -2622,10 +2632,7 @@ describe('ModbusClient', () => { await scanPromise const transactions = getWindowCalls('transaction') - expect(transactions.map((call) => call[1].id.split('__')[0])).toEqual([ - 'undefined', - 'undefined' - ]) + expect(transactions.map((call) => call[1].id.split('__')[0])).toEqual(['1', '1']) expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) }) }) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index f12874c8..3aaeae75 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1191,8 +1191,8 @@ describe('ModbusServer', () => { expect(newRegisterCalls.length).toBe(0) }) - // ! Coverage-only: exercises FALSE branch of if (unitIdGenerators) in syncServerRegisters - it('skips generator cleanup when no generators exist for the uuid', () => { + // ! Coverage-only: exercises FALSE branch of if (serverGenerators) in resetRegisters + it('syncs a uuid that has no generators yet', () => { // Call syncServerRegisters without any prior addRegister server.syncServerRegisters({ uuid: 'fresh-uuid', @@ -1219,8 +1219,9 @@ describe('ModbusServer', () => { ) }) - // ! Coverage-only: exercises FALSE branch of if (generators) in syncServerRegisters - it('skips generator cleanup when unitId has no generators', () => { + // ! Coverage-only: the same FALSE branch, reached with the uuid's map there + // and the unit id's missing. + it('syncs a unit id that has no generators yet', () => { // Add a generator for unitId '1' server.addRegister({ uuid, diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index b89d9ddd..a2315054 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -22,6 +22,7 @@ import { } from '@shared' import { Windows } from '../windows' import { + NodeStyleCallback, ReadCoilResult, ReadRegisterResult, WriteCoilResult, @@ -91,6 +92,11 @@ interface ModbusRTUEmitter extends ModbusRTU { * `ModbusRTU.d.ts` declares neither, so both arrive untyped and the shape is * written here. `_transactions` is the table every request is filed in, and * `_port._transactionIdWrite` is the key the next one files under. + * + * Both optionals describe modbus-serial rather than guarding anything here. A + * client built but not connected has no `_port` at all, and a serial port has + * no `_transactionIdWrite` until `open` sets it. `_nextTransactionIdKey` runs + * behind `_requireConnected`, so it meets neither state. */ interface ModbusRTUInternals extends ModbusRTU { _transactions: Record @@ -665,9 +671,12 @@ export class ModbusClient { // The library checks for them before using them; this used to not, and a // scan that met one crashed the handler it ran in. // - // The key is a transaction id on TCP and UDP, and nothing at all on a - // serial port: RTU has no transaction ids, so every serial transaction is - // filed under the string "undefined". It is a map key, not a number. + // The key is a transaction id on TCP and UDP, whose ports write it into the + // MBAP header and increment it per request. A serial port has none: RTU + // frames carry no transaction id, and `rtubufferedport.js` never names + // `_transactionIdWrite`. `open` sets it to 1 for every transport + // (`index.js:679`), so every serial request files under the key 1 and the + // next one lands on top of the last. It is a map key, not a number. private _internals = (): ModbusRTUInternals => this._client as ModbusRTUInternals /** @@ -868,6 +877,27 @@ export class ModbusClient { if (!this._readLoopOwner()) this.read() } + /** + * A `writeFCx` as a promise. + * + * `ModbusRTU.d.ts` gives FC5, FC6, FC15 and FC16 one signature apart from the + * value: unit id, data address, the value, and a `NodeStyleCallback`. This + * conversion was written out once per code, and the four copies differed in + * the method name, the type argument and the value they passed. The caller + * hands over a call with the callback still open, so `this._client` stays + * bound and the method and its arguments stay where the reader is. + */ + private _awaitWrite = (write: (next: NodeStyleCallback) => void): Promise => + new Promise((resolve, reject) => + write((error, data) => { + if (error) { + reject(error) + return + } + resolve(data) + }) + ) + private _writeCoil = async ( address: number, value: boolean[], @@ -893,26 +923,14 @@ export class ModbusClient { try { if (single) { - await new Promise((resolve, reject) => - this._client.writeFC5(unitId, address, first, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite((next) => + this._client.writeFC5(unitId, address, first, next) ) return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple coils - await new Promise((resolve, reject) => - this._client.writeFC15(unitId, address, value, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite((next) => + this._client.writeFC15(unitId, address, value, next) ) } catch (error) { this._emitMessage({ message: (error as Error).message, variant: 'error', error }) @@ -958,26 +976,14 @@ export class ModbusClient { try { if (single) { // Write single register - await new Promise((resolve, reject) => - this._client.writeFC6(unitId, address, registers[0], (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite((next) => + this._client.writeFC6(unitId, address, registers[0], next) ) return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple registers - await new Promise((resolve, reject) => - this._client.writeFC16(unitId, address, registers, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite((next) => + this._client.writeFC16(unitId, address, registers, next) ) } catch (error) { this._emitMessage({ message: (error as Error).message, variant: 'error', error: error }) diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 01699348..98666661 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -3,8 +3,6 @@ import { SetBooleanParameters, SyncBoolsParameters, SyncRegisterValueParams, - createRegisters, - createStringRegisters, ResetRegistersParams, ResetBoolsParams, CreateServerParams, @@ -17,15 +15,15 @@ import { RegisterType, RegisterValue, DataBits, - StopBits, - ValuedDataType + StopBits } from '@shared' import { ServerTCP, ServerSerial } from 'modbus-serial' import { ServerEndianness } from '@shared' import { Windows } from '../windows' import { ValueGenerator } from './modbusServer/valueGenerator' +import { encodeRegisters, writeRegisters } from './modbusServer/registers' import type { IServiceVector, FCallbackVal, FCallback } from 'modbus-serial' -import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' +import { registerWidth } from '@shared' import net from 'net' const getDefaultGenerators = (): ValueGenerators => ({ @@ -486,23 +484,9 @@ export class ModbusServer { * about. This is the class answering for its own input: `addRegister` is * public, and a caller inside main reaches it without crossing that boundary. */ - private _encode = ({ - dataType, - value, - littleEndian, - stringValue, - length - }: { - dataType: ValuedDataType - value: number - littleEndian: boolean - stringValue?: string - length?: number - }): number[] | undefined => { + private _encode = (params: Parameters[0]): number[] | undefined => { try { - return dataType === 'utf8' - ? createStringRegisters(stringValue ?? '', length ?? DEFAULT_UTF8_LENGTH) - : createRegisters(dataType, value, littleEndian) + return encodeRegisters(params) } catch { return undefined } @@ -603,20 +587,14 @@ export class ModbusServer { } const serverData = takeTheAddress() - registers.forEach((register, index) => { - const registerAddress = address + index - serverData[registerType][registerAddress] = register - this._windows.send( - 'register_value', - { - uuid, - unitId, - registerType, - address: registerAddress, - value: register - }, - 'serverView' - ) + writeRegisters({ + windows: this._windows, + serverData, + uuid, + unitId, + registerType, + address, + registers }) this._setServerData(uuid, unitId, serverData) return registers @@ -685,23 +663,17 @@ export class ModbusServer { /** * Synchronizes all register values for a given server and unitId. * Resets all holding and input registers, then adds all provided registers. + * + * `resetRegisters` disposes the generators of one register type and clears + * their map before it replaces the data array, so the two calls below reach + * every generator this unit has. This opened by doing that dispose and clear + * for both types first, which left the calls below nothing to dispose. */ public syncServerRegisters = ({ uuid, unitId, registerValues }: SyncRegisterValueParams): void => { - // Cleanup generators only for this unitId - const unitIdGenerators = this._generatorMap.get(uuid) - if (unitIdGenerators) { - const generators = unitIdGenerators.get(unitId) - if (generators) { - generators.holding_registers.forEach((g) => g.dispose()) - generators.input_registers.forEach((g) => g.dispose()) - generators.holding_registers.clear() - generators.input_registers.clear() - } - } this.resetRegisters({ uuid, unitId, registerType: 'holding_registers' }) this.resetRegisters({ uuid, unitId, registerType: 'input_registers' }) for (const params of registerValues) this.addRegister({ uuid, unitId, params }) @@ -908,19 +880,14 @@ export class ModbusServer { /** * Stops all running TCP servers. Does NOT clear server data or generators * so registers are preserved for restore when switching back to TCP. + * + * `_closeAndForget` per uuid, which is the same close and the same message, + * and it deletes both maps' entry rather than clearing the maps at the end. + * The key sets cannot differ: `_bindServer` writes `_servers` and `_port` on + * consecutive lines and nothing else writes either. */ public stopAllTcpServers = async (): Promise => { - for (const [, server] of this._servers) { - await new Promise((resolve) => { - server.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - } - this._servers.clear() - this._port.clear() + for (const uuid of [...this._servers.keys()]) await this._closeAndForget(uuid) } /** diff --git a/src/main/modules/modbusServer/registers.ts b/src/main/modules/modbusServer/registers.ts new file mode 100644 index 00000000..fea479af --- /dev/null +++ b/src/main/modules/modbusServer/registers.ts @@ -0,0 +1,74 @@ +import { + createRegisters, + createStringRegisters, + DEFAULT_UTF8_LENGTH, + NumberRegisters, + ServerData, + UnitIdString, + ValuedDataType +} from '@shared' +import { Windows } from '../../windows' + +/** + * The words a value of this type occupies. + * + * Two writers reach it: `ModbusServer.addRegister` for a fixed register, and + * `ValueGenerator` on every tick. Both used to spell the pair of encoders out, + * and what the copies did with a throw drifted apart: one grew a `try` around + * it and the other stayed bare. `ModbusServer._encode` is that `try` now. The + * generator calls this bare on purpose, and `addRegister`'s docblock says why. + * + * A string is written from its characters rather than from `value`, so `utf8` + * ignores it. + */ +export const encodeRegisters = ({ + dataType, + value, + littleEndian, + stringValue, + length +}: { + dataType: ValuedDataType + value: number + littleEndian: boolean + stringValue?: string + length?: number +}): number[] => + dataType === 'utf8' + ? createStringRegisters(stringValue ?? '', length ?? DEFAULT_UTF8_LENGTH) + : createRegisters(dataType, value, littleEndian) + +/** + * Writes `registers` from `address` on and tells the server view each word. + * + * One event per register, because the renderer folds a word at a time: the + * store's `applyRegisterValue` looks back up to three addresses for the entry + * that owns the one it was handed. + */ +export const writeRegisters = ({ + windows, + serverData, + uuid, + unitId, + registerType, + address, + registers +}: { + windows: Windows + serverData: ServerData + uuid: string + unitId: UnitIdString + registerType: NumberRegisters + address: number + registers: number[] +}): void => { + registers.forEach((register, index) => { + const registerAddress = address + index + serverData[registerType][registerAddress] = register + windows.send( + 'register_value', + { uuid, unitId, registerType, address: registerAddress, value: register }, + 'serverView' + ) + }) +} diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 35447366..66be8581 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -1,6 +1,5 @@ import { - createRegisters, - createStringRegisters, + DEFAULT_UTF8_LENGTH, NumberRegisters, RegisterParamsGeneratorPart, RegisterParamsBasePart, @@ -12,6 +11,7 @@ import { ValuedDataType } from '@shared' import { Windows } from '../../windows' +import { encodeRegisters, writeRegisters } from './registers' import { round } from 'lodash' type ValueGeneratorParams = { @@ -80,7 +80,7 @@ export class ValueGenerator implements RegisterValueGenerator { this._interval = interval this._comment = comment this._stringValue = stringValue ?? '' - this._length = length ?? 10 + this._length = length ?? DEFAULT_UTF8_LENGTH // Set initial value and start periodic updates this._updateValue() @@ -104,25 +104,20 @@ export class ValueGenerator implements RegisterValueGenerator { * Updates the value in the server data and notifies the frontend. */ private _updateServerData = (value: number): void => { - const registers = - this._dataType === 'utf8' - ? createStringRegisters(this._stringValue, this._length) - : createRegisters(this._dataType, value, this._littleEndian) - - registers.forEach((register, index) => { - const registerAddress = this._address + index - this._serverData[this._registerType][registerAddress] = register - this._windows.send( - 'register_value', - { - uuid: this._uuid, - unitId: this._unitId, - registerType: this._registerType, - address: registerAddress, - value: register - }, - 'serverView' - ) + writeRegisters({ + windows: this._windows, + serverData: this._serverData, + uuid: this._uuid, + unitId: this._unitId, + registerType: this._registerType, + address: this._address, + registers: encodeRegisters({ + dataType: this._dataType, + value, + littleEndian: this._littleEndian, + stringValue: this._stringValue, + length: this._length + }) }) } From ecd8b17c2032582975ef18fa614d4303eb037f2f Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 09:13:15 +0200 Subject: [PATCH 014/137] docs: two comments the serial key made harder to read `/code-review` on `6f0c682`, the commit that established what a serial request files under. `_logTransaction` said the table holds everyone else's requests and named a write and a poll overlapping as the reason the caller passes its own key. That overlap is refused: `write` and `read` both return at `_readLoopOwner`, and `_poll` awaits each read before it arms the next. On a serial port the key is 1 for every request, so naming it discriminates nothing and the delete would take an entry still in flight. The docblock says that, and says what keeps it from happening: the client sends one request at a time. `encodeRegisters` opened with "The words a value of this type occupies", which is the question `registerWidth` answers. It says which of the two it is. --- src/main/modules/modbusClient.ts | 14 ++++++++++---- src/main/modules/modbusServer/registers.ts | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index a2315054..4af37dda 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -716,10 +716,16 @@ export class ModbusClient { /** * Log the request filed under `transactionIdKey`, and only that one. * - * The caller names its own request because the table holds everyone else's. - * A write and a read loop overlap: `write` files its transaction, the poll - * the user then starts files a second, and taking the last entry logged the - * read as the write and took the read's entry with it. + * The caller names its own request because the table holds whatever earlier + * requests were never logged out of it. Taking the last entry instead logged + * one caller's frame as another's and deleted the entry that caller was + * still waiting on. + * + * On a serial port that key is 1 for every request, so naming it + * discriminates nothing and the delete below would take an entry still in + * flight. What keeps them apart there is that there is only ever one: + * `write` and `read` are both refused while `_readLoopOwner` answers, and a + * poll awaits each request before it sends the next. */ private _logTransaction = (transactionIdKey: string, errorMessage: string | undefined): void => { const rawTransactions = this._internals()._transactions diff --git a/src/main/modules/modbusServer/registers.ts b/src/main/modules/modbusServer/registers.ts index fea479af..17cc7b2f 100644 --- a/src/main/modules/modbusServer/registers.ts +++ b/src/main/modules/modbusServer/registers.ts @@ -10,7 +10,8 @@ import { import { Windows } from '../../windows' /** - * The words a value of this type occupies. + * The words a value encodes to, which is not the same question as how many. + * `registerWidth` answers that one. * * Two writers reach it: `ModbusServer.addRegister` for a fixed register, and * `ValueGenerator` on every tick. Both used to spell the pair of encoders out, From 713dc0b7011bb4e4db66f77d0325855f44145906 Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 17:55:22 +0200 Subject: [PATCH 015/137] fix: the Linear Interpolation fields kept two decimals of what you typed `interpolation.tsx`'s four fields, x1, x2, y1 and y2, shared a mask with four on the server side and wrote no `scale`, so `IMask.MaskedNumber` fell back to its own default of 2. The mask cuts while you type, so an endpoint of 0.0625 went in as 0.06 and every row scaled through it came out 4% off. The other four wrote `scale: integer ? 0 : 7`. `decimalMask(integer)` is the one copy, beside `integerMask`, and it takes all five sites. `decimalMask.test.ts` holds the scale at 7 and at 0, and was seen red with the 2 back in place. On this commit alone: lint 0, typecheck 0, 68 files and 1392 passed, measured in a worktree at it. This left `0f67bff` afterwards. It was the one behaviour that commit changed, and a commit saying `refactor` while a user sees a different number is not what it says it is. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 4 +++ .../columns/WriteModal/WriteModal.tsx | 11 ++----- .../RegisterGrid/columns/interpolation.tsx | 10 ++---- .../AddRegister/maskedInputs.tsx | 27 +++------------- .../inputs/__tests__/decimalMask.test.ts | 28 +++++++++++++++++ .../components/shared/inputs/decimalMask.ts | 31 +++++++++++++++++++ .../components/shared/inputs/integerMask.ts | 3 +- 7 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/shared/inputs/__tests__/decimalMask.test.ts create mode 100644 src/renderer/src/components/shared/inputs/decimalMask.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a60630db..5bcbcb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -309,6 +309,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unit the server hosts, and nothing goes back on the line. Modbux says so once while the RTU server is running and unit 0 holds registers. Over TCP there is no broadcast and unit 0 stays an ordinary address. +- **Linear Interpolation takes seven decimals now.** Its four fields stopped at + two, so an endpoint of 0.0625 went in as 0.06 and every row scaled through it + came out 4% off. They take what the value, min and max fields on the server + side have always taken. - **The parity list offers none, even and odd.** Mark and space were in it, and on macOS and Linux picking either one failed the connection outright, because the serial layer Modbux uses has no setting for them on those platforms. A diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index c237032d..e1d3899f 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -16,7 +16,8 @@ import { useDataZustand } from '@renderer/context/data.zustand' import { useMinMaxInteger } from '@renderer/hooks' import { notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' -import { IMaskInput, IMask } from 'react-imask' +import { decimalMask } from '@renderer/components/shared/inputs/decimalMask' +import { IMaskInput } from 'react-imask' import { seedCoils, useValueInputZustand, writeDataTypeFor } from './writeModal.zustand' const ValueInputForward = forwardRef((props, ref) => { @@ -27,16 +28,10 @@ const ValueInputForward = forwardRef((props, r return ( { set(value, notEmpty(value)) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx index fcac79ae..1f5b1297 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/interpolation.tsx @@ -27,7 +27,8 @@ import { useRef, useState } from 'react' -import { IMask, IMaskInput } from 'react-imask' +import { decimalMask } from '@renderer/components/shared/inputs/decimalMask' +import { IMaskInput } from 'react-imask' const defaultInterpolation: RegisterLinearInterpolation = { x1: '0', x2: '1', y1: '0', y2: '1' } @@ -41,12 +42,7 @@ const ValueInputForward = forwardRef((props, r return ( { set(value, true) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx index 77aaec7c..cc3aad62 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx @@ -6,11 +6,12 @@ * than spread through the dialog. */ import { useAddRegisterZustand } from './addRegister.zustand' +import { decimalMask } from '@renderer/components/shared/inputs/decimalMask' import { integerMask } from '@renderer/components/shared/inputs/integerMask' import { meme } from '@renderer/components/shared/inputs/meme' import { MaskInputProps } from '@renderer/components/shared/inputs/types' import { forwardRef } from 'react' -import { IMask, IMaskInput } from 'react-imask' +import { IMaskInput } from 'react-imask' import { MAX_UTF8_LENGTH, notEmpty, registerWidth } from '@shared' import { useMinMaxInteger } from '@renderer/hooks' @@ -47,16 +48,10 @@ const ValueInputForward = forwardRef((props, r return ( { set(value, notEmpty(value)) @@ -78,16 +73,10 @@ const MinInputForward = forwardRef((props, ref return ( set(value, notEmpty(value))} /> @@ -107,16 +96,10 @@ const MaxInputForward = forwardRef((props, ref return ( set(value, notEmpty(value))} /> diff --git a/src/renderer/src/components/shared/inputs/__tests__/decimalMask.test.ts b/src/renderer/src/components/shared/inputs/__tests__/decimalMask.test.ts new file mode 100644 index 00000000..cca8884d --- /dev/null +++ b/src/renderer/src/components/shared/inputs/__tests__/decimalMask.test.ts @@ -0,0 +1,28 @@ +// The five copies of this object had already drifted: four set +// `scale: integer ? 0 : 7` and `interpolation.tsx` set none, so its four fields +// ran on `IMask.MaskedNumber`'s default of 2. One function, and the scale is a +// thing a test can hold. +import { describe, expect, it } from 'vitest' +import { IMask } from 'react-imask' +import { decimalMask } from '../decimalMask' +import { integerMask } from '../integerMask' + +describe('the decimal mask', () => { + it('takes seven digits after the separator for a fractional type', () => { + expect(decimalMask(false).scale).toBe(7) + }) + + it('takes none for an integer type, which is what integerMask says too', () => { + expect(decimalMask(true).scale).toBe(0) + expect(decimalMask(true).scale).toBe(integerMask.scale) + }) + + it('reads both separators as the radix, and prints the dot', () => { + const { radix, mapToRadix, thousandsSeparator, mask } = decimalMask(false) + + expect(radix).toBe('.') + expect(mapToRadix).toEqual(['.', ',']) + expect(thousandsSeparator).toBe('') + expect(mask).toBe(IMask.MaskedNumber) + }) +}) diff --git a/src/renderer/src/components/shared/inputs/decimalMask.ts b/src/renderer/src/components/shared/inputs/decimalMask.ts new file mode 100644 index 00000000..66813d21 --- /dev/null +++ b/src/renderer/src/components/shared/inputs/decimalMask.ts @@ -0,0 +1,31 @@ +import { IMask } from 'react-imask' + +interface DecimalMask { + mask: typeof IMask.MaskedNumber + scale: number + thousandsSeparator: string + radix: string + mapToRadix: string[] +} + +/** + * The mask a field takes when the value it carries may hold a fraction. + * + * `integerMask` is the other half, for a field whose value is always a whole + * number. Here `integer` decides: `useMinMaxInteger` answers it true for the + * seven integer data types and false for the rest, so a field bound to an + * `int32` register refuses a separator and one bound to a `float` takes seven + * digits after it. + * + * The five copies this replaces had already drifted. Four wrote + * `scale: integer ? 0 : 7`; `interpolation.tsx` wrote no scale, so its four + * fields ran on `IMask.MaskedNumber`'s default of 2 and dropped every digit + * after the second. + */ +export const decimalMask = (integer: boolean): DecimalMask => ({ + mask: IMask.MaskedNumber, + scale: integer ? 0 : 7, + thousandsSeparator: '', + radix: '.', + mapToRadix: ['.', ','] +}) diff --git a/src/renderer/src/components/shared/inputs/integerMask.ts b/src/renderer/src/components/shared/inputs/integerMask.ts index 4a4203b2..30d42798 100644 --- a/src/renderer/src/components/shared/inputs/integerMask.ts +++ b/src/renderer/src/components/shared/inputs/integerMask.ts @@ -8,7 +8,6 @@ import { IMask } from 'react-imask' * hands its setter a string `Number()` answers `NaN` to. A scale of 0 refuses * both separators, and the digits around one still arrive. * - * The fields that do carry a fraction set their own scale and radix beside the - * mask, and this is not for them. + * The fields that may carry a fraction take `decimalMask` instead. */ export const integerMask = { mask: IMask.MaskedNumber, scale: 0 } From fe667cb37a2fb0ebbeeb184e57e6c2ea6f76145d Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 17:55:52 +0200 Subject: [PATCH 016/137] refactor: one shape, written out again in six places in the renderer Round D of the annoying half: R20, T17, T18, T26, T27 and T31. R16 was the seventh and is the commit before this one, because it is a fix. T17 is `ServerPanel`, which holds the outer box, the Paper, the title and the scroll box, and takes `openFlex`, `openMinWidth` and `contentSx`. The scroll box lives there rather than in each caller because its `top: 38` is `ServerPartTitle`'s `height: 38`, and the two have to move together. The `collapse` guard went with it, so neither caller reads the field. T18 is `utf8RegisterLength` and `utf8MaxBytes` in the dialog's helpers, plus `registerLength` in `FIELD_DEFAULTS`. The width comes off `registerWidth`, which C1 settled as the one table, and the five literal tens are gone. T26 is `setSerialOption` and `setRegisterConfigField` in `client.zustand.ts`. Nine setters that differed in nothing but a key are one line each. The eight that stay written out each add something: a payload of another shape, a string to convert, a validity flag, a grid to clear, a read to ask for. T27 is `boolArraysOf` in the server store's helpers. `resetBools` and `syncBoolsWithBackend` each built two arrays of 65536 from one unit's map, and main writes both by index on every sync, so the type that is not being cleared has to arrive carrying what it had. T31 is the three layout toggles writing `!state.showLog` rather than reading the same field back through `get()`, which no longer reaches the factory. R20 is nineteen short names spelled out, across the two stores, the server store's helpers and `ServerBooleans`. Four more left with T27's fold. Three test files added and two extended, and each was seen red first: a wrong key in the setter fold, a pair that answers the cleared arrays twice, a fallback that no longer catches an empty field, a toggle stuck on, and the collapse guard taken out. Measured over this commit and the fix before it together, which is how round D was built: lint 0, typecheck 0, 71 files and 1407 passed. E2E 02, 04, 06, 09, 15, 16, 18, 26 and 30: 242 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../server/ServerGrid/ServerBooleans.tsx | 90 +++-------- .../server/ServerGrid/ServerPanel.tsx | 88 ++++++++++ .../AddRegister/AddRegister.tsx | 6 +- .../addRegister.zustand.helpers.test.ts | 40 +++++ .../addRegister.zustand.helpers.ts | 30 +++- .../AddRegister/addRegister.zustand.ts | 15 +- .../AddRegister/valueParameters.tsx | 4 +- .../ServerRegisters/ServerRegisters.tsx | 57 ++----- .../ServerGrid/__tests__/collapse.test.tsx | 34 +++- .../context/__tests__/configSetters.test.ts | 107 ++++++++++++ .../context/__tests__/layoutToggles.test.ts | 44 +++++ .../src/context/__tests__/resetBools.test.ts | 62 +++++++ src/renderer/src/context/client.zustand.ts | 152 ++++++++---------- .../src/context/client.zustand.types.ts | 2 + src/renderer/src/context/data.zustand.ts | 4 +- src/renderer/src/context/layout.zustand.ts | 9 +- .../src/context/server.zustand.helpers.ts | 38 +++-- src/renderer/src/context/server.zustand.ts | 47 +++--- 18 files changed, 564 insertions(+), 265 deletions(-) create mode 100644 src/renderer/src/components/server/ServerGrid/ServerPanel.tsx create mode 100644 src/renderer/src/context/__tests__/configSetters.test.ts create mode 100644 src/renderer/src/context/__tests__/layoutToggles.test.ts create mode 100644 src/renderer/src/context/__tests__/resetBools.test.ts diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx index 8dec2b86..f6298a1a 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx @@ -2,19 +2,16 @@ import { DeleteFilled, PlusCircleOutlined } from '@ant-design/icons' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import { InputBaseComponentProps } from '@mui/material/InputBase' -import Paper from '@mui/material/Paper' import TextField from '@mui/material/TextField' import { alpha } from '@mui/material/styles' import { useServerZustand } from '@renderer/context/server.zustand' import { BooleanRegisters, ServerBoolEntry } from '@shared' import { ElementType, useCallback, useEffect, useMemo, useState } from 'react' -import ServerPartTitle from './ServerPartTitle' +import ServerPanel from './ServerPanel' import { meme } from '@renderer/components/shared/inputs/meme' -import useServerGridZustand from './serverGrid.zustand' import ServerBit from './shared/ServerBit' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { maskInputProps } from '@renderer/components/shared/inputs/types' -import { gridSurface } from '@renderer/theme' interface ServerBooleanProps { name: string @@ -140,7 +137,7 @@ const ServerBoolList = meme(({ type }: Omit) => { // ─── Inline Add bar ────────────────────────────────────────────────────────── -const getRegs = (type: BooleanRegisters): Record => { +const getBoolMap = (type: BooleanRegisters): Record => { const serverZustand = useServerZustand.getState() return ( serverZustand.serverRegisters[serverZustand.selectedUuid]?.[ @@ -149,14 +146,14 @@ const getRegs = (type: BooleanRegisters): Record => { ) } -const nextFree = (from: number, regs: Record): number => { - let a = from - while (a <= 65535 && a in regs) a++ - return a +const nextFree = (from: number, boolMap: Record): number => { + let address = from + while (address <= 65535 && address in boolMap) address++ + return address } const AddBoolInline = meme(({ type }: Omit) => { - const [address, setAddress] = useState(() => String(nextFree(0, getRegs(type)))) + const [address, setAddress] = useState(() => String(nextFree(0, getBoolMap(type)))) // Reset to 0 when all bools are cleared const empty = useServerZustand((z) => { @@ -172,12 +169,12 @@ const AddBoolInline = meme(({ type }: Omit) => { let nextAddress = Number(address) if (isNaN(nextAddress) || nextAddress < 0 || nextAddress > 65535) return // If typed address is already taken, snap to next free - const regs = getRegs(type) - if (nextAddress in regs) nextAddress = nextFree(nextAddress, regs) + const boolMap = getBoolMap(type) + if (nextAddress in boolMap) nextAddress = nextFree(nextAddress, boolMap) if (nextAddress > 65535) return useServerZustand.getState().addBool(type, nextAddress) // Auto-increment to next free address - const next = nextFree(nextAddress + 1, getRegs(type)) + const next = nextFree(nextAddress + 1, getBoolMap(type)) if (next <= 65535) setAddress(String(next)) }, [type, address]) @@ -204,7 +201,7 @@ const AddBoolInline = meme(({ type }: Omit) => { input: { inputComponent: UIntInput as unknown as ElementType, inputProps: maskInputProps({ - set: (v) => setAddress(String(v)) + set: (value) => setAddress(String(value)) }) } }} @@ -224,64 +221,17 @@ const AddBoolInline = meme(({ type }: Omit) => { // ─── Main component ────────────────────────────────────────────────────────── const ServerBooleans = meme(({ name, type }: ServerBooleanProps) => { - const collapse = useServerGridZustand((z) => z.collapse[type]) - const allOtherCollapsed = useServerGridZustand((z) => { - const entries = Object.entries(z.collapse) - const filtered = entries.filter(([k]) => k !== type) - return filtered.every((entry) => entry[1]) - }) - return ( - - - - {/* - Positioned rather than flexed, the way ServerRegisters does it. A - flex child takes its share of a parent that has a height, and this - one does not: the Paper asks for 100% of a box that only carries a - minHeight. So the list grew instead, and the overflow landed on the - view rather than here. Anchored top to bottom, the height comes from - the Paper and the scrollbar appears where it belongs. - */} - - {!collapse && ( - <> - - - - )} - - - + + + ) }) diff --git a/src/renderer/src/components/server/ServerGrid/ServerPanel.tsx b/src/renderer/src/components/server/ServerGrid/ServerPanel.tsx new file mode 100644 index 00000000..a8075e05 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerPanel.tsx @@ -0,0 +1,88 @@ +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import { SxProps, Theme } from '@mui/material/styles' +import { meme } from '@renderer/components/shared/inputs/meme' +import { gridSurface } from '@renderer/theme' +import { RegisterType } from '@shared' +import { ReactNode } from 'react' +import ServerPartTitle from './ServerPartTitle' +import useServerGridZustand from './serverGrid.zustand' + +interface ServerPanelProps { + name: string + type: RegisterType + /** What the outer box takes of the row's width while the section is open. */ + openFlex: number + openMinWidth: number + /** What the scroll box adds to the anchoring below. */ + contentSx: SxProps + children: ReactNode +} + +/** + * One titled section of the server grid, with its own scroll box. + * + * The scroll box is positioned rather than flexed. A flex child takes its share + * of a parent that has a height, and this one does not: the Paper asks for 100% + * of a box that only carries a minHeight. So the list grew instead, and the + * overflow landed on the view rather than here. Anchored top to bottom, the + * height comes from the Paper and the scrollbar appears where it belongs. + * + * That anchor is why the box lives here rather than in each caller: `top: 38` + * is `ServerPartTitle`'s `height: 38`, and the two have to move together. + * + * `children` is guarded on `collapse` here, so neither caller reads it. + */ +const ServerPanel = meme( + ({ name, type, openFlex, openMinWidth, contentSx, children }: ServerPanelProps) => { + const collapse = useServerGridZustand((z) => z.collapse[type]) + const allOtherCollapsed = useServerGridZustand((z) => { + const entries = Object.entries(z.collapse) + const filtered = entries.filter(([registerType]) => registerType !== type) + return filtered.every((entry) => entry[1]) + }) + + return ( + + + + + {!collapse && children} + + + + ) + } +) + +export default ServerPanel diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx index 8aac1d04..b9237762 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx @@ -9,7 +9,6 @@ import { FixedOrGenerator, ValueParameters } from './valueParameters' import { AddressField, DataTypeSelect, CommentField } from './registerFields' import { AddButtons, DeleteButton } from './addRegisterActions' import { FIELD_DEFAULTS, inTimestampWindow, isTimestampType } from './addRegister.zustand.helpers' -import { DEFAULT_UTF8_LENGTH } from '@shared' const AddRegister = meme(() => { const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) @@ -74,7 +73,10 @@ const AddRegister = meme(() => { if (dataType === 'utf8') { addRegisterZustand.setStringValue(stringValue ?? '') - addRegisterZustand.setRegisterLength(String(length ?? DEFAULT_UTF8_LENGTH), true) + addRegisterZustand.setRegisterLength( + length === undefined ? FIELD_DEFAULTS.registerLength : String(length), + true + ) addRegisterZustand.setValue(FIELD_DEFAULTS.value, true) } else if (value !== undefined && isTimestampType(dataType)) { // The picker works in milliseconds and a unix register stores seconds. diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts index 6daf9f39..9ba388b6 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect } from 'vitest' +import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' import { + FIELD_DEFAULTS, isAddressInUse, isFormDirty, toFormSnapshot, toRegisterParams, + utf8MaxBytes, + utf8RegisterLength, type RegisterFormSnapshot, type RegisterFormValues } from '../addRegister.zustand.helpers' @@ -253,3 +257,39 @@ describe('toRegisterParams', () => { }) }) }) + +// ─── The utf8 width, and the bytes it buys ────────────────────────── + +// Five sites wrote the fallback as the literal 10 and three named the +// constant. The byte ceiling was computed twice, once for the helper text +// under the field and once for the flag that turns it red, and a disagreement +// between those two is a field that says a string fits and refuses it. + +describe('utf8RegisterLength', () => { + it('reads what the field holds', () => { + expect(utf8RegisterLength('24')).toBe(24) + }) + + it('falls back to the default for an empty field and for a typed zero', () => { + expect(utf8RegisterLength('')).toBe(DEFAULT_UTF8_LENGTH) + expect(utf8RegisterLength('0')).toBe(DEFAULT_UTF8_LENGTH) + }) + + it('answers what registerWidth answers for the same register', () => { + expect(utf8RegisterLength('24')).toBe(registerWidth('utf8', 24)) + expect(utf8RegisterLength('')).toBe(registerWidth('utf8')) + }) +}) + +describe('utf8MaxBytes', () => { + it('is two bytes per register', () => { + expect(utf8MaxBytes('24')).toBe(48) + expect(utf8MaxBytes('')).toBe(DEFAULT_UTF8_LENGTH * 2) + }) +}) + +describe('FIELD_DEFAULTS', () => { + it('holds the length field as the text the default width prints as', () => { + expect(FIELD_DEFAULTS.registerLength).toBe(String(DEFAULT_UTF8_LENGTH)) + }) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts index d554eac6..a97adb86 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts @@ -1,6 +1,7 @@ import { BaseDataType, DataType, + DEFAULT_UTF8_LENGTH, getMinMaxValues, NumberRegisters, RegisterParams, @@ -82,15 +83,38 @@ export interface RegisterFormValues { * * The dialog offers both a fixed value and a generator's range, and a register * carries one set or the other. Switching to the set it does not carry has to - * land on something a user can submit. + * land on something a user can submit. `registerLength` is here for the same + * reason: only a `utf8` register carries one. */ export const FIELD_DEFAULTS = { value: '0', min: '0', max: '1', - interval: '1' + interval: '1', + registerLength: String(DEFAULT_UTF8_LENGTH) } +/** + * How many registers the length field asks for, which is the width the string + * gets. + * + * The field holds text, and an empty one or a typed `0` both fall back to the + * width `registerWidth` already answers for a `utf8` register with no length + * of its own. + */ +export const utf8RegisterLength = (registerLength: string): number => + registerWidth('utf8', Number(registerLength) || undefined) + +/** + * The bytes `stringValue` is measured against: two per register. + * + * Read twice, by the helper text under the field and by the flag that turns it + * red, and a disagreement between those two is a field that says a string fits + * and refuses it. + */ +export const utf8MaxBytes = (registerLength: string): number => + utf8RegisterLength(registerLength) * 2 + /** * The fields a user can change while the dialog is open. `registerType` is not * one of them: it comes from the button that opened the dialog, and no control @@ -154,7 +178,7 @@ export const toRegisterParams = (form: RegisterFormValues): RegisterParams => { ...base, value: 0, stringValue: form.stringValue, - length: Number(form.registerLength) || 10 + length: utf8RegisterLength(form.registerLength) } } diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index b1327a81..3144099c 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -4,7 +4,6 @@ import { useServerZustand } from '@renderer/context/server.zustand' import { BaseDataType, DataType, - DEFAULT_UTF8_LENGTH, getAddressFitError, NumberRegisters, registerWidth, @@ -18,7 +17,9 @@ import { isAddressInUse, RegisterFormSnapshot, toFormSnapshot, - toRegisterParams + toRegisterParams, + utf8MaxBytes, + utf8RegisterLength } from './addRegister.zustand.helpers' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -97,7 +98,7 @@ const validateAddress = ( const uuid = serverZustand.selectedUuid const unitId = serverZustand.getUnitId(uuid) const addressNum = Number(address) - const length = dataType === 'utf8' ? Number(registerLength) || DEFAULT_UTF8_LENGTH : undefined + const length = dataType === 'utf8' ? utf8RegisterLength(registerLength) : undefined const addressInUse = getAddressInUse(uuid, unitId, registerType, dataType, addressNum, length) const addressFitError = getAddressFitError(dataType, addressNum, length) @@ -284,7 +285,7 @@ export const useAddRegisterZustand = create { const { registerLength } = getState() - const maxBytes = (Number(registerLength) || DEFAULT_UTF8_LENGTH) * 2 + const maxBytes = utf8MaxBytes(registerLength) const valid = new TextEncoder().encode(value).length <= maxBytes set((state) => { state.stringValue = value @@ -292,7 +293,7 @@ export const useAddRegisterZustand = create set((state) => { state.registerLength = registerLength @@ -322,7 +323,7 @@ export const useAddRegisterZustand = create { const StringValueField = meme(() => { const stringValue = useAddRegisterZustand((z) => z.stringValue) - const maxBytes = useAddRegisterZustand((z) => (Number(z.registerLength) || 10) * 2) + const maxBytes = useAddRegisterZustand((z) => utf8MaxBytes(z.registerLength)) const valid = useAddRegisterZustand((z) => z.valid.stringValue) const handleChange = useCallback((event: ChangeEvent): void => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 77dc693e..79a1bb93 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -3,7 +3,6 @@ import ExpandLess from '@mui/icons-material/ExpandLess' import ExpandMore from '@mui/icons-material/ExpandMore' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' -import Paper from '@mui/material/Paper' import { alpha } from '@mui/material/styles' import { formatUnixSeconds, @@ -14,11 +13,9 @@ import { } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { gridSurface } from '@renderer/theme' import { useCallback, useMemo, useState } from 'react' import { useAddRegisterZustand } from './AddRegister/addRegister.zustand' -import ServerPartTitle from '../ServerPartTitle' -import useServerGridZustand from '../serverGrid.zustand' +import ServerPanel from '../ServerPanel' import ServerBitMapDetail from './ServerBitMapDetail/ServerBitMapDetail' interface RowProps { @@ -160,52 +157,16 @@ interface ServerRegistersProps { } const ServerRegisters = meme(({ name, type }: ServerRegistersProps) => { - const collapse = useServerGridZustand((z) => z.collapse[type]) - const allOtherCollapsed = useServerGridZustand((z) => { - const entries = Object.entries(z.collapse) - const filtered = entries.filter(([k]) => k !== type) - return filtered.every((entry) => entry[1]) - }) - return ( - - - - - {!collapse && } - - - + + ) }) diff --git a/src/renderer/src/components/server/ServerGrid/__tests__/collapse.test.tsx b/src/renderer/src/components/server/ServerGrid/__tests__/collapse.test.tsx index c8b94fce..7d8da295 100644 --- a/src/renderer/src/components/server/ServerGrid/__tests__/collapse.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/__tests__/collapse.test.tsx @@ -12,11 +12,22 @@ import { describe, it, expect, vi } from 'vitest' import type { ServerBool } from '@shared' const coils: ServerBool = { 0: { value: true, comment: 'run' } } +const holdingRegisters = { + 0: { + value: 7, + params: { + address: 0, + dataType: 'int16' as const, + registerType: 'holding_registers' as const, + comment: 'level' + } + } +} const serverState = { selectedUuid: 'main', getUnitId: (): string => '0', unitId: { main: '0' }, - serverRegisters: { main: { '0': { coils } } } + serverRegisters: { main: { '0': { coils, holding_registers: holdingRegisters } } } } vi.mock('@renderer/context/server.zustand', () => ({ @@ -27,6 +38,7 @@ vi.mock('@renderer/context/server.zustand', () => ({ })) import ServerBooleans from '../ServerBooleans' +import ServerRegisters from '../ServerRegisters/ServerRegisters' import useServerGridZustand from '../serverGrid.zustand' const allOpen = { @@ -53,3 +65,23 @@ describe('a collapsed boolean section', () => { expect(screen.queryByTestId('server-bool-row-coils-0')).toBeNull() }) }) + +// Both sections are one `ServerPanel` now, and the guard that hides the list +// went with it. `ServerBooleans` was the only caller a test reached. +describe('a collapsed register section', () => { + it('shows its rows while it is open', () => { + useServerGridZustand.setState({ collapse: allOpen }) + render() + + expect(screen.getByTestId('server-reg-value-holding_registers-0')).toBeInTheDocument() + expect(screen.getByTestId('server-edit-reg-holding_registers-0')).toBeInTheDocument() + }) + + it('renders no row at all once collapsed', () => { + useServerGridZustand.setState({ collapse: { ...allOpen, holding_registers: true } }) + render() + + expect(screen.queryByTestId('server-reg-value-holding_registers-0')).toBeNull() + expect(screen.queryByTestId('server-edit-reg-holding_registers-0')).toBeNull() + }) +}) diff --git a/src/renderer/src/context/__tests__/configSetters.test.ts b/src/renderer/src/context/__tests__/configSetters.test.ts new file mode 100644 index 00000000..1b04eb58 --- /dev/null +++ b/src/renderer/src/context/__tests__/configSetters.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// +// Nine setters that differ in nothing but a key were written out nine times, +// and a fold over the key is a fold that can send the wrong one: the store +// would still write the field the caller named while main got another. Each +// case here names both halves, the payload that went out and the field that +// came back. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' + +const load = async (): Promise => + await import('../client.zustand') + +let calls: ApiCall[] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() + calls = [] +}) + +/** The last payload the named channel carried, or undefined if it never ran. */ +const lastPayload = (method: string): unknown => + calls.filter((call) => call.method === method).at(-1)?.payload + +describe('the four serial options', () => { + it('each send their own key under rtu.options and write it back', async () => { + const { useClientZustand } = await load() + recordApiCalls(calls) + + await useClientZustand.getState().setBaudRate('19200') + expect(lastPayload('updateConnectionConfig')).toEqual({ + rtu: { options: { baudRate: '19200' } } + }) + + await useClientZustand.getState().setParity('even') + expect(lastPayload('updateConnectionConfig')).toEqual({ rtu: { options: { parity: 'even' } } }) + + await useClientZustand.getState().setDataBits(7) + expect(lastPayload('updateConnectionConfig')).toEqual({ rtu: { options: { dataBits: 7 } } }) + + await useClientZustand.getState().setStopBits(2) + expect(lastPayload('updateConnectionConfig')).toEqual({ rtu: { options: { stopBits: 2 } } }) + + const { options } = useClientZustand.getState().connectionConfig.rtu + expect(options).toEqual({ baudRate: '19200', parity: 'even', dataBits: 7, stopBits: 2 }) + }) + + it('write nothing while a connection stands', async () => { + const { useClientZustand } = await load() + const before = useClientZustand.getState().connectionConfig.rtu.options.baudRate + useClientZustand.getState().setClientState({ + connectState: 'connected', + polling: false, + scanningUnitIds: false, + scanningRegisters: false, + reading: false + }) + recordApiCalls(calls) + + await useClientZustand.getState().setBaudRate('19200') + + expect(lastPayload('updateConnectionConfig')).toBeUndefined() + expect(useClientZustand.getState().connectionConfig.rtu.options.baudRate).toBe(before) + }) +}) + +describe('the five register config fields', () => { + it('each send their own key and write it back', async () => { + const { useClientZustand } = await load() + recordApiCalls(calls) + + await useClientZustand.getState().setAddressBase('1') + expect(lastPayload('updateRegisterConfig')).toEqual({ addressBase: '1' }) + + await useClientZustand.getState().setShow64BitValues(true) + expect(lastPayload('updateRegisterConfig')).toEqual({ show64BitValues: true }) + + await useClientZustand.getState().setAdvancedMode(true) + expect(lastPayload('updateRegisterConfig')).toEqual({ advancedMode: true }) + + await useClientZustand.getState().setPollRate(2000) + expect(lastPayload('updateRegisterConfig')).toEqual({ pollRate: 2000 }) + + await useClientZustand.getState().setTimeout(3000) + expect(lastPayload('updateRegisterConfig')).toEqual({ timeout: 3000 }) + + const { registerConfig } = useClientZustand.getState() + expect(registerConfig.addressBase).toBe('1') + expect(registerConfig.show64BitValues).toBe(true) + expect(registerConfig.advancedMode).toBe(true) + expect(registerConfig.pollRate).toBe(2000) + expect(registerConfig.timeout).toBe(3000) + }) + + // `ReadTimingSchema` is `multipleOf(1000)`, so 1500 is a value the boundary + // refuses and the field is one no mask holds to that step. + it('keep the value they had when main refuses the payload', async () => { + const { useClientZustand } = await load() + await useClientZustand.getState().setPollRate(2000) + + await useClientZustand.getState().setPollRate(1500) + + expect(useClientZustand.getState().registerConfig.pollRate).toBe(2000) + }) +}) diff --git a/src/renderer/src/context/__tests__/layoutToggles.test.ts b/src/renderer/src/context/__tests__/layoutToggles.test.ts new file mode 100644 index 00000000..a7b9abc7 --- /dev/null +++ b/src/renderer/src/context/__tests__/layoutToggles.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +// +// The three toggles read their own field through `get()` from inside a +// mutative recipe, where the draft holds it. They read the draft now, and a +// draft that answered a stale value would make a toggle a no-op on the second +// press rather than on the first. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stubRenderer } from './stubRenderer' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +describe('the layout toggles', () => { + it('flip their own field and flip it back', async () => { + const { useLayoutZustand } = await import('../layout.zustand') + + for (const [toggle, field] of [ + ['toggleShowLog', 'showLog'], + ['toggleShowClientRawValues', 'showClientRawValues'], + ['toggleShowGridWhileScanning', 'showGridWhileScanning'] + ] as const) { + const before = useLayoutZustand.getState()[field] + + useLayoutZustand.getState()[toggle]() + expect(useLayoutZustand.getState()[field]).toBe(!before) + + useLayoutZustand.getState()[toggle]() + expect(useLayoutZustand.getState()[field]).toBe(before) + } + }) + + it('leaves the other two where they were', async () => { + const { useLayoutZustand } = await import('../layout.zustand') + const { showClientRawValues, showGridWhileScanning } = useLayoutZustand.getState() + + useLayoutZustand.getState().toggleShowLog() + + expect(useLayoutZustand.getState().showClientRawValues).toBe(showClientRawValues) + expect(useLayoutZustand.getState().showGridWhileScanning).toBe(showGridWhileScanning) + }) +}) diff --git a/src/renderer/src/context/__tests__/resetBools.test.ts b/src/renderer/src/context/__tests__/resetBools.test.ts new file mode 100644 index 00000000..f86b6932 --- /dev/null +++ b/src/renderer/src/context/__tests__/resetBools.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +// +// `resetBools` and `syncBoolsWithBackend` each built two arrays of 65536 from +// one unit's map, and they are one helper now. Main writes both arrays by +// index on every sync, so the type that is not being cleared has to arrive +// carrying what it had: a helper that answered the cleared pair twice would +// blank the other half of the unit and nothing on screen would say so. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAIN_SERVER_UUID, type SyncBoolsParameters } from '@shared' +import { recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' + +let calls: ApiCall[] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() + calls = [] +}) + +/** The payload of the last `syncBools`, typed, so the test can index it. */ +const lastSync = (): SyncBoolsParameters => { + const payload = calls.filter((call) => call.method === 'syncBools').at(-1)?.payload + if (payload === undefined) throw new Error('nothing called syncBools') + return payload as SyncBoolsParameters +} + +describe('clearing one boolean type', () => { + it('empties that type and leaves the other one standing', async () => { + const { useServerZustand } = await import('../server.zustand') + const serverZustand = useServerZustand.getState() + serverZustand.addBool('coils', 3) + serverZustand.setBool({ registerType: 'coils', address: 3, boolState: true }) + serverZustand.addBool('discrete_inputs', 7) + serverZustand.setBool({ registerType: 'discrete_inputs', address: 7, boolState: true }) + recordApiCalls(calls) + + useServerZustand.getState().resetBools('coils') + + const unit = useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0'] + expect(unit?.coils).toEqual({}) + expect(unit?.discrete_inputs?.[7]?.value).toBe(true) + }) + + it('sends main a blank array for it and the values for the other', async () => { + const { useServerZustand } = await import('../server.zustand') + const serverZustand = useServerZustand.getState() + serverZustand.addBool('coils', 3) + serverZustand.setBool({ registerType: 'coils', address: 3, boolState: true }) + serverZustand.addBool('discrete_inputs', 7) + serverZustand.setBool({ registerType: 'discrete_inputs', address: 7, boolState: true }) + recordApiCalls(calls) + + useServerZustand.getState().resetBools('coils') + + const sent = lastSync() + expect(sent.coils).toHaveLength(65536) + expect(sent.coils.some((value) => value)).toBe(false) + expect(sent.discrete_inputs[7]).toBe(true) + expect(sent.discrete_inputs.filter((value) => value)).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 1ecc8b49..6ebc182d 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -4,6 +4,7 @@ import { useLayoutZustand } from './layout.zustand' import { mutative } from 'zustand-mutative' import { persist } from 'zustand/middleware' import { + ClientSet, PersistedClientZustand, PersistedClientZustandSchema, ClientZustand @@ -16,7 +17,9 @@ import { carryFormerClientState, CLIENT_ZUSTAND_STORAGE_KEY, emptyRegisterMapping, - RegisterMapping + RegisterConfig, + RegisterMapping, + SerialPortOptions } from '@shared' import { useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' @@ -76,6 +79,52 @@ const clearRegisterDataWhenIdle = (): void => { useDataZustand.getState().setRegisterData([]) } +/** + * One serial option, sent and then written where main took it. + * + * `baudRate`, `parity`, `dataBits` and `stopBits` differ in nothing but the + * key. All four describe the line a connect opens, so all four are refused + * while one stands. `setCom` is not this shape: it carries a validity flag. + */ +const setSerialOption = async ( + set: ClientSet, + get: () => ClientZustand, + key: Key, + value: SerialPortOptions[Key] +): Promise => { + const currentState = get() + if (!currentState.ready) return + if (currentState.clientState.connectState !== 'disconnected') return + + if (!(await window.api.updateConnectionConfig({ rtu: { options: { [key]: value } } }))) return + + set((state) => { + state.connectionConfig.rtu.options[key] = value + }) +} + +/** + * One register config field, sent and then written where main took it. + * + * `addressBase`, `show64BitValues`, `advancedMode`, `pollRate` and `timeout` + * differ in nothing but the key. The four fields that are not here each end on + * something more: `address`, `length` and `type` clear the grid, `littleEndian` + * reads again, and `length` carries a validity flag as well. + */ +const setRegisterConfigField = async ( + set: ClientSet, + get: () => ClientZustand, + key: Key, + value: RegisterConfig[Key] +): Promise => { + if (!get().ready) return + if (!(await window.api.updateRegisterConfig({ [key]: value }))) return + + set((state) => { + state.registerConfig[key] = value + }) +} + /** * Ask main for a read, unless it is in no position to answer. * @@ -239,6 +288,12 @@ export const useClientZustand = create< // that wrote it persisted `null` and lost the whole config on the next // launch. A round trip is shorter than the gap between two keystrokes, so // no field waits on the answer. + // + // Nine of them differ in nothing but a key under `rtu.options` or under + // `registerConfig`, and those are one line each over `setSerialOption` + // or `setRegisterConfigField` above. Every setter still written out + // below has its own reason: a payload of another shape, a string to + // convert, a validity flag, a grid to clear, a read to ask for. setProtocol: async (protocol) => { const currentState = get() if (!currentState.ready) return @@ -303,77 +358,18 @@ export const useClientZustand = create< state.connectionConfig.rtu.com = com }) }, - setBaudRate: async (baudRate) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return - - if (!(await window.api.updateConnectionConfig({ rtu: { options: { baudRate } } }))) return - - set((state) => { - state.connectionConfig.rtu.options.baudRate = baudRate - }) - }, - setParity: async (parity) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return - - if (!(await window.api.updateConnectionConfig({ rtu: { options: { parity } } }))) return - - set((state) => { - state.connectionConfig.rtu.options.parity = parity - }) - }, - setDataBits: async (dataBits) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return - - if (!(await window.api.updateConnectionConfig({ rtu: { options: { dataBits } } }))) return - - set((state) => { - state.connectionConfig.rtu.options.dataBits = dataBits - }) - }, - setStopBits: async (stopBits) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return - - if (!(await window.api.updateConnectionConfig({ rtu: { options: { stopBits } } }))) return - - set((state) => { - state.connectionConfig.rtu.options.stopBits = stopBits - }) - }, + setBaudRate: (baudRate) => setSerialOption(set, get, 'baudRate', baudRate), + setParity: (parity) => setSerialOption(set, get, 'parity', parity), + setDataBits: (dataBits) => setSerialOption(set, get, 'dataBits', dataBits), + setStopBits: (stopBits) => setSerialOption(set, get, 'stopBits', stopBits), // // // Layout configuration settings - setAddressBase: async (addressBase) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ addressBase }))) return - - set((state) => { - state.registerConfig.addressBase = addressBase - }) - }, - setShow64BitValues: async (show64BitValues) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ show64BitValues }))) return - - set((state) => { - state.registerConfig.show64BitValues = show64BitValues - }) - }, - setAdvancedMode: async (advancedMode) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ advancedMode }))) return - - set((state) => { - state.registerConfig.advancedMode = advancedMode - }) - }, + setAddressBase: (addressBase) => setRegisterConfigField(set, get, 'addressBase', addressBase), + setShow64BitValues: (show64BitValues) => + setRegisterConfigField(set, get, 'show64BitValues', show64BitValues), + setAdvancedMode: (advancedMode) => + setRegisterConfigField(set, get, 'advancedMode', advancedMode), // Addressing setUnitId: async (unitId) => { if (!get().ready) return @@ -461,22 +457,8 @@ export const useClientZustand = create< if (readConfiguration) readWhenMainCan() }, // Reading - setPollRate: async (pollRate) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ pollRate }))) return - - set((state) => { - state.registerConfig.pollRate = pollRate - }) - }, - setTimeout: async (timeout) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ timeout }))) return - - set((state) => { - state.registerConfig.timeout = timeout - }) - }, + setPollRate: (pollRate) => setRegisterConfigField(set, get, 'pollRate', pollRate), + setTimeout: (timeout) => setRegisterConfigField(set, get, 'timeout', timeout), // Transaction lastSuccessfulTransactionMillis: null, setLastSuccessfulTransactionMillis: (value) => diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index deb24514..991f915e 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -107,6 +107,8 @@ export type ClientZustand = { validateSerialPort: (portPath: string) => Promise } & PersistedClientZustand +export type ClientSet = (recipe: (state: ClientZustand) => void) => void + export type MaskSetFn = (value: V, valid?: boolean) => void /** A masked setter that waits on the backend before the value settles. */ diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 715ff548..46be0c71 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -34,8 +34,8 @@ export const showMapping = (): void => { const registerMapping = useClientZustand.getState().registerMapping const type = useClientZustand.getState().registerConfig.type - Object.entries(registerMapping[type]).forEach(([addressString, m]) => { - if (!m || m.dataType === 'none' || !m.dataType) return + Object.entries(registerMapping[type]).forEach(([addressString, mapValue]) => { + if (!mapValue || mapValue.dataType === 'none' || !mapValue.dataType) return const address = parseInt(addressString, 10) const row: RegisterData = { diff --git a/src/renderer/src/context/layout.zustand.ts b/src/renderer/src/context/layout.zustand.ts index 7ee5b8d6..99940242 100644 --- a/src/renderer/src/context/layout.zustand.ts +++ b/src/renderer/src/context/layout.zustand.ts @@ -7,7 +7,7 @@ import { onEvent } from '@renderer/events' const isServerWindow = window.api.isServerWindow export const useLayoutZustand = create( - mutative((set, get) => ({ + mutative((set) => ({ showLog: false, version: '', setVersion: (version) => @@ -19,11 +19,11 @@ export const useLayoutZustand = create set((state) => { - state.showGridWhileScanning = !get().showGridWhileScanning + state.showGridWhileScanning = !state.showGridWhileScanning }), toggleShowClientRawValues: () => set((state) => { - state.showClientRawValues = !get().showClientRawValues + state.showClientRawValues = !state.showClientRawValues }), setHideHomeButton: (hide) => @@ -32,8 +32,7 @@ export const useLayoutZustand = create set((state) => { - const currentState = get() - state.showLog = !currentState.showLog + state.showLog = !state.showLog }), appType: isServerWindow ? 'server' : undefined, setAppType: (appType: AppType | undefined) => diff --git a/src/renderer/src/context/server.zustand.helpers.ts b/src/renderer/src/context/server.zustand.helpers.ts index 6e7f62c3..6be51e31 100644 --- a/src/renderer/src/context/server.zustand.helpers.ts +++ b/src/renderer/src/context/server.zustand.helpers.ts @@ -19,32 +19,46 @@ import { export const extractUnitIdsWithData = (serverRegisters: DefinedServerRegisters): UnitIdString[] => { const unitIds = Object.keys(serverRegisters) as UnitIdString[] const unitIdsWithData = unitIds.filter((unitId) => { - const reg = serverRegisters[unitId] - return checkHasConfig(reg) + const registers = serverRegisters[unitId] + return checkHasConfig(registers) }) return unitIdsWithData } -const syncBoolsWithBackend = async ( +/** + * The two bool arrays main takes, built from what a unit holds. + * + * Main keeps a coil and a discrete input for every one of the 65536 addresses, + * so a sync sends both arrays whole and the store's sparse map decides which + * entries are true. Both callers build the pair the same way; `resetBools` + * then blanks the one it is clearing. + */ +export const boolArraysOf = ( serverRegisters: DefinedServerRegisters, - unitId: UnitIdString, - syncUuid: string -): Promise => { + unitId: UnitIdString +): { coils: boolean[]; discrete_inputs: boolean[] } => { const coils: boolean[] = Array(65536).fill(false) const discreteInputs: boolean[] = Array(65536).fill(false) Object.entries(serverRegisters[unitId]?.['coils'] ?? {}).forEach( - ([addr, entry]) => (coils[Number(addr)] = entry.value) + ([address, entry]) => (coils[Number(address)] = entry.value) ) Object.entries(serverRegisters[unitId]?.['discrete_inputs'] ?? {}).forEach( - ([addr, entry]) => (discreteInputs[Number(addr)] = entry.value) + ([address, entry]) => (discreteInputs[Number(address)] = entry.value) ) + return { coils, discrete_inputs: discreteInputs } +} + +const syncBoolsWithBackend = async ( + serverRegisters: DefinedServerRegisters, + unitId: UnitIdString, + syncUuid: string +): Promise => { await window.api.syncBools({ uuid: syncUuid, unitId, - coils, - discrete_inputs: discreteInputs + ...boolArraysOf(serverRegisters, unitId) }) } @@ -58,10 +72,10 @@ export const syncRegistersWithBackend = async ( }> => { const inputRegisterRegisterValues = Object.values( serverRegisters[unitId]?.['input_registers'] ?? [] - ).map((r) => r.params) + ).map((register) => register.params) const holdingRegisterRegisterValues = Object.values( serverRegisters[unitId]?.['holding_registers'] ?? [] - ).map((r) => r.params) + ).map((register) => register.params) await window.api.syncServerRegister({ uuid: uuid, diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 6ec1b2f1..e5663cc6 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -30,6 +30,7 @@ import { import { onEvent } from '@renderer/events' import { round } from 'lodash' import { + boolArraysOf, extractUnitIdsWithData, serverRegistersOf, syncRegistersWithBackend, @@ -126,7 +127,7 @@ export const useServerZustand = create< deleteServer: async (uuid) => { await window.api.deleteServer(uuid) set((state) => { - state.uuids = state.uuids.filter((u) => u !== uuid) + state.uuids = state.uuids.filter((existingUuid) => existingUuid !== uuid) // The delete button is off for the main server, so the list keeps at // least that one and the selection lands on a server that is there. const [firstRemaining = MAIN_SERVER_UUID] = state.uuids @@ -157,7 +158,7 @@ export const useServerZustand = create< init: async (uuid) => { set((state) => { if (uuid) state.ready[uuid] = false - else for (const u of state.uuids) state.ready[u] = false + else for (const readyUuid of state.uuids) state.ready[readyUuid] = false }) const state = get() const mode = state.serverMode ?? 'tcp' @@ -287,8 +288,8 @@ export const useServerZustand = create< setBool: (params) => { const written: SetBooleanParameters[] = [] set((state) => { - for (const p of Array.isArray(params) ? params : [params]) { - const { registerType, address, boolState, optionalUuid, optionalUnitId } = p + for (const parameters of Array.isArray(params) ? params : [params]) { + const { registerType, address, boolState, optionalUuid, optionalUnitId } = parameters const uuid = optionalUuid ?? get().selectedUuid const unitId = optionalUnitId ?? get().getUnitId(uuid) const registers = unitRegisters(state, uuid, unitId) @@ -315,27 +316,16 @@ export const useServerZustand = create< resetBools: (registerType) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) - const currentState = get() - const currentCoils = new Array(65536).fill(false) - const currentDiscreteInputs = new Array(65536).fill(false) - Object.entries(currentState.serverRegisters[uuid]?.[unitId]?.coils ?? {}).forEach( - ([k, v]) => { - currentCoils[Number(k)] = v.value - } - ) - Object.entries( - currentState.serverRegisters[uuid]?.[unitId]?.['discrete_inputs'] ?? {} - ).forEach(([k, v]) => { - currentDiscreteInputs[Number(k)] = v.value - }) + // Read before the store is emptied, because the other type keeps the + // values it had and main takes both arrays on every sync. + const bools = boolArraysOf(get().serverRegisters[uuid] ?? {}, unitId) set((state) => { unitRegisters(state, uuid, unitId)[registerType] = {} }) const newBools: SyncBoolsParameters = { uuid, unitId, - coils: currentCoils, - discrete_inputs: currentDiscreteInputs, + ...bools, [registerType]: new Array(65536).fill(false) } window.api.syncBools(newBools) @@ -362,7 +352,7 @@ export const useServerZustand = create< const registers = unitRegisters(state, uuid, unitId) registers[params.registerType][params.address] = { value: 0, params } unitUsedAddresses(state, uuid, unitId)[params.registerType] = getUsedAddresses( - Object.values(registers[params.registerType]).map((r) => r.params) + Object.values(registers[params.registerType]).map((register) => register.params) ) }) @@ -387,7 +377,7 @@ export const useServerZustand = create< if (registers === undefined) return delete registers[registerType][address] unitUsedAddresses(state, uuid, unitId)[registerType] = getUsedAddresses( - Object.values(registers[registerType]).map((r) => r.params) + Object.values(registers[registerType]).map((register) => register.params) ) }) window.api.removeServerRegister(removeParams) @@ -395,8 +385,8 @@ export const useServerZustand = create< setRegisterValue: (params) => { if (!Array.isArray(params)) params = [params] set((state) => { - for (const p of params) { - const { registerType, address, value, optionalUuid, optionalUnitId } = p + for (const parameters of params) { + const { registerType, address, value, optionalUuid, optionalUnitId } = parameters const uuid = optionalUuid ?? get().selectedUuid const unitId = optionalUnitId ?? get().getUnitId(uuid) @@ -640,12 +630,13 @@ export const applyRegisterValue = (payload: RegisterValue): void => { let serverRegisterEntry: ServerRegisterEntry | undefined let entryAddress: number | undefined - for (let cand = address; cand >= address - 3; cand--) { - const maybe = serverZustand.serverRegisters[uuid]?.[unitId]?.[registerType]?.[cand] - if (!maybe) continue + for (let candidateAddress = address; candidateAddress >= address - 3; candidateAddress--) { + const candidateEntry = + serverZustand.serverRegisters[uuid]?.[unitId]?.[registerType]?.[candidateAddress] + if (!candidateEntry) continue // Found an entry at candidate index—this is our base - serverRegisterEntry = maybe - entryAddress = cand + serverRegisterEntry = candidateEntry + entryAddress = candidateAddress break } if (!serverRegisterEntry || entryAddress === undefined) { From eaab8ab534c780ade836880326107cb8f80b84d8 Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 11:29:53 +0200 Subject: [PATCH 017/137] docs: the changelog line for a defect, under Fixed Round D's own line sat under Changed while its body described a wrong value. The same Unreleased block has a Fixed section, and a user looking there for a reading that came out low would not have found it. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcbcb73..41400ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -300,6 +300,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release link opened the release page in a second Modbux window rather than in the browser. The notice is the main window's alone now, and a link from either window opens in the browser. +- **A Linear Interpolation endpoint no longer loses its decimals.** Its four + fields stopped at two, so an endpoint of 0.0625 went in as 0.06 and every row + scaled through it read 4% low. They take seven now, which is what the value, + min and max fields on the server side have always taken. ### Changed @@ -309,10 +313,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unit the server hosts, and nothing goes back on the line. Modbux says so once while the RTU server is running and unit 0 holds registers. Over TCP there is no broadcast and unit 0 stays an ordinary address. -- **Linear Interpolation takes seven decimals now.** Its four fields stopped at - two, so an endpoint of 0.0625 went in as 0.06 and every row scaled through it - came out 4% off. They take what the value, min and max fields on the server - side have always taken. - **The parity list offers none, even and odd.** Mark and space were in it, and on macOS and Linux picking either one failed the connection outright, because the serial layer Modbux uses has no setting for them on those platforms. A From ece3b827d74625c5de61e26027fc54bbc1675d1a Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 13:16:09 +0200 Subject: [PATCH 018/137] refactor: one shape, written twice in shared R17, R25, T23 and T28. The v1 server config wrote the bool shape migration out a third time, and the copy differed from `migrateBoolShapeForUnit`: it built a new record per bool type, so a `coils` or `discrete_inputs` holding a number or a true became an empty record and loaded. The triage read both outcomes as refused by the final `safeParse`, which holds for a string and not for those two. The helper runs over the units before the loop reads them now, and `V1ServerRegisters` says so. A changelog line, because Load reaches it. `migrateServerModeState` wrote the four serial defaults as a literal beside `defaultSerialPortOptions`, which `getDefaultSerialConfig` spreads. Three copies, two agreeing by construction and this one by coincidence. `isNumberRegister` and `isBooleanRegister` are built from `.options` and take the seven sites that wrote one of the two pairs out by hand, five in the client toolbar and grid, one in `migrateBoolShapeForUnit` and one in the register scan. T23 is a measurement and a comment. The finding's 41.37 ms at 2000 registers describes a buffer no read can carry: FC03 and FC04 answer at most 125 registers, so the widest `utf8` tail is 250 bytes. Capping each word at `MAX_UTF8_READ_REGISTERS` took a 125 register conversion from 2.88 ms to 2.58 ms, median of 40, and cut what the grid draws for a string read with read configuration off from 250 characters to 48. Jens: the protocol is the ceiling, so there is nothing to cap. T28's cycle between `client.zustand` and `data.zustand` stands. A module-scope `useClientZustand.getState()` beside `data.zustand`'s `create` threw "Cannot read properties of undefined (reading 'getState')" before `client.zustand.test.ts` ran a case, and the comment says so where an edit would add one. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 5 +++ src/main/modules/modbusClient.ts | 5 +-- .../MenuButton/MenuRegisterOptions.tsx | 3 +- .../MenuButton/ScanRegistersButton.tsx | 3 +- .../RegisterGridToolbar/RawButton.tsx | 3 +- .../ToggleEndianButton.tsx | 3 +- .../RegisterGrid/columns/index.tsx | 4 +- src/renderer/src/context/data.zustand.ts | 8 ++++ src/shared/__tests__/migrations.test.ts | 39 ++++++++++++++++++- src/shared/__tests__/schemas.test.ts | 22 ++++++++++- src/shared/conversion.ts | 12 +++++- src/shared/migrations/server/config.ts | 34 +++++++--------- src/shared/migrations/server/shared.ts | 11 ++++-- src/shared/migrations/server/zustand.ts | 9 +++-- src/shared/migrations/shared.ts | 10 ++++- src/shared/types/register.ts | 17 ++++++++ 16 files changed, 146 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41400ca3..99ce7d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 app. The warning said some features may not work correctly. The file is now read against what this version understands, what matches is kept, and the message says which parts did not come across. +- **An old configuration whose bits are not bits is now refused instead of half + opened.** A v1 server config whose coils or discrete inputs key held a number + or a true instead of the addresses under it loaded with those bits gone, and + reported that it had been updated from an older format. It is refused by name + now, the way every other unreadable file already is. - **A file that is not a configuration is now refused instead of opened.** Picking a JSON file holding a number, a string or a list emptied the server and reported that the configuration had been updated from an older format. A diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 4af37dda..9e5013a3 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -10,6 +10,7 @@ import { createRegisters, groupAddressInfos, humanizeSerialError, + isBooleanRegister, PROTOCOL_LABELS, RawTransaction, RegisterData, @@ -1135,9 +1136,7 @@ export class ModbusClient { this._logTransaction(transactionIdKey, errorMessage) if (!data) return - data = data.filter((row) => - ['coils', 'discrete_inputs'].includes(type) ? row.bit : row.hex !== '0000' - ) + data = data.filter((row) => (isBooleanRegister(type) ? row.bit : row.hex !== '0000')) this._sendData(data) } diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions.tsx index 4dfbf9cc..deecf1a7 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions.tsx @@ -4,6 +4,7 @@ import FormControlLabel from '@mui/material/FormControlLabel' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { ChangeEvent, useCallback } from 'react' +import { isNumberRegister } from '@shared' const MenuRegisterOptions = meme((): JSX.Element | null => { const type = useClientZustand((z) => z.registerConfig.type) @@ -21,7 +22,7 @@ const MenuRegisterOptions = meme((): JSX.Element | null => { clientZustand.setShow64BitValues(event.target.checked) }, []) - const registers16Bit = ['input_registers', 'holding_registers'].includes(type) + const registers16Bit = isNumberRegister(type) if (!registers16Bit) return null return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton.tsx index 988a64bd..06397e59 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton.tsx @@ -3,12 +3,13 @@ import { useScanRegistersZustand } from '@renderer/components/client/ScanRegiste import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' +import { isNumberRegister } from '@shared' import type { SetAnchorProps } from './MenuButton' const ScanRegistersButton = meme(({ setAnchor }: SetAnchorProps) => { const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') const type = useClientZustand((z) => z.registerConfig.type) - const registers16Bit = ['input_registers', 'holding_registers'].includes(type) + const registers16Bit = isNumberRegister(type) const handleOpen = useCallback(() => { useScanRegistersZustand.getState().setOpen(true) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx index 2c8da998..9e9dbc49 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton.tsx @@ -4,6 +4,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' +import { isNumberRegister } from '@shared' const RawButton = meme((): JSX.Element | null => { const type = useClientZustand((z) => z.registerConfig.type) @@ -14,7 +15,7 @@ const RawButton = meme((): JSX.Element | null => { layoutZustand.toggleShowClientRawValues() }, []) - if (!['input_registers', 'holding_registers'].includes(type)) return null + if (!isNumberRegister(type)) return null const variant: ButtonProps['variant'] = showRawValues ? 'contained' : 'outlined' const color: ButtonProps['color'] = showRawValues ? 'warning' : 'primary' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx index 38969a03..917349ae 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton.tsx @@ -5,6 +5,7 @@ import EndianTable from '@renderer/components/shared/inputs/EndianTable' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' +import { isNumberRegister } from '@shared' const ToggleEndianButton = meme((): JSX.Element | null => { const type = useClientZustand((z) => z.registerConfig.type) @@ -16,7 +17,7 @@ const ToggleEndianButton = meme((): JSX.Element | null => { clientZustand.setLittleEndian(value) }, []) - const registers16Bit = ['input_registers', 'holding_registers'].includes(type) + const registers16Bit = isNumberRegister(type) if (!registers16Bit) return null return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx index d8462b6f..90b26aab 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/index.tsx @@ -1,6 +1,6 @@ import { GridColDef } from '@mui/x-data-grid/models' import { useClientZustand } from '@renderer/context/client.zustand' -import { RegisterData } from '@shared' +import { RegisterData, isNumberRegister } from '@shared' import { useMemo } from 'react' import { addressColumn } from './address' import { bitColumn } from './bit' @@ -32,7 +32,7 @@ const useRegisterGridColumns = (): GridColDef[] => { const showRaw = useLayoutZustand((z) => z.showClientRawValues) return useMemo(() => { - const registers16Bit = ['input_registers', 'holding_registers'].includes(type) + const registers16Bit = isNumberRegister(type) const columns: GridColDef[] = [addressColumn(addressBase)] diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 46be0c71..524293be 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -3,6 +3,14 @@ import { create } from 'zustand' import { DataZustand } from './data.zustand.types' import { mutative } from 'zustand-mutative' import { DateTime } from 'luxon' +// This import closes a cycle: `client.zustand.ts` imports `useDataZustand` and +// reads it in `clearRegisterDataWhenIdle` and `setLittleEndian`. Both sides +// hold because every use on both sides sits inside a function body, which runs +// after both modules have evaluated. A module-scope read is the way to break +// it: `useClientZustand.getState()` beside the `create` call below threw +// "Cannot read properties of undefined (reading 'getState')" before +// `client.zustand.test.ts` ran a single case, and at startup there is no React +// render behind that to catch it. import { useClientZustand } from './client.zustand' import { onEvent } from '@renderer/events' import { RegisterData, dummyWords } from '@shared' diff --git a/src/shared/__tests__/migrations.test.ts b/src/shared/__tests__/migrations.test.ts index 9e5459e2..c4b89cf1 100644 --- a/src/shared/__tests__/migrations.test.ts +++ b/src/shared/__tests__/migrations.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect } from 'vitest' import { CURRENT_SERVER_CONFIG_VERSION, migrateServerConfig } from '../migrations/server/config' import { migrateClientConfig } from '../migrations/client/config' import { resetMessage } from '../repairPersisted' -import { migrateServerRegistersState, migrateBoolShape } from '../migrations/server/zustand' +import { + migrateServerRegistersState, + migrateBoolShape, + migrateServerModeState +} from '../migrations/server/zustand' +import { defaultSerialPortOptions } from '../default' import { readFileSync } from 'fs' import { join } from 'path' @@ -836,5 +841,37 @@ describe('configMigration', () => { expect(unit.coils['0']).toEqual({ value: true }) expect(unit.coils['1']).toEqual({ value: false }) }) + + // The step is `migrateBoolShapeForUnit`, which leaves a bool record that is + // not an object where it found it. The copy this file used to hold built a + // new record per bool type, so `coils: 5` became `{}` and loaded. + it.each([5, true, 'ab'])('refuses a v1 coils holding %o', (coils) => { + const v1Config = JSON.stringify({ + name: 'Not A Record', + serverRegistersPerUnit: { '1': { coils, discrete_inputs: {} } } + }) + + expect(() => migrateServerConfig(v1Config)).toThrow(/serverRegistersPerUnit\.1\.coils/) + }) + + it.each([5, true, 'ab'])('refuses a v1 discrete_inputs holding %o', (discreteInputs) => { + const v1Config = JSON.stringify({ + name: 'Not A Record', + serverRegistersPerUnit: { '1': { coils: {}, discrete_inputs: discreteInputs } } + }) + + expect(() => migrateServerConfig(v1Config)).toThrow( + /serverRegistersPerUnit\.1\.discrete_inputs/ + ) + }) + }) + + describe('Server Zustand Migration - Serial Defaults', () => { + it('writes the serial options the rest of the app defaults to', () => { + const migrated = migrateServerModeState({}) + + expect(migrated.serverMode).toBe('tcp') + expect(migrated.serialConfig).toEqual({ com: '', options: defaultSerialPortOptions }) + }) }) }) diff --git a/src/shared/__tests__/schemas.test.ts b/src/shared/__tests__/schemas.test.ts index a3250ee9..b4202396 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -11,7 +11,12 @@ import { DataBitsSchema, SerialPortOptionsSchema, StopBitsSchema } from '../type import { MAX_UTF8_LENGTH } from '../utils' import { ScanUnitIDParametersSchema } from '../types/scan' import { MAX_READ_BITS, MAX_READ_REGISTERS, maxReadQuantity } from '../types/ranges' -import type { RegisterType } from '../types/register' +import { + isBooleanRegister, + isNumberRegister, + RegisterTypeSchema, + type RegisterType +} from '../types/register' describe('RegisterMapObjectSchema', () => { it('accepts numeric string keys', () => { @@ -592,3 +597,18 @@ describe('SerialPortOptionsSchema', () => { expect(StopBitsSchema.options.map((option) => option.value)).toEqual([1, 2]) }) }) + +describe('isNumberRegister and isBooleanRegister', () => { + // Seven sites wrote one of the two pairs out by hand, and a list written out + // does not grow when the enum does. Both predicates read `.options`, so this + // asks the question that answer settles: every register type is one or the + // other, and neither predicate takes something that is not a register type. + it.each(RegisterTypeSchema.options)('sorts %s into exactly one of the two', (registerType) => { + expect(isNumberRegister(registerType)).toBe(!isBooleanRegister(registerType)) + }) + + it.each(['', 'holding', 'Coils', 'input_register'])('refuses %o', (notARegisterType) => { + expect(isNumberRegister(notARegisterType)).toBe(false) + expect(isBooleanRegister(notARegisterType)).toBe(false) + }) +}) diff --git a/src/shared/conversion.ts b/src/shared/conversion.ts index cb28076c..2f675342 100644 --- a/src/shared/conversion.ts +++ b/src/shared/conversion.ts @@ -151,8 +151,16 @@ export const convertRegisterData = ( uint64: buf64 ? buf64.readBigUInt64BE(0) : BigInt(0), double: buf64 ? round(buf64.readDoubleBE(0), 10) : 0, datetime: buf64 ? parseIEC870DateTime(buf64) : '', - // Replace null values with spaces — start from current register offset - // so character indexing aligns with register positions + // Replace null values with spaces, starting from this register's + // offset so character indexing aligns with register positions. + // + // The rest of the read, with no ceiling of its own, because the + // protocol is the ceiling: FC03 and FC04 answer at most 125 registers, + // so the widest tail here is 250 bytes. Holding it to + // `MAX_UTF8_READ_REGISTERS` instead took a 125 register conversion + // from 2.88 ms to 2.58 ms, median of 40, and cut what the grid draws + // for a string read with read configuration off from 250 characters to + // 48. utf8: Buffer.from(buffer.subarray(offset).map((b) => (b === 0 ? 32 : b))).toString('utf-8') }, bit: false, diff --git a/src/shared/migrations/server/config.ts b/src/shared/migrations/server/config.ts index bfeb4aea..36fa7719 100644 --- a/src/shared/migrations/server/config.ts +++ b/src/shared/migrations/server/config.ts @@ -1,7 +1,6 @@ import { ServerConfigSchema, ServerConfig, - ServerBoolEntry, RegisterParams, ServerRegistersPerUnit, ServerRegisters @@ -45,6 +44,12 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian renameLegacyRegisterTypeKeys(v1Config) const config = v1Config as V1ServerConfig const v1Registers = config.serverRegistersPerUnit ?? {} + // `V1ServerRegisters` declares the bool records as the entries this leaves, + // because the loop below reads them after it. The step was written out a + // third time here, and the copy differed: it built a new record per bool + // type, so a `coils` holding a number became an empty one and passed, where + // the helper leaves the number for `ServerConfigSchema` to refuse. + migrateBoolShapeInConfig(v1Registers) const { endianness, wasMixed } = extractGlobalEndianness(v1Registers) const migratedRegisters: ServerRegistersPerUnit = {} @@ -52,20 +57,9 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian for (const [unitId, serverRegisters] of Object.entries(v1Registers)) { if (!serverRegisters) continue - // Convert old boolean shape to { value: boolean } entries - const migratedCoils: Record = {} - for (const [address, value] of Object.entries(serverRegisters.coils ?? {})) { - migratedCoils[address] = typeof value === 'boolean' ? { value } : (value as ServerBoolEntry) - } - const migratedDiscreteInputs: Record = {} - for (const [address, value] of Object.entries(serverRegisters.discrete_inputs ?? {})) { - migratedDiscreteInputs[address] = - typeof value === 'boolean' ? { value } : (value as ServerBoolEntry) - } - const migratedServerRegisters: ServerRegisters = { - coils: migratedCoils, - discrete_inputs: migratedDiscreteInputs, + coils: serverRegisters.coils ?? {}, + discrete_inputs: serverRegisters.discrete_inputs ?? {}, input_registers: {}, holding_registers: {} } @@ -128,17 +122,17 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian */ function migrateServerV2toV3(v2Config: unknown): ServerConfig { const config = v2Config as ServerConfig & Record - migrateBoolShapeInConfig(config) + migrateBoolShapeInConfig(config.serverRegistersPerUnit) stringifyExact64BitValues({ serverRegisters: { config: config.serverRegistersPerUnit } }) return { ...config, version: 3 } } /** - * Convert old `boolean` bool entries to `{ value: boolean }` in a parsed config - * object. Mutates in place. Safe to call on already-migrated data. + * Convert old `boolean` bool entries to `{ value: boolean }` across every unit + * of a parsed config. Mutates in place. Safe to call on already-migrated data. */ -function migrateBoolShapeInConfig(config: Record): void { - for (const unitRegisters of objectValues(config.serverRegistersPerUnit)) { +function migrateBoolShapeInConfig(serverRegistersPerUnit: unknown): void { + for (const unitRegisters of objectValues(serverRegistersPerUnit)) { migrateBoolShapeForUnit(unitRegisters) } } @@ -151,7 +145,7 @@ export function migrateServerConfig(raw: string): MigrationResult // Current version - migrate bool shape if needed, then validate if (detectedVersion === CURRENT_SERVER_CONFIG_VERSION) { - migrateBoolShapeInConfig(parsed) + migrateBoolShapeInConfig(parsed.serverRegistersPerUnit) // A file this version writes carries a decimal string for a 64 bit value, // and a hand-edited one can carry the number: `ServerConfigSchema` takes // either. The persisted store's own step is gated on the store version and diff --git a/src/shared/migrations/server/shared.ts b/src/shared/migrations/server/shared.ts index 6af5cccb..1fcaa0de 100644 --- a/src/shared/migrations/server/shared.ts +++ b/src/shared/migrations/server/shared.ts @@ -1,4 +1,4 @@ -import { RegisterParams } from '../../types/server' +import { RegisterParams, ServerBoolEntry } from '../../types/server' // // V1 types — describe the old config format for type-safe migration @@ -15,9 +15,14 @@ export interface V1ServerRegisterEntry { export type V1ServerRegister = Record +/** + * The two bool records hold the entry, not the bare `boolean` a v1 file does, + * because `migrateServerV1toV2` runs `migrateBoolShapeInConfig` over the units + * before it reads them. + */ export interface V1ServerRegisters { - coils?: Record - discrete_inputs?: Record + coils?: Record + discrete_inputs?: Record input_registers?: V1ServerRegister holding_registers?: V1ServerRegister } diff --git a/src/shared/migrations/server/zustand.ts b/src/shared/migrations/server/zustand.ts index 59068d4c..1bb117a1 100644 --- a/src/shared/migrations/server/zustand.ts +++ b/src/shared/migrations/server/zustand.ts @@ -1,3 +1,4 @@ +import { defaultSerialPortOptions } from '../../default' import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } from './shared' import { dropUnservableRegisters, @@ -48,10 +49,10 @@ export function migrateServerModeState(state: Record): Record): void { export function migrateBoolShapeForUnit(unitRegisters: unknown): void { if (!isRecord(unitRegisters)) return - for (const boolType of ['coils', 'discrete_inputs'] as const) { + for (const boolType of BooleanRegistersSchema.options) { const boolRecord = unitRegisters[boolType] if (!isRecord(boolRecord)) continue diff --git a/src/shared/types/register.ts b/src/shared/types/register.ts index 9e6c0571..750fa44d 100644 --- a/src/shared/types/register.ts +++ b/src/shared/types/register.ts @@ -13,3 +13,20 @@ export const RegisterTypeSchema = z.enum([ ...BooleanRegistersSchema.options ]) export type RegisterType = z.infer + +const NUMBER_REGISTERS = new Set(NumberRegistersSchema.options) +const BOOLEAN_REGISTERS = new Set(BooleanRegistersSchema.options) + +/** + * Whether `type` is one of the two register types holding 16 bit words. + * + * The pair was written out at five sites and the bool pair at two, and a + * hand-written membership test does not grow when the enum does. Both read + * `.options`, which is why the enums are here. + */ +export const isNumberRegister = (type: string): type is NumberRegisters => + NUMBER_REGISTERS.has(type) + +/** Whether `type` is one of the two register types holding single bits. */ +export const isBooleanRegister = (type: string): type is BooleanRegisters => + BOOLEAN_REGISTERS.has(type) From 14ce09545716dabc426449f181ff1c7fff1503b5 Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 13:30:20 +0200 Subject: [PATCH 019/137] fix: a v1 unit that is not a unit, and the copy the test did not ask about The review of `ddcde7b` found three. The half-open door the commit closed for the bool records was one level up as well. `if (!serverRegisters) continue` let a truthy non-object through, and reading `coils` off a number answers `undefined`, so the unit loaded empty: `{"serverRegistersPerUnit":{"1":5}}` opened with unit 1 gone, and `{"serverRegistersPerUnit":"ab"}` opened two empty units keyed 0 and 1, both reporting the file had been updated from an older format. Each unit is read as `unknown` and named after the check now, because narrowing the V1 type with `isRecord` takes the bool records' own type with it. A list still loads as an empty unit: `isRecord` takes one, and widening that guard reaches seven other callers. The changelog line grows to say all of it. `maxReadQuantity` was an eighth site asking the same question by hand, at the cost of a Zod parse per element. The three remaining `Array<'input_registers' | 'holding_registers'>` literals stay: they describe the v1 file shape, which should not follow the enum. The serial defaults test asserted the values and not the copy, so it passed whether `migrateServerModeState` spreads `defaultSerialPortOptions` or hands the object itself over. `default.ts:17` states the copy, so the identity is its own half. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 12 +++++++----- src/shared/__tests__/migrations.test.ts | 25 +++++++++++++++++++++++++ src/shared/migrations/server/config.ts | 19 ++++++++++++++++--- src/shared/migrations/shared.ts | 2 +- src/shared/types/ranges.ts | 6 ++---- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ce7d6a..bee34522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,11 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 app. The warning said some features may not work correctly. The file is now read against what this version understands, what matches is kept, and the message says which parts did not come across. -- **An old configuration whose bits are not bits is now refused instead of half - opened.** A v1 server config whose coils or discrete inputs key held a number - or a true instead of the addresses under it loaded with those bits gone, and - reported that it had been updated from an older format. It is refused by name - now, the way every other unreadable file already is. +- **An old configuration that is not shaped like one is now refused instead of + half opened.** A v1 server config whose unit held a number or a piece of text, + or whose coils or discrete inputs key held one instead of the addresses under + it, opened with those parts silently empty and reported that it had been + updated from an older format. A file whose whole register section was a piece + of text opened as two empty units. Each of those is refused by name now, the + way every other unreadable file already is. - **A file that is not a configuration is now refused instead of opened.** Picking a JSON file holding a number, a string or a list emptied the server and reported that the configuration had been updated from an older format. A diff --git a/src/shared/__tests__/migrations.test.ts b/src/shared/__tests__/migrations.test.ts index c4b89cf1..bbca21dc 100644 --- a/src/shared/__tests__/migrations.test.ts +++ b/src/shared/__tests__/migrations.test.ts @@ -8,6 +8,7 @@ import { migrateServerModeState } from '../migrations/server/zustand' import { defaultSerialPortOptions } from '../default' +import type { SerialPortOptions } from '../types/serial' import { readFileSync } from 'fs' import { join } from 'path' @@ -854,6 +855,24 @@ describe('configMigration', () => { expect(() => migrateServerConfig(v1Config)).toThrow(/serverRegistersPerUnit\.1\.coils/) }) + // A list is left out: `isRecord` takes one, so `[]` loads as an empty unit + // the way it did before, and widening that guard reaches seven other + // callers. + it.each([5, true, 'ab'])('refuses a v1 unit holding %o', (unit) => { + const v1Config = JSON.stringify({ + name: 'Not A Unit', + serverRegistersPerUnit: { '1': unit } + }) + + expect(() => migrateServerConfig(v1Config)).toThrow(/serverRegistersPerUnit\.1/) + }) + + it('refuses a v1 serverRegistersPerUnit that is a string', () => { + const v1Config = JSON.stringify({ name: 'Not Units', serverRegistersPerUnit: 'ab' }) + + expect(() => migrateServerConfig(v1Config)).toThrow(/serverRegistersPerUnit/) + }) + it.each([5, true, 'ab'])('refuses a v1 discrete_inputs holding %o', (discreteInputs) => { const v1Config = JSON.stringify({ name: 'Not A Record', @@ -872,6 +891,12 @@ describe('configMigration', () => { expect(migrated.serverMode).toBe('tcp') expect(migrated.serialConfig).toEqual({ com: '', options: defaultSerialPortOptions }) + + // `default.ts` states the copy: a store writing one option has to leave + // the other's alone, which handing the object itself over would break. + // `toEqual` above passes either way, so the identity is its own half. + const serialConfig = migrated.serialConfig as { options: SerialPortOptions } + expect(serialConfig.options).not.toBe(defaultSerialPortOptions) }) }) }) diff --git a/src/shared/migrations/server/config.ts b/src/shared/migrations/server/config.ts index 36fa7719..dfce8dea 100644 --- a/src/shared/migrations/server/config.ts +++ b/src/shared/migrations/server/config.ts @@ -11,11 +11,12 @@ import { migrateBoolShapeForUnit, objectValues, dropUnservableConfigRegisters, + isRecord, parseConfigFile, renameLegacyRegisterTypeKeys, stringifyExact64BitValues } from '../shared' -import { V1ServerConfig, extractGlobalEndianness } from './shared' +import { V1ServerConfig, V1ServerRegisters, extractGlobalEndianness } from './shared' import { repairPersisted } from '../../repairPersisted' /** @@ -54,8 +55,20 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian const migratedRegisters: ServerRegistersPerUnit = {} - for (const [unitId, serverRegisters] of Object.entries(v1Registers)) { - if (!serverRegisters) continue + // Each unit is read as `unknown` and named after the check, because the V1 + // types describe a file that parsed rather than one that was validated. + for (const [unitId, unit] of Object.entries(v1Registers)) { + if (unit === undefined) continue + + // A unit that is not an object read `coils` off itself, got `undefined` and + // loaded as an empty unit, which is the half-open door the bool records had + // one level down. `serverRegistersPerUnit` holding the string 'ab' opened + // two of them, keyed 0 and 1. It reaches the final `safeParse` whole now. + if (!isRecord(unit)) { + migratedRegisters[unitId] = unit as ServerRegisters + continue + } + const serverRegisters = unit as V1ServerRegisters const migratedServerRegisters: ServerRegisters = { coils: serverRegisters.coils ?? {}, diff --git a/src/shared/migrations/shared.ts b/src/shared/migrations/shared.ts index 98439003..65a3695a 100644 --- a/src/shared/migrations/shared.ts +++ b/src/shared/migrations/shared.ts @@ -35,7 +35,7 @@ export function repairPersistedParity(state: Record, ...path: s serialOptions.parity = 'none' } -const isRecord = (value: unknown): value is Record => +export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null /** diff --git a/src/shared/types/ranges.ts b/src/shared/types/ranges.ts index 9941649e..f60619ba 100644 --- a/src/shared/types/ranges.ts +++ b/src/shared/types/ranges.ts @@ -1,5 +1,5 @@ import z from 'zod' -import { NumberRegistersSchema, RegisterType } from './register' +import { isNumberRegister, RegisterType } from './register' /** * Ranges the protocol and the socket fix, so a schema states them once. @@ -48,9 +48,7 @@ export const MAX_READ_REGISTERS = 125 * about the bus. */ export const maxReadQuantity = (registerTypes: readonly RegisterType[]): number => - registerTypes.some((registerType) => NumberRegistersSchema.safeParse(registerType).success) - ? MAX_READ_REGISTERS - : MAX_READ_BITS + registerTypes.some(isNumberRegister) ? MAX_READ_REGISTERS : MAX_READ_BITS /** * The same range for a register map keyed by address. From 955ab51e5cf8fd3c40146de07c0c5520d805977d Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 22:39:57 +0200 Subject: [PATCH 020/137] fix: the key, the count and the message Round E of the annoying half: T19, T20, T21, T22, T24 and T32. Not split: every one of the six changes what the app does, so there is no refactor here for T20 to be a fix inside of. T20 is `Math.floor` on the register count in `convertRegisterData`. modbus-serial slices the response's buffer at the byte count the frame declares and never compares that field with the frame length it checked, so a device declaring an odd one leaves a trailing byte. The count was then 1.5, `inRange32` was 0 < 0.5, and the read stopped on "Attempt to access memory outside buffer bounds", taking the registers that did arrive with it. T21 narrows `BitMapConfigSchema`'s key to `/^(?:[0-9]|1[0-5])$/`. It was `!isNaN(Number(v))` refined to the range, the shape `RegisterAddressKeySchema` was rewritten away from, and seven spellings got in that `ServerBitMapDetail` and `BitMapDetailPanel` never ask for. Both write and read the key as `String(bitIndex)`, so nothing shipped writes one and no migration is owed. T22 is `${key}.corrupt` rather than `${key}.corrupt-${now()}`, and `keepCorrupt` loses its `now`. Every launch that repaired anything left another whole blob in the store's own localStorage budget, and grep over `src` and `e2e` finds no reader and no deleter. T24 prefixes the port on `humanizeSerialError`'s third return too. It was on the two branches Modbux writes the text for and off the one that hands the driver's own text through. T19 sorts the register rows by `params.address`, and drops a `replace` of a space `NumberRegistersSchema` has no member holding. `Object.values` is ascending over integer-index keys only and `RegisterAddressKeySchema` accepts '007', which is not one, so a config carrying one drew that row where it was written while `ServerBoolList` next door sorts. T32 runs `repairPersistedStore` after the `window_update` rehydrate as well as at load. That path ran `migrateServerState` alone, so a key hand-edited while the split out window was up was installed unvalidated. `migrate` rewrites `persistedVersion` on the way through, which is what the second call reads. Jens took it over the comment the finding offered instead. A changelog line for T20, T22 and T24. The other three need a hand-edited file to reach. Every item was seen red first, over seven test files: an odd buffer, seven bitmap keys, a second corrupt copy, an unrecognised serial error, a row keyed '007', and a rehydrate of a blob whose `port` does not parse. lint 0, typecheck 0, 71 files and 1444 passed. E2E 06, 10, 21, 26 and 30: 99 passed. E2E 02-standalone: 25 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 14 +++++++ .../ServerRegisters/ServerRegisters.tsx | 10 ++++- .../__tests__/ServerRegisters.test.tsx | 40 +++++++++++++++++++ .../context/__tests__/corruptedConfig.test.ts | 3 +- .../__tests__/serverStoreRehydrate.test.ts | 35 ++++++++++++++++ src/renderer/src/context/server.zustand.ts | 30 ++++++++++---- src/shared/__tests__/conversion.test.ts | 13 ++++++ src/shared/__tests__/repairPersisted.test.ts | 22 ++++++++-- src/shared/__tests__/schemas.test.ts | 16 ++++++++ src/shared/__tests__/utils.test.ts | 9 ++++- src/shared/conversion.ts | 8 +++- src/shared/repairPersisted.ts | 14 ++++--- src/shared/types/bitmap.ts | 15 ++++--- src/shared/utils.ts | 3 +- 14 files changed, 201 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee34522..3ff19894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -311,6 +311,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fields stopped at two, so an endpoint of 0.0625 went in as 0.06 and every row scaled through it read 4% low. They take seven now, which is what the value, min and max fields on the server side have always taken. +- **A read no longer fails on a device whose answer carries half a register.** + The reply declares how many bytes it holds, and a device declaring an odd + number leaves a byte no register is made of. Modbux read past the end of it + and the read stopped with `Attempt to access memory outside buffer bounds` + rather than showing the registers that did come through. The trailing byte is + dropped now. +- **Modbux keeps one copy of a configuration it could not read, rather than one + per launch.** Every launch that had to repair the saved setup left a copy of + the unreadable one beside it, and nothing read a copy or removed one. The copy + is the whole saved configuration, register mapping included. +- **A serial error Modbux does not recognise now names the port it came from.** + The port was in front of the two messages Modbux writes itself and missing + from the one that hands the driver's own text through, which is the message a + bug report gets written about. ### Changed diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 79a1bb93..534d5413 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -141,11 +141,17 @@ const ServerRegisterRows = meme(({ type }: { type: NumberRegisters }) => { const unitId = z.getUnitId(uuid) return z.serverRegisters[uuid]?.[unitId]?.[type] }) - const registers = useMemo(() => Object.values(registerMap ?? {}), [registerMap]) + // Sorted by the address the row draws, the way `ServerBoolList` sorts its + // keys. `Object.values` is ascending only over integer-index keys, and + // `RegisterAddressKeySchema` accepts '007', which is not one. + const registers = useMemo( + () => Object.values(registerMap ?? {}).sort((a, b) => a.params.address - b.params.address), + [registerMap] + ) return registers.map((register) => ( )) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx index 2c11e371..73d83ae7 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx @@ -122,3 +122,43 @@ describe('what the server row shows for a timestamp', () => { expect(cell).toHaveTextContent('—') }) }) + +describe('what order the server rows come out in', () => { + /** A `uint16` register at `address`, which is what the panel draws a row per. */ + const registerAt = (address: number): ServerRegisterEntry => ({ + value: address, + params: { + address, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 0, + min: undefined, + max: undefined, + interval: undefined + } + }) + + /** The addresses the rendered rows carry, top to bottom. */ + const renderedAddresses = (map: ServerRegister): number[] => { + registers.holding_registers = map + const { container } = render( + + ) + return Array.from(container.querySelectorAll('[data-testid^="server-reg-value-"]')).map( + (cell) => Number(cell.getAttribute('data-testid')?.split('-').pop()) + ) + } + + // `RegisterAddressKeySchema` is `/^\d+$/` refined to 65535, so a hand-edited + // config carrying '007' is accepted. A key that is not a canonical numeric + // string is not an integer index, so the language enumerates it after every + // key that is, in insertion order. + it('sorts by address where a key is not an integer index', () => { + expect(renderedAddresses({ '007': registerAt(7), '9': registerAt(9) })).toEqual([7, 9]) + }) + + it('sorts by address where every key is one', () => { + expect(renderedAddresses({ 2: registerAt(2), 10: registerAt(10) })).toEqual([2, 10]) + }) +}) diff --git a/src/renderer/src/context/__tests__/corruptedConfig.test.ts b/src/renderer/src/context/__tests__/corruptedConfig.test.ts index 6b438a78..5a3e74f0 100644 --- a/src/renderer/src/context/__tests__/corruptedConfig.test.ts +++ b/src/renderer/src/context/__tests__/corruptedConfig.test.ts @@ -82,7 +82,8 @@ describe('a persisted client config with one field that fails its schema', () => await import('../client.zustand') - const kept = Object.keys(localStorage).filter((k) => k.startsWith('client.zustand.corrupt-')) + const kept = Object.keys(localStorage).filter((k) => k.startsWith('client.zustand.corrupt')) + expect(kept).toEqual(['client.zustand.corrupt']) expect(kept.map((key) => localStorage.getItem(key))).toEqual([stored]) }) diff --git a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts index bf61a7bb..cdf5f9af 100644 --- a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts @@ -146,6 +146,41 @@ describe('the server window closing', () => { expect(useServerZustand.getState().ready.u).toBe(false) }) + // The load path reads the key back through `PersistedServerZustandSchema` + // and this one ran `migrateServerState` alone, so a key hand-edited between + // that window opening and closing was installed unvalidated. + it('reads the re-read key back through the schema', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0])) + const { useServerZustand } = await import('../server.zustand') + await settle() + expect(useServerZustand.getState().configReset).toBeUndefined() + + const edited = JSON.parse(store([0])) + edited.state.port = 'nope' + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(edited)) + + fireEvent('window_update', { main: true, server: true }) + fireEvent('window_update', { main: true, server: false }) + await settle() + + expect(useServerZustand.getState().configReset?.fields).toContain('port') + expect(useServerZustand.getState().port).toEqual(useServerZustand.getInitialState().port) + }) + + it('says nothing when the re-read key parses', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0])) + const { useServerZustand } = await import('../server.zustand') + await settle() + + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0, 77])) + + fireEvent('window_update', { main: true, server: true }) + fireEvent('window_update', { main: true, server: false }) + await settle() + + expect(useServerZustand.getState().configReset).toBeUndefined() + }) + // `rtuServerActive` is not persisted, so re-reading the key leaves it where // it was, and the split out window is the one that heard the last change. it('asks main for the RTU status, at load and at the close', async () => { diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index e5663cc6..8806613a 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -536,14 +536,25 @@ export const useServerZustand = create< const serverZustand = useServerZustand.getState() -// Keep the fields that parsed and default the rest, then say which went. -const repair = repairPersistedStore(useServerZustand, PersistedServerZustandSchema, { - storageKey: SERVER_ZUSTAND_STORAGE_KEY, - persistedVersion, - currentVersion: CURRENT_SERVER_ZUSTAND_VERSION -}) +/** + * Keep the fields that parsed and default the rest, then say which went. + * + * Called at load and again after the `window_update` rehydrate, which is the + * store's second way in: `persist.rehydrate()` runs `migrate` and merges, and + * `migrate` writes `persistedVersion`, so each call reads the version the key + * it is checking carried. `MessageReceiver` selects `configReset`, so a reset + * found at the close is told the same way one found at load is. + */ +const repairFromTheKey = (): void => { + const repair = repairPersistedStore(useServerZustand, PersistedServerZustandSchema, { + storageKey: SERVER_ZUSTAND_STORAGE_KEY, + persistedVersion, + currentVersion: CURRENT_SERVER_ZUSTAND_VERSION + }) + if (repair) useServerZustand.setState({ ...repair.state, configReset: repair.reset }) +} -if (repair) useServerZustand.setState({ ...repair.state, configReset: repair.reset }) +repairFromTheKey() // Init server useServerZustand.getState().init() @@ -852,7 +863,10 @@ onEvent('window_update', ({ server }) => { if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false void Promise.resolve(useServerZustand.persist.rehydrate()) - .then(markRehydratedUuidsReady) + .then(() => { + repairFromTheKey() + markRehydratedUuidsReady() + }) // `rehydrate` reads the key and runs `migrateServerState` over what it // finds, and either can throw on a blob a hand edit left there. Unhandled, // that is the window's only sign that the re-read did not happen. diff --git a/src/shared/__tests__/conversion.test.ts b/src/shared/__tests__/conversion.test.ts index b028384a..11659bce 100644 --- a/src/shared/__tests__/conversion.test.ts +++ b/src/shared/__tests__/conversion.test.ts @@ -202,6 +202,19 @@ describe('convertRegisterData', () => { expect(convertRegisterData(null as never, 0, false, false)).toEqual([]) }) + it('reads the whole registers of an odd buffer and stops', () => { + // Three bytes is what a device declaring a byte count of 3 hands over. + // Without a floor the register count is 1.5, `inRange32` is 0 < 0.5, and + // `readInt32BE(0)` on three bytes is "Attempt to access memory outside + // buffer bounds". + const result = { data: [1], buffer: Buffer.from([0x00, 0x01, 0x02]) } + + const data = convertRegisterData(result, 0, false, false) + + expect(data).toHaveLength(1) + expect(data[0]?.words?.uint16).toBe(1) + }) + it('converts a single register (uint16)', () => { const result = makeResult([1234]) const data = convertRegisterData(result, 100, false, false) diff --git a/src/shared/__tests__/repairPersisted.test.ts b/src/shared/__tests__/repairPersisted.test.ts index e7a20ae4..cb5c5be6 100644 --- a/src/shared/__tests__/repairPersisted.test.ts +++ b/src/shared/__tests__/repairPersisted.test.ts @@ -151,15 +151,29 @@ describe('keepCorrupt', () => { it('copies the blob under a key nothing reads', () => { const store = storage({ 'client.zustand': '{"broken":true}' }) - keepCorrupt(store, 'client.zustand', () => 1756800000000) + keepCorrupt(store, 'client.zustand') - expect(store.held['client.zustand.corrupt-1756800000000']).toBe('{"broken":true}') + expect(store.held['client.zustand.corrupt']).toBe('{"broken":true}') + }) + + it('keeps one copy per key, whatever a launch before it left', () => { + const store = storage({ + 'client.zustand': '{"broken":"second"}', + 'client.zustand.corrupt': '{"broken":"first"}' + }) + + keepCorrupt(store, 'client.zustand') + + expect(Object.keys(store.held).filter((key) => key.includes('.corrupt'))).toEqual([ + 'client.zustand.corrupt' + ]) + expect(store.held['client.zustand.corrupt']).toBe('{"broken":"second"}') }) it('leaves the original where it is', () => { const store = storage({ 'client.zustand': '{"broken":true}' }) - keepCorrupt(store, 'client.zustand', () => 1) + keepCorrupt(store, 'client.zustand') expect(store.held['client.zustand']).toBe('{"broken":true}') }) @@ -167,7 +181,7 @@ describe('keepCorrupt', () => { it('writes nothing when there is no blob', () => { const store = storage() - keepCorrupt(store, 'client.zustand', () => 1) + keepCorrupt(store, 'client.zustand') expect(Object.keys(store.held)).toEqual([]) }) diff --git a/src/shared/__tests__/schemas.test.ts b/src/shared/__tests__/schemas.test.ts index b4202396..496563a1 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -174,6 +174,22 @@ describe('BitMapConfigSchema', () => { expect(result.success).toBe(false) }) + // Both writers spell the key `String(bitIndex)` over 0 to 15, and + // `ServerBitMapDetail` and `BitMapDetailPanel` read it back the same way, so + // each of these is a key a hand edit put there and nothing shows. + it.each(['', ' ', '1.5', '1e1', '0x0', '+1', '\n'])( + 'rejects the bit index %j, which no reader asks for', + (key) => { + expect(BitMapConfigSchema.safeParse({ [key]: { comment: 'unreachable' } }).success).toBe( + false + ) + } + ) + + it.each(['0', '9', '10', '15'])('accepts the bit index %j', (key) => { + expect(BitMapConfigSchema.safeParse({ [key]: { comment: 'read' } }).success).toBe(true) + }) + it('accepts empty config', () => { expect(BitMapConfigSchema.safeParse({}).success).toBe(true) }) diff --git a/src/shared/__tests__/utils.test.ts b/src/shared/__tests__/utils.test.ts index a2cb17d0..5ace3c34 100644 --- a/src/shared/__tests__/utils.test.ts +++ b/src/shared/__tests__/utils.test.ts @@ -518,9 +518,14 @@ describe('humanizeSerialError', () => { expect(humanizeSerialError(err)).toBe('Connection timeout') }) - it('returns original message for unknown errors even with port', () => { + it('prepends port name for an error it does not recognise', () => { const err = new Error('Something went wrong') - expect(humanizeSerialError(err, 'COM5')).toBe('Something went wrong') + expect(humanizeSerialError(err, 'COM5')).toBe('COM5: Something went wrong') + }) + + it('prepends port name for an error with no message', () => { + const err = Object.assign(new Error(''), { code: 'EBUSY' }) + expect(humanizeSerialError(err, 'COM5')).toBe('COM5: Connection failed (EBUSY)') }) }) diff --git a/src/shared/conversion.ts b/src/shared/conversion.ts index 2f675342..3d90141f 100644 --- a/src/shared/conversion.ts +++ b/src/shared/conversion.ts @@ -105,8 +105,12 @@ export const convertRegisterData = ( const { buffer } = result const registerData: RegisterData[] = [] - // A register contains 16 bits, so we handle 2 bytes at a time - const registers = result.buffer.byteLength / 2 + // A register contains 16 bits, so we handle 2 bytes at a time. The floor is + // for the odd buffer: modbus-serial slices this at the byte count the + // response declares and never compares that field with the frame length it + // checked, so a device that declares an odd one leaves a trailing byte no + // register is made of. + const registers = Math.floor(buffer.byteLength / 2) for (let i = 0; i < registers; i++) { const offset = i * 2 // Register (16 bits) = 2 bytes diff --git a/src/shared/repairPersisted.ts b/src/shared/repairPersisted.ts index d1c56afd..ff6ba452 100644 --- a/src/shared/repairPersisted.ts +++ b/src/shared/repairPersisted.ts @@ -124,16 +124,18 @@ export function resetMessage(store: 'Client' | 'Server', reset: ConfigReset): st * * The reset is what makes the app usable again, and it is also what destroys * the evidence. A copy under a key nothing reads costs the bytes it holds. + * + * One copy per key, overwritten. The key carried a timestamp, so every launch + * that repaired anything left another whole blob in the store's own + * localStorage budget, and nothing read one or deleted one. The blob worth + * having is the one that failed most recently, and a fixed key is one + * something could offer to export. */ -export function keepCorrupt( - storage: Pick, - key: string, - now: () => number = Date.now -): void { +export function keepCorrupt(storage: Pick, key: string): void { try { const blob = storage.getItem(key) if (blob === null) return - storage.setItem(`${key}.corrupt-${now()}`, blob) + storage.setItem(`${key}.corrupt`, blob) } catch { // Storage that cannot be read holds nothing worth keeping either. } diff --git a/src/shared/types/bitmap.ts b/src/shared/types/bitmap.ts index 9fe572ff..e984dc80 100644 --- a/src/shared/types/bitmap.ts +++ b/src/shared/types/bitmap.ts @@ -10,12 +10,17 @@ export const BitMapEntrySchema = z.object({ invert: z.boolean().optional() }) -/** Record keyed by bit-index string ("0" – "15"). */ +/** + * Record keyed by bit-index string ("0" to "15"). + * + * The key was `!isNaN(Number(v))` refined to the range, the shape + * `RegisterAddressKeySchema` was rewritten away from: `Number` takes spellings + * that are not decimal digits, and `ServerBitMapDetail` and `BitMapDetailPanel` + * both write and read this key as `String(bitIndex)`. `schemas.test.ts` names + * the seven that got in. + */ export const BitMapConfigSchema = z.record( - z.string().refine((v) => { - const n = Number(v) - return !isNaN(n) && n >= 0 && n <= 15 - }), + z.string().regex(/^(?:[0-9]|1[0-5])$/), BitMapEntrySchema.optional() ) export type BitMapConfig = z.infer diff --git a/src/shared/utils.ts b/src/shared/utils.ts index b8eae277..ef4b38e0 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -331,13 +331,14 @@ export const getValueRangeError = (dataType: DataType, value: number): string | export const notEmpty = (value: number | string): boolean => String(value).replace(/-/g, '').length > 0 +/** What to show for a serial error, named by the port it came from. */ export const humanizeSerialError = (error: Error, port?: string): string => { const prefix = port ? `${port}: ` : '' const msg = (error.message || '').toLowerCase() if (msg.includes('file not found')) return `${prefix}Port not found or not available` if (msg.includes('access denied') || msg.includes('permission denied')) return `${prefix}Port access denied (already in use?)` - return error.message || `Connection failed${error['code'] ? ` (${error['code']})` : ''}` + return `${prefix}${error.message || `Connection failed${error['code'] ? ` (${error['code']})` : ''}`}` } export const getUsedAddresses = (registers: RegisterParams[]): number[] => { From d7512204aeb30ed63f1ac61c6aec799860913dd9 Mon Sep 17 00:00:00 2001 From: Harted Date: Fri, 18 Sep 2026 22:53:10 +0200 Subject: [PATCH 021/137] fix: a version the rehydrate did not re-read, and a row key two addresses share What `/code-review` found on `de9607e`, three of five. The second `repairFromTheKey` read a stale `persistedVersion`. persist runs `migrate` only where the blob's version differs from the store's (`zustand/middleware.js`), so a re-read of a key already at the current version leaves the number the launch put there. A launch off a newer blob puts a higher one there and the store re-persists at the current version, so every close of the split out window then reported "saved by a newer version of Modbux and was read in full" again, with no field having failed. It reaches no hand edit: one newer blob, then split and close. The rehydrate clears `persistedVersion` before it re-reads, so an unmigrated key answers undefined. That also took the copy `keepCorrupt` exists to keep. A second reset calls it, and the key is fixed now, so the healthy blob overwrote the unreadable one. '7' and '007' both pass `RegisterAddressKeySchema` and name address 7, so a row key built from the address was the same string twice. Both rows draw and React says the behaviour is unsupported: "Encountered two children with the same key, `server_register_holding_registers_7`." Measured, because the finding read it as one row with shared `expanded` state and that is not what happens. The key is the map key now, which is unique by construction. `Object.entries` sorts on the entry's address the same way. The changelog line for `keepCorrupt` is gone. `git show main:src/shared/repairPersisted.ts` does not resolve: the timestamped key arrived in `8ca2b10` on `chore/audit` and is in no tag, so no release ever wrote one copy per launch. The fifth is in TODO.md rather than here. A repair on this path defaults a field while main keeps serving, and the load path's `init()` is not what the close can call. lint 0, typecheck 0, 71 files and 1446 passed. E2E 06, 10, 21, 26, 30 and 02-standalone: 124 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 4 ---- .../ServerRegisters/ServerRegisters.tsx | 15 ++++++------ .../__tests__/ServerRegisters.test.tsx | 18 +++++++++++++++ .../__tests__/serverStoreRehydrate.test.ts | 23 +++++++++++++++++++ src/renderer/src/context/server.zustand.ts | 14 +++++++---- 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff19894..ce6e03fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -317,10 +317,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and the read stopped with `Attempt to access memory outside buffer bounds` rather than showing the registers that did come through. The trailing byte is dropped now. -- **Modbux keeps one copy of a configuration it could not read, rather than one - per launch.** Every launch that had to repair the saved setup left a copy of - the unreadable one beside it, and nothing read a copy or removed one. The copy - is the whole saved configuration, register mapping included. - **A serial error Modbux does not recognise now names the port it came from.** The port was in front of the two messages Modbux writes itself and missing from the one that hands the driver's own text through, which is the message a diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 534d5413..b9587d41 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -142,18 +142,17 @@ const ServerRegisterRows = meme(({ type }: { type: NumberRegisters }) => { return z.serverRegisters[uuid]?.[unitId]?.[type] }) // Sorted by the address the row draws, the way `ServerBoolList` sorts its - // keys. `Object.values` is ascending only over integer-index keys, and - // `RegisterAddressKeySchema` accepts '007', which is not one. + // keys, and keyed by the map key rather than that address. `Object.entries` + // is ascending only over integer-index keys, and `RegisterAddressKeySchema` + // accepts '007', which is not one and which names the same address as '7'. const registers = useMemo( - () => Object.values(registerMap ?? {}).sort((a, b) => a.params.address - b.params.address), + () => + Object.entries(registerMap ?? {}).sort(([, a], [, b]) => a.params.address - b.params.address), [registerMap] ) - return registers.map((register) => ( - + return registers.map(([address, register]) => ( + )) }) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx index 73d83ae7..c6a8aa63 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/ServerRegisters.test.tsx @@ -161,4 +161,22 @@ describe('what order the server rows come out in', () => { it('sorts by address where every key is one', () => { expect(renderedAddresses({ 2: registerAt(2), 10: registerAt(10) })).toEqual([2, 10]) }) + + // '7' and '007' both pass `RegisterAddressKeySchema` and both entries carry + // address 7, so a row key built from the address alone is the same string + // twice. Both rows draw today and React says the behaviour is unsupported: + // "Encountered two children with the same key, + // `server_register_holding_registers_7`." The map key is unique by + // construction, so keying on it is the answer that cannot go either way. + it('gives a row per entry a key of its own where two keys name one address', () => { + const warnings: unknown[] = [] + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + warnings.push(args[0]) + }) + + expect(renderedAddresses({ '007': registerAt(7), '7': registerAt(7) })).toEqual([7, 7]) + + spy.mockRestore() + expect(warnings.filter((w) => String(w).includes('same key'))).toEqual([]) + }) }) diff --git a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts index cdf5f9af..6f2bc6ad 100644 --- a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts @@ -167,6 +167,29 @@ describe('the server window closing', () => { expect(useServerZustand.getState().port).toEqual(useServerZustand.getInitialState().port) }) + // `persist` runs `migrate` only where the blob's version differs from the + // store's, so a re-read of a key already at the current version leaves + // `persistedVersion` holding whatever the launch put there. A launch off a + // newer blob puts a higher number there, and `repairPersisted` answers a + // reset on that alone. + it('does not report a newer version twice for a key at this one', async () => { + const fromNewer = JSON.parse(store([0])) + fromNewer.version = CURRENT_SERVER_ZUSTAND_VERSION + 1 + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(fromNewer)) + const { useServerZustand } = await import('../server.zustand') + await settle() + expect(useServerZustand.getState().configReset?.savedByNewerVersion).toBe(true) + + useServerZustand.getState().acknowledgeConfigReset() + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0, 77])) + + fireEvent('window_update', { main: true, server: true }) + fireEvent('window_update', { main: true, server: false }) + await settle() + + expect(useServerZustand.getState().configReset).toBeUndefined() + }) + it('says nothing when the re-read key parses', async () => { localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, store([0])) const { useServerZustand } = await import('../server.zustand') diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 8806613a..c7d5f702 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -46,10 +46,15 @@ import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' /** - * The version the blob on disk carried, set by `migrate` and read once below. + * The version the blob on disk carried, set by `migrate` and read by + * `repairFromTheKey`. * * persist calls `migrate` for any version that is not the current one, the ones - * above it included, and that call is the only place the number is offered. + * above it included, and that call is the only place the number is offered. A + * key at the current version therefore leaves this where it was, which is why + * the rehydrate clears it before re-reading: a launch off a newer blob leaves a + * higher number here, the store re-persists at the current version, and + * `repairPersisted` answers a reset on `savedByNewerVersion` alone. */ let persistedVersion: number | undefined @@ -540,9 +545,7 @@ const serverZustand = useServerZustand.getState() * Keep the fields that parsed and default the rest, then say which went. * * Called at load and again after the `window_update` rehydrate, which is the - * store's second way in: `persist.rehydrate()` runs `migrate` and merges, and - * `migrate` writes `persistedVersion`, so each call reads the version the key - * it is checking carried. `MessageReceiver` selects `configReset`, so a reset + * store's second way in. `MessageReceiver` selects `configReset`, so a reset * found at the close is told the same way one found at load is. */ const repairFromTheKey = (): void => { @@ -862,6 +865,7 @@ onEvent('window_update', ({ server }) => { } if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false + persistedVersion = undefined void Promise.resolve(useServerZustand.persist.rehydrate()) .then(() => { repairFromTheKey() From 3e94acdf9c3cd64c6e67f11f831b8d57f66406b8 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 10:21:43 +0200 Subject: [PATCH 022/137] refactor: what six comments and three names point at Round A of the audit's non-blocking half, and the last of its six rounds. Every item changes what the text says rather than what the app does, so the meter is typecheck and the unit suite. It ran last because a comment adjusted first describes the code the rounds after it move. Three comments described something else. `resetServer` said `createServer` is called "for the case where there is none, such as after a spell in RTU mode", and after a spell in RTU mode is when it is not called at all. `_bindServer` writes `_port` and `_servers` on consecutive lines and `_closeAndForget` deletes both, so a stored port means a listener on that port, which `createServer` answers at its first line. `f339d93` is where that became true and `modbusServer.test.ts` has asserted it since. `MessageReceiver` said the server window runs the server store "and no message listener", and `Server.tsx:55` renders it. `migrations/server/zustand.ts` carried `migrateServerRegistersState`'s docblock over `migrateServerModeState` as well as over the function it belongs to. Three names pointed at the wrong thing. `DEFAULT_MOBUS_PORT` misspells Modbus, and nothing outside `modbusServer.ts` reads it, so it lost its `export` with the rename. `src/shared` held two `shared.ts` and two `utils.ts`. Both `utils.ts` reach the `@shared` barrel, through `index.ts` and through `types/index.ts`, and `migrations/server/config.ts` imported from both `shared.ts` in one block, as `'../shared'` and `'./shared'`. `migrations/server/shared.ts` holds the v1 server types and is `v1.ts` now; `types/utils.ts` holds two type-level helpers and is `types/helpers.ts`. `IpcEventTarget` says which of its three a caller names and what decides it. A boundary is handed the asker, so `ipc.ts` answers `event.sender`; a module is not, so it names the view that draws what failed. The two rules pick the same window wherever the window that asked is the one drawing, and the deliberate exception is a client channel refused from a window that is not the main one. `send`'s docblock lost the two paragraphs that now sit on the type. A scan's three pauses carry their cost. The widest unit id scan the fields accept is 256 ids over four register types, which is 1024 progress calls and 5.12 s of pausing plus 2.56 s of the per-id pause; a register scan of 65536 addresses at a chunk of 100 is 656 chunks and 6.56 s. `8aed54a` brought all three in with the scans and recorded no reason for any of them, so shortening one is measured against a device rather than settled in a comment. `littleBigEndian.md` is tracked, sat under `src/main/modules` and was named by nothing. It documents `littleEndian32`, which lives in `src/shared/utils.ts`, so it moved beside that file and the two name each other now. `applyRegisterValue` looked back three addresses under a comment naming int64 and double. `MAX_NUMBER_REGISTER_WIDTH` is the 4 those two have, read off `registerWidth` rather than written beside it. Set to 3, the four `exact64BitValue.test.ts` cases go red: a word at offset 3 stops finding its base. lint 0, typecheck 0, 71 files and 1446 passed. E2E 06, 07, 08 and 09: 114 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../modules/__tests__/modbusServer.test.ts | 2 +- src/main/modules/modbusClient.ts | 19 +++++++++++ src/main/modules/modbusServer.ts | 19 +++++++---- src/main/windows.ts | 33 ++++++++++++------- .../src/components/shared/MessageReceiver.tsx | 4 +-- src/renderer/src/context/server.zustand.ts | 12 +++++-- .../modules => shared}/littleBigEndian.md | 2 ++ src/shared/migrations/server/config.ts | 2 +- .../migrations/server/{shared.ts => v1.ts} | 0 src/shared/migrations/server/zustand.ts | 7 +--- src/shared/types/{utils.ts => helpers.ts} | 0 src/shared/types/index.ts | 2 +- src/shared/utils.ts | 30 ++++++++++++++--- 13 files changed, 96 insertions(+), 36 deletions(-) rename src/{main/modules => shared}/littleBigEndian.md (95%) rename src/shared/migrations/server/{shared.ts => v1.ts} (100%) rename src/shared/types/{utils.ts => helpers.ts} (100%) diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 3aaeae75..4e12d20e 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1332,7 +1332,7 @@ describe('ModbusServer', () => { }) }) - // ! Coverage-only: exercises port ?? DEFAULT_MOBUS_PORT branch + // ! Coverage-only: exercises port ?? DEFAULT_MODBUS_PORT branch it('uses default port (502) when port is not provided', async () => { const port = await server.createServer({ uuid, port: undefined as unknown as number }) expect(port).toBe(502) diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 9e5013a3..ba85a95f 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -228,6 +228,19 @@ export class ModbusClient { this._windows.send('address_groups', groups, 'main') } + /** + * Sends how far a scan has got, and pauses 5 ms. + * + * Both scans await this between two device requests, so the pause is what a + * scan spends rather than what it reports. The widest unit id scan the fields + * accept is 256 ids over four register types, which is 1024 calls and 5.12 s; + * a register scan of 65536 addresses at a chunk of 100 is 656 calls and + * 3.28 s. + * + * `8aed54a` brought the three pauses in with the scans and recorded no reason + * for any of them. Shortening one is a change to how fast Modbux talks to a + * device, so it is measured against a device rather than settled here. + */ private _sendScanProgress = async (): Promise => { this._scansDone++ const progress = round((this._scansDone / this._totalScans) * 100, 2) @@ -1079,6 +1092,9 @@ export class ModbusClient { return } + // One pause per unit id, between the last register type's progress and this + // id's result row: 2.56 s over the widest scan the fields accept. + // `_sendScanProgress` says what these figures are. await new Promise((resolve) => setTimeout(resolve, 10)) this._sendUnitIdResult(result) } @@ -1110,6 +1126,9 @@ export class ModbusClient { await this._sendScanProgress() if (!this._clientState.scanningRegisters) break if (this._clientState.connectState !== 'connected') break + // The second pause per chunk, on top of `_sendScanProgress`'s, so a + // register scan waits 10 ms between chunks: 6.56 s over 65536 addresses + // at a chunk of 100. await new Promise((resolve) => setTimeout(resolve, 5)) } diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 98666661..480ecf7d 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -53,7 +53,9 @@ const getDefaultServerData = (): { export const ILLEGAL_DATA_ADDRESS = 2 export const SERVER_DEVICE_FAILURE = 4 export const GATEWAY_TARGET_FAILED = 11 -export const DEFAULT_MOBUS_PORT = 502 + +/** Modbus TCP's registered port, which the three paths below fall back to. */ +const DEFAULT_MODBUS_PORT = 502 /** * The transport a vector answers on. RS-485 is shared and a socket is not, so a @@ -387,7 +389,7 @@ export class ModbusServer { public createServer = async ({ uuid, port }: CreateServerParams): Promise => { // A stored 0 from before this was refused would send the server to a port // nobody can name, so it starts where it would have started without one. - let actualPort = port !== undefined && isPort(port) ? port : DEFAULT_MOBUS_PORT + let actualPort = port !== undefined && isPort(port) ? port : DEFAULT_MODBUS_PORT const maxAttempts = 10000 if (this._servers.has(uuid) && this._port.get(uuid) === actualPort) return actualPort @@ -456,8 +458,13 @@ export class ModbusServer { * clears its register data. * * The vectors read `_serverData` per request, so the cleared data is what a - * master gets from the listener that is already up. `createServer` is called - * for the case where there is none, such as after a spell in RTU mode. + * master gets from the listener that is already up. + * + * `createServer` rebinds nothing from here. `_bindServer` writes `_port` and + * `_servers` on consecutive lines and `_closeAndForget` deletes both, so a + * stored port means a listener on that port, which is `createServer`'s first + * return. After a spell in RTU mode there is no stored port and the call does + * not happen at all. */ public resetServer = async (uuid: string): Promise => { const unitIdGenerators = this._generatorMap.get(uuid) @@ -895,7 +902,7 @@ export class ModbusServer { * no auto-increment. Emits error message on failure and returns the current port. */ public setPort = async ({ uuid, port }: CreateServerParams): Promise => { - const requestedPort = port ?? DEFAULT_MOBUS_PORT + const requestedPort = port ?? DEFAULT_MODBUS_PORT const currentPort = this._port.get(uuid) ?? requestedPort // Port 0 is not a port, it is a request for whichever one is free, and @@ -903,7 +910,7 @@ export class ModbusServer { // name, and the number sent back to the view would be the 0 it asked for. if (!isPort(requestedPort)) { this._emitMessage({ message: 'A server needs a port between 1 and 65535', variant: 'error' }) - return this._port.get(uuid) ?? DEFAULT_MOBUS_PORT + return this._port.get(uuid) ?? DEFAULT_MODBUS_PORT } const result = await this._isPortAvailable(requestedPort) diff --git a/src/main/windows.ts b/src/main/windows.ts index 06dc31e2..c4d4ab4b 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -6,7 +6,26 @@ interface WindowsObject { server: BrowserWindow | null } -/** Who an addressed event goes to. `send` without one reaches every window. */ +/** + * Who an addressed event goes to. `send` without one reaches every window. + * + * A `WebContents` is the window that asked. `ipcMain.handle` hands the invoking + * contents to every handler, so a refused payload reports where it came from + * rather than everywhere. + * + * `'main'` is the window the client work happens in, which never moves. + * `'serverView'` is the window showing the server, which is the popped out one + * while it exists and the main window otherwise, the same question + * `PrivilegedPortModal` answers by where it is mounted. + * + * Which one a caller picks follows from what it knows. A boundary is handed the + * sender; a module is not, so it names the view that draws what failed, and + * `modbusClient` sends `'main'` where `modbusServer` sends `'serverView'`. The + * two rules pick the same window wherever the window that asked is the one + * drawing, and `CLIENT_CHANNELS` holds the client half to that. Its refusal is + * the deliberate exception: a client channel from any other window is refused + * before its handler runs, and that answer goes to the window that asked. + */ export type IpcEventTarget = WebContents | 'main' | 'serverView' export class Windows { @@ -26,16 +45,8 @@ export class Windows { * server store, and the listeners those stores install run in both. An event * that changes a store only one view draws leaves the other window writing * its own copy over the same key, and the copy that writes last is the one on - * disk. That is what `to` is for. - * - * A `WebContents` is the window that asked. `ipcMain.handle` hands the - * invoking contents to every handler, so a refused payload reports where it - * came from rather than everywhere. - * - * `'main'` is the window the client work happens in, which never moves. - * `'serverView'` is the window showing the server, which is the popped out - * one while it exists and the main window otherwise, the same question - * `PrivilegedPortModal` answers by where it is mounted. + * disk. That is what `to` is for, and `IpcEventTarget` says which of the + * three a caller names. * * The guard is per window rather than around the loop, because a throw on one * window costs every window after it the event. `Object.values` puts `main` diff --git a/src/renderer/src/components/shared/MessageReceiver.tsx b/src/renderer/src/components/shared/MessageReceiver.tsx index c2118649..94ffc0f1 100644 --- a/src/renderer/src/components/shared/MessageReceiver.tsx +++ b/src/renderer/src/components/shared/MessageReceiver.tsx @@ -32,8 +32,8 @@ const MessageReceiver = meme((): null => { // A store repairs its persisted config while the module graph is still // evaluating, which is before any provider exists to tell. It records what it - // had to reset instead, and this says so. Both windows report their own: the - // server window runs the server store and no message listener. + // had to reset instead, and this says so. Both windows report their own, + // because each holds its own copy of both stores. // // Acknowledged after telling, because this component mounts inside Client and // Server rather than at the root: without that, walking Home and back reports diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index c7d5f702..f85689ac 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -20,6 +20,7 @@ import { CURRENT_SERVER_ZUSTAND_VERSION, SERVER_ZUSTAND_STORAGE_KEY, registerWidth, + MAX_NUMBER_REGISTER_WIDTH, ModbusBaudRate, RegisterType, RegisterValue, @@ -639,12 +640,17 @@ export const applyRegisterValue = (payload: RegisterValue): void => { // Handle input and holding registers const { uuid, unitId, registerType, address, value: numberValue } = payload - // 1) Find the “base entry” in state.serverRegisters[*][*][registerType] - // We look back up to 3 registers because the largest DataType (int64/double) uses 4 registers. + // 1) Find the base entry in state.serverRegisters[*][*][registerType] this + // word belongs to. A register covers its own address and the ones after + // it, so the base is within `MAX_NUMBER_REGISTER_WIDTH` addresses back. let serverRegisterEntry: ServerRegisterEntry | undefined let entryAddress: number | undefined - for (let candidateAddress = address; candidateAddress >= address - 3; candidateAddress--) { + for ( + let candidateAddress = address; + candidateAddress > address - MAX_NUMBER_REGISTER_WIDTH; + candidateAddress-- + ) { const candidateEntry = serverZustand.serverRegisters[uuid]?.[unitId]?.[registerType]?.[candidateAddress] if (!candidateEntry) continue diff --git a/src/main/modules/littleBigEndian.md b/src/shared/littleBigEndian.md similarity index 95% rename from src/main/modules/littleBigEndian.md rename to src/shared/littleBigEndian.md index f58f1b10..2139451f 100644 --- a/src/main/modules/littleBigEndian.md +++ b/src/shared/littleBigEndian.md @@ -1,3 +1,5 @@ +What `bigEndian32` and `littleEndian32` in `utils.ts` do to a buffer, read from the PLC's side. + A table showing the association between Modbus registers and the SCL variables `W1` (most significant word) and `W0` (least significant word) in both Big-Endian and Little-Endian order. We use the same `int32` value of **305419896** (hexadecimal **0x12345678**). ### Big-Endian vs Little-Endian Table with SCL Register Assignment: diff --git a/src/shared/migrations/server/config.ts b/src/shared/migrations/server/config.ts index dfce8dea..a26ebac3 100644 --- a/src/shared/migrations/server/config.ts +++ b/src/shared/migrations/server/config.ts @@ -16,7 +16,7 @@ import { renameLegacyRegisterTypeKeys, stringifyExact64BitValues } from '../shared' -import { V1ServerConfig, V1ServerRegisters, extractGlobalEndianness } from './shared' +import { V1ServerConfig, V1ServerRegisters, extractGlobalEndianness } from './v1' import { repairPersisted } from '../../repairPersisted' /** diff --git a/src/shared/migrations/server/shared.ts b/src/shared/migrations/server/v1.ts similarity index 100% rename from src/shared/migrations/server/shared.ts rename to src/shared/migrations/server/v1.ts diff --git a/src/shared/migrations/server/zustand.ts b/src/shared/migrations/server/zustand.ts index 1bb117a1..6889b54f 100644 --- a/src/shared/migrations/server/zustand.ts +++ b/src/shared/migrations/server/zustand.ts @@ -1,5 +1,5 @@ import { defaultSerialPortOptions } from '../../default' -import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } from './shared' +import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } from './v1' import { dropUnservableRegisters, migrateBoolShapeForUnit, @@ -35,11 +35,6 @@ export function migrateBoolShape( } } -/** - * Migrate server Zustand state from v1 (littleEndian per register) to v2 (global littleEndian). - * Also converts old boolean shape to { value: boolean } entries. - * Used by Zustand persist middleware. - */ /** * Migrate server Zustand state from v2 to v3: add serverMode and serialConfig defaults. */ diff --git a/src/shared/types/utils.ts b/src/shared/types/helpers.ts similarity index 100% rename from src/shared/types/utils.ts rename to src/shared/types/helpers.ts diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 22fd4c76..8f5d8a0c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,6 +1,6 @@ export * from './datatype' export * from './server' -export * from './utils' +export * from './helpers' export * from './ranges' export * from './scan' export * from './client' diff --git a/src/shared/utils.ts b/src/shared/utils.ts index ef4b38e0..a4905407 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,9 +1,10 @@ // `types/server.ts` imports `getAddressFitError` and `getValueRangeError` from // here, so this reads the barrel no longer: it would be a cycle through -// `types/index.ts`, and one that costs nothing only while every name below is a -// type the compiler erases. `import type` says which of the two these are. -import type { CamelCase } from './types/utils' -import type { DataType, EncodableDataType } from './types/datatype' +// `types/index.ts`. `import type` says which names the compiler erases, and the +// cycle back through `types/server.ts` is one of them. `DataTypeSchema` is the +// one value below, out of a module that imports zod and nothing else. +import type { CamelCase } from './types/helpers' +import { DataTypeSchema, type DataType, type EncodableDataType } from './types/datatype' import type { RegisterParams, ServerRegisters } from './types/server' /** @@ -64,12 +65,31 @@ export const registerWidth = (dataType: DataType, length?: number): number => { } } +/** + * The most registers a number spans, which is how far back a word has to look + * for the register it belongs to. + * + * Read off the table above rather than written again, so a wider type is one + * edit. `utf8` is left out because its width is the length the user chose, and + * a string composes no value out of the words around it. + */ +export const MAX_NUMBER_REGISTER_WIDTH = Math.max( + ...DataTypeSchema.options + .filter((dataType) => dataType !== 'utf8') + .map((dataType) => registerWidth(dataType)) +) + // Regular most significant word first (big endian) export const bigEndian32 = (buffer: Buffer, offset: number): Buffer => { return buffer.subarray(offset, offset + 4) } -// Uncommon least significant word first (little endian) +/** + * Uncommon least significant word first (little endian). + * + * `littleBigEndian.md`, beside this file, puts 0x12345678 through both orders + * and gives the SCL that writes either one from a Siemens PLC. + */ export const littleEndian32 = (buffer: Buffer, offset: number): Buffer => { return Buffer.concat([ buffer.subarray(offset + 2, offset + 4) as Uint8Array, From 80986b4d4dcb04ed1606bd8219cb47ab7102d3b1 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 10:27:54 +0200 Subject: [PATCH 023/137] docs: the lookback, one address narrower What `/code-review` found on `ded1cfb`, the only finding. `MAX_NUMBER_REGISTER_WIDTH`'s docblock said it is how far back a word has to look, and `applyRegisterValue`'s comment said the base is within that many addresses back. Both are one wide: the loop visits `address` down to `address - 3`, and a fourth step would reach a base a 4 register value starts too early to cover, where `offsetRegisters >= registersCount` drops the word instead of composing it. Two sentences, no code: the loop was already written the narrow way. lint 0, typecheck 0, 71 files and 1446 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/renderer/src/context/server.zustand.ts | 3 ++- src/shared/utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index f85689ac..01e3907c 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -642,7 +642,8 @@ export const applyRegisterValue = (payload: RegisterValue): void => { // 1) Find the base entry in state.serverRegisters[*][*][registerType] this // word belongs to. A register covers its own address and the ones after - // it, so the base is within `MAX_NUMBER_REGISTER_WIDTH` addresses back. + // it, so the base is this address or one of the + // `MAX_NUMBER_REGISTER_WIDTH - 1` before it. let serverRegisterEntry: ServerRegisterEntry | undefined let entryAddress: number | undefined diff --git a/src/shared/utils.ts b/src/shared/utils.ts index a4905407..9c928333 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -66,8 +66,8 @@ export const registerWidth = (dataType: DataType, length?: number): number => { } /** - * The most registers a number spans, which is how far back a word has to look - * for the register it belongs to. + * The most registers a number spans. A word belongs to a register starting at + * its own address or at one of the `MAX_NUMBER_REGISTER_WIDTH - 1` before it. * * Read off the table above rather than written again, so a wider type is one * edit. `utf8` is left out because its width is the length the user chose, and From d2fcda3552dfdb0ff310d7c2f5ade8be3da55b1f Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 11:49:47 +0200 Subject: [PATCH 024/137] refactor: the version is the layout store's to fetch R4 of the audit's non-blocking half. Two calls ran from `client.zustand`'s module tail with nothing awaiting them and no catch, in a file whose `init` carries one for exactly that reason. The app version was the one call that tail made from the split out server window, and a docblock said why it could: `get_app_version` asks `app` rather than the client, and Home is the only reader. Home is not the only reader. `SaveButton` and `OpenSaveClear` both read `version` into the config they save, and `OpenSaveClear` is the server view's, which is exactly what the split out window draws. The fetch is `layout.zustand`'s now, beside the field it fills and guarded on nothing, so the client store's tail is inside its guard end to end. `stopScanningUnitIds` got the catch it owed. Both catches say what happened and no more: main reports its own failures through `backend_message`, so a rejected invoke carries the channel name and nothing the user can act on. `clientStoreInServerWindow.test.ts` asserted `['getAppVersion']` under the title "tells main nothing at module scope". It asserts nothing now, which is what the title says. `appVersion.test.ts` is the pair: the version reaches the layout store in both windows, and it is asked for once. Gating the new fetch the way the client store's tail is gated takes the server window's copy away and two of the three go red; removing it takes all three. lint 0, typecheck 0, 72 files and 1449 passed. E2E 01, 05, 10 and 14: 51 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../src/context/__tests__/appVersion.test.ts | 55 +++++++++++++++++++ .../clientStoreInServerWindow.test.ts | 2 +- src/renderer/src/context/client.zustand.ts | 22 +++++--- src/renderer/src/context/layout.zustand.ts | 19 +++++++ 4 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/context/__tests__/appVersion.test.ts diff --git a/src/renderer/src/context/__tests__/appVersion.test.ts b/src/renderer/src/context/__tests__/appVersion.test.ts new file mode 100644 index 00000000..d37317e3 --- /dev/null +++ b/src/renderer/src/context/__tests__/appVersion.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom +// +// The version was fetched from `client.zustand`'s module tail, which is guarded +// on the main window, so the fetch was the one call that tail made from both. +// `OpenSaveClear` writes `version` into every server config saved, and the +// split out server window is where that button is, so the fetch has to happen +// there too. It is `layout.zustand`'s now, which owns the field and is guarded +// on nothing. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiCall, recordApiCalls, stubRenderer } from './stubRenderer' + +const calls: ApiCall[] = [] + +/** Waits out the module tail's microtasks, so the answer is in the store. */ +const settle = async (): Promise => { + await Promise.resolve() + await Promise.resolve() +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + calls.length = 0 +}) + +describe('the app version', () => { + it('reaches the layout store in the main window', async () => { + stubRenderer() + + const { useLayoutZustand } = await import('../layout.zustand') + await settle() + + expect(useLayoutZustand.getState().version).toBe('0.0.0-test') + }) + + it('reaches the layout store in the split out server window', async () => { + stubRenderer({ isServerWindow: true }) + + const { useLayoutZustand } = await import('../layout.zustand') + await settle() + + expect(useLayoutZustand.getState().version).toBe('0.0.0-test') + }) + + it('is asked for once, by the layout store', async () => { + stubRenderer({ isServerWindow: true }) + recordApiCalls(calls) + + await import('../layout.zustand') + await import('../client.zustand') + await settle() + + expect(calls.filter(({ method }) => method === 'getAppVersion')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/context/__tests__/clientStoreInServerWindow.test.ts b/src/renderer/src/context/__tests__/clientStoreInServerWindow.test.ts index f8af07ad..5a79110d 100644 --- a/src/renderer/src/context/__tests__/clientStoreInServerWindow.test.ts +++ b/src/renderer/src/context/__tests__/clientStoreInServerWindow.test.ts @@ -32,7 +32,7 @@ describe('client.zustand in the split out server window', () => { await import('../client.zustand') await settle() - expect(calls.map(({ method }) => method)).toEqual(['getAppVersion']) + expect(calls.map(({ method }) => method)).toEqual([]) }) // persist wraps `setState`, so `init`'s own `set` wrote the whole partialized diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 6ebc182d..d76da248 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { create } from 'zustand' -import { useLayoutZustand } from './layout.zustand' import { mutative } from 'zustand-mutative' import { persist } from 'zustand/middleware' import { @@ -533,9 +532,9 @@ const clientZustand = useClientZustand.getState() * `CLIENT_ZUSTAND_STORAGE_KEY` as well, because persist wraps `setState`, so * the split window overwrote the shared key on every open. * - * The version fetch below stays outside it. `get_app_version` asks `app` rather - * than the client, and `containers/Home.tsx` is the only reader of what it - * answers, which `App.tsx` does not draw in this window. + * Everything this tail does is inside the guard now. The app version was the + * exception, and it is fetched from `layout.zustand` instead, where the field + * it fills lives and where both windows reach it. */ const isServerWindow = window.api.isServerWindow @@ -586,8 +585,13 @@ onEvent('scan_progress', (scanProgress) => { // // Stop scanning when reloaded, shouldn't be a problem with the build app, // but just in case and for development, stop scanning when the frontend is reloaded -if (!isServerWindow) window.api.stopScanningUnitIds() - -window.api.getAppVersion().then((version) => { - useLayoutZustand.getState().setVersion(version) -}) +// +// The catch is what a module tail owes: nothing awaits this call, and a +// rejection with nothing behind it is an unhandled one. Main reports its own +// failures through `backend_message`, so a rejected invoke carries the channel +// name and nothing the user can act on. +if (!isServerWindow) { + window.api + .stopScanningUnitIds() + .catch((error) => console.error('A running scan was not stopped:', error)) +} diff --git a/src/renderer/src/context/layout.zustand.ts b/src/renderer/src/context/layout.zustand.ts index 99940242..9e4f7742 100644 --- a/src/renderer/src/context/layout.zustand.ts +++ b/src/renderer/src/context/layout.zustand.ts @@ -42,6 +42,25 @@ export const useLayoutZustand = create useLayoutZustand.getState().setVersion(version)) + .catch((error) => console.error('The app version was not read:', error)) + // Listen to main process events onEvent('window_update', (windows) => { if (isServerWindow) return From 9c66e28eabf702e98bfa1fc040472c2b2f69dbba Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 11:55:43 +0200 Subject: [PATCH 025/137] docs: what the guard covers, and what it does not What `/code-review` found on `c4092af`, the only finding. "Everything this tail does is inside the guard now" is not true, and the commit body said it too. The persisted repair sits below the guard and runs in both windows, and so do the four `onEvent` registrations. What the guard covers is every call this tail makes to main, which is what the docblock says now. The repair's `setState` writes the shared key through persist, the way `init`'s does, and the paragraph above already blames that mechanism. `appVersion.test.ts`'s third case was titled "by the layout store" and counts to one, which a fetch moved back into the client store would still satisfy. The two cases above it are what pin the fetch, so the title says what this one asserts. lint 0, typecheck 0, 72 files and 1449 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/renderer/src/context/__tests__/appVersion.test.ts | 2 +- src/renderer/src/context/client.zustand.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/context/__tests__/appVersion.test.ts b/src/renderer/src/context/__tests__/appVersion.test.ts index d37317e3..66646f7a 100644 --- a/src/renderer/src/context/__tests__/appVersion.test.ts +++ b/src/renderer/src/context/__tests__/appVersion.test.ts @@ -42,7 +42,7 @@ describe('the app version', () => { expect(useLayoutZustand.getState().version).toBe('0.0.0-test') }) - it('is asked for once, by the layout store', async () => { + it('is asked for once when both stores are loaded', async () => { stubRenderer({ isServerWindow: true }) recordApiCalls(calls) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index d76da248..4ad1612a 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -532,9 +532,11 @@ const clientZustand = useClientZustand.getState() * `CLIENT_ZUSTAND_STORAGE_KEY` as well, because persist wraps `setState`, so * the split window overwrote the shared key on every open. * - * Everything this tail does is inside the guard now. The app version was the - * exception, and it is fetched from `layout.zustand` instead, where the field - * it fills lives and where both windows reach it. + * Every call this tail makes to main is inside the guard now. The app version + * was the one outside it, and `layout.zustand` fetches it instead, beside the + * field it fills and in both windows. What is left unguarded asks main nothing: + * the repair below and the `onEvent` registrations after it. The repair's + * `setState` still writes the shared key, for the same reason `init`'s does. */ const isServerWindow = window.api.isServerWindow From 8bb279a1abbdadbb7fc1ee7b668d388051b1fb9c Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 12:00:52 +0200 Subject: [PATCH 026/137] refactor: two ways past the boundary's generics, both closed R3 of the audit's non-blocking half. Two generics whose cast is what stopped TypeScript asking, one on each side of the preload. `createIpcHandle` parses `args[0]` and calls the listener with `[result.data]`, and `PayloadSchema` names `args[0]` alone, so a channel declaring two arguments loses the second where a schema guards it and hands it over unvalidated where none does. Measured: giving `request_logout` an `args` of `[string, number]` under the old signature typechecks clean, and under `OneArgumentChannel` it is TS2345 at the `ipcHandle` call, naming the channel. Every spec entry declares `[]` or a one element tuple today, so the constraint refuses nothing that exists. `src/preload/index.ts` builds `window.api` with `Object.fromEntries` over `IPC_CHANNELS.map(snakeToCamel)` and casts the result into the mapped type, so two channels reaching one method name would drop a method with typecheck green. The lowercase segments rule beside this one is what makes that impossible rather than unlikely: every capital in a method name marks a segment boundary, so the channel is readable back off it. That was an argument, and an argument with no meter goes stale, so the conformance suite calls the real `snakeToCamel` and counts. Adding `requestLogout` beside `request_logout` turns it red along with three rules it already had. lint 0, typecheck 0, 72 files and 1450 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/__tests__/conformance.test.ts | 13 +++++++++++++ src/main/ipc.ts | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index 773af337..0b83784a 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -13,6 +13,7 @@ * line moves on the next edit above it and the symbol does not. */ import { describe, expect, it } from 'vitest' +import { snakeToCamel } from '@shared' import { globSync, readdirSync, readFileSync } from 'fs' import { join, relative } from 'path' import ts from 'typescript' @@ -851,6 +852,18 @@ describe('the lists that name a channel agree', () => { const odd = [...channels, ...events].filter((name) => !/^[a-z]+(_[a-z]+)*$/.test(name)) expect(odd).toEqual([]) }) + + // `src/preload/index.ts` builds `window.api` with `Object.fromEntries` over + // the same names and casts the result into the mapped type, so two channels + // reaching one method name would drop a method with typecheck green. The rule + // above is what makes that impossible rather than unlikely: over lowercase + // segments every capital in the method name marks a segment boundary, so the + // channel it came from can be read back off it. Asserted here because that is + // an argument, and an argument goes stale without a meter. + it('gives every channel a method name of its own', () => { + const methods = [...channels].map((channel) => snakeToCamel(channel)) + expect(new Set(methods).size).toBe(channels.size) + }) }) // diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 68d45af4..1a69e111 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -51,6 +51,21 @@ type PayloadSchema = ? ZodType : never +/** + * A channel this boundary can carry, which is one taking a single argument or + * none. + * + * `createIpcHandle` parses `args[0]` and calls the listener with + * `[result.data]`, and `PayloadSchema` names `args[0]` alone. A second + * argument is therefore dropped where a schema guards the channel and reaches + * the handler unvalidated where none does. Every spec entry declares `[]` or a + * one element tuple today, and a channel declaring two is refused here rather + * than losing one of them quietly. + */ +type OneArgumentChannel = { + [C in keyof IpcHandlerMap]: IpcHandlerMap[C]['args'] extends [] | [unknown] ? C : never +}[keyof IpcHandlerMap] + /** A channel a refusal has an answer for, which is the same rule as above. */ type RefusableChannel = { [C in keyof IpcHandlerMap]: undefined extends Awaited ? C : never @@ -115,7 +130,7 @@ const CLIENT_CHANNELS: readonly RefusableChannel[] = [ */ export const createIpcHandle = (windows: Windows) => - ( + ( channel: C, listener: IpcListener, schema?: PayloadSchema From 52a71cd25327a72bf2909fe0a3e565ba73416b5f Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 12:06:00 +0200 Subject: [PATCH 027/137] test: the one method name that is not a channel's What `/code-review` found on `bc6ff4c`, all three. The collision meter counted channels against each other and missed the name already on the object. `src/preload/index.ts` builds `api` as `{ isServerWindow, ...handlers }` with the spread last, so a channel named `is_server_window` passes the lowercase rule, collides with no other channel, and puts a function where `client.zustand`, `layout.zustand` and `server.zustand` read a boolean. Every window would then read as the split out one: no `init` in the client store, and no home button in the main window. Added to IPC_CHANNELS it turns the rule red, along with three the suite already had. `snakeToCamel` is imported into this file now, and the caller rule had its own copy of the same replace. The commit's own argument is that an unmetered copy goes stale, so that rule calls the imported one too. `OneArgumentChannel` refuses `[T?]` and `T[]` as well as a second argument, which the docblock did not say. Both refusals are wanted: `args` is read as a count, so a channel says how many it takes rather than how many it might. lint 0, typecheck 0, 72 files and 1450 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/__tests__/conformance.test.ts | 13 ++++++++----- src/main/ipc.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index 0b83784a..de94d56b 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -860,9 +860,15 @@ describe('the lists that name a channel agree', () => { // segments every capital in the method name marks a segment boundary, so the // channel it came from can be read back off it. Asserted here because that is // an argument, and an argument goes stale without a meter. + // + // `isServerWindow` is in the count because `api` is `{ isServerWindow, + // ...handlers }` with the spread last, so a channel named `is_server_window` + // would put a function where three stores read a boolean. Every window would + // read as the split out one: the client store skips `init` and the main + // window loses its home button. it('gives every channel a method name of its own', () => { const methods = [...channels].map((channel) => snakeToCamel(channel)) - expect(new Set(methods).size).toBe(channels.size) + expect(new Set([...methods, 'isServerWindow']).size).toBe(channels.size + 1) }) }) @@ -1013,10 +1019,7 @@ describe('every channel has a caller', () => { if (node.name.getText(spec) !== 'IPC_CHANNELS') return eachNode(node as unknown as ts.SourceFile, (child) => { if (!ts.isStringLiteral(child)) return - methods.set( - child.text, - child.text.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) - ) + methods.set(child.text, snakeToCamel(child.text)) }) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1a69e111..860f1d84 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -52,8 +52,8 @@ type PayloadSchema = : never /** - * A channel this boundary can carry, which is one taking a single argument or - * none. + * A channel this boundary can carry, which is one declaring `[]` or a tuple of + * exactly one. * * `createIpcHandle` parses `args[0]` and calls the listener with * `[result.data]`, and `PayloadSchema` names `args[0]` alone. A second @@ -61,6 +61,10 @@ type PayloadSchema = * the handler unvalidated where none does. Every spec entry declares `[]` or a * one element tuple today, and a channel declaring two is refused here rather * than losing one of them quietly. + * + * `[T?]` and `T[]` are refused as well, and deliberately: the arity is what a + * reader of `args` counts, so a channel says how many it takes rather than how + * many it might. */ type OneArgumentChannel = { [C in keyof IpcHandlerMap]: IpcHandlerMap[C]['args'] extends [] | [unknown] ? C : never From f7d79eb1b3fa6c772c6480c3a564138fc448e858 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 12:10:17 +0200 Subject: [PATCH 028/137] refactor: one event list per direction T1 of the audit's non-blocking half. `IPC_EVENTS` held both directions, so the four helpers that carry an event all took the same union: `windows.send` would take `open_server_window`, which no window listens for, and `onEvent` would take it too, which no window sends. Neither has anything at the far end and both typechecked. `EVENTS_TO_RENDERER` is what main pushes and `onEvent` hears. `EVENTS_TO_MAIN` is what `sendEvent` pushes and `onIpcEvent` hears, and it holds `open_server_window` alone. `IPC_EVENTS` is the two spread into one, because a payload belongs to the event's name rather than to its direction and `IpcEventPayloadMap` is keyed by all eleven. The split is what the rule underneath it needed. "Every event has a sender and a listener" over one list can only ask whether the name appears somewhere; over two it asks for the right call on the right side, and an event added to a list with nothing at either end is red rather than green. Adding `nobody_sends_this` to `EVENTS_TO_RENDERER` turns it red, and the population assertion caught the split itself: `IPC_EVENTS` is a spread now, so the old reader found no string literal in it and said so before any rule could pass on an empty set. `literalsIn` is one helper at module scope rather than the copy each of the two describes would otherwise hold. lint 0, typecheck 0, 72 files and 1453 passed. E2E 10 and 31, which are the specs that open the window this event opens: 13 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/__tests__/conformance.test.ts | 95 ++++++++++++++++++++++++++----- src/main/ipc.ts | 6 +- src/main/windows.ts | 4 +- src/renderer/src/events/index.ts | 13 +++-- src/shared/types/ipc.ts | 19 ++++++- 5 files changed, 111 insertions(+), 26 deletions(-) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index de94d56b..d1c5e168 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -56,6 +56,19 @@ const eachNode = (source: ts.SourceFile, visit: (node: ts.Node) => void): void = walk(source) } +/** The string literals of a `const` array declared in `source`. */ +const literalsIn = (source: ts.SourceFile, declaration: string): Set => { + const names = new Set() + eachNode(source, (node) => { + if (!ts.isVariableDeclaration(node)) return + if (node.name.getText(source) !== declaration) return + eachNode(node as unknown as ts.SourceFile, (child) => { + if (ts.isStringLiteral(child)) names.add(child.text) + }) + }) + return names +} + /** The module specifier of an import, or null for anything that is not one. */ const importedFrom = (node: ts.Node): string | null => ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) @@ -781,18 +794,7 @@ describe('every configured path alias is used', () => { describe('the lists that name a channel agree', () => { const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) - /** The string literals of a `const` array declared in `ipc.ts`. */ - const literalsOf = (declaration: string): Set => { - const names = new Set() - eachNode(spec, (node) => { - if (!ts.isVariableDeclaration(node)) return - if (node.name.getText(spec) !== declaration) return - eachNode(node as unknown as ts.SourceFile, (child) => { - if (ts.isStringLiteral(child)) names.add(child.text) - }) - }) - return names - } + const literalsOf = (declaration: string): Set => literalsIn(spec, declaration) /** The member names of an interface declared in `ipc.ts`. */ const membersOf = (declaration: string): Set => { @@ -809,7 +811,9 @@ describe('the lists that name a channel agree', () => { const channels = literalsOf('IPC_CHANNELS') const specced = membersOf('IpcHandlerSpec') - const events = literalsOf('IPC_EVENTS') + // `IPC_EVENTS` is the two direction lists spread into one, so it holds no + // string literal of its own to read. + const events = new Set([...literalsOf('EVENTS_TO_RENDERER'), ...literalsOf('EVENTS_TO_MAIN')]) const payloads = membersOf('IpcEventPayloadMap') /** Every channel `initIpc` registered a handler for. */ @@ -872,6 +876,71 @@ describe('the lists that name a channel agree', () => { }) }) +// +// ─── Every event has a sender and a listener ───────────────────────────────── +// +// One list held both directions, so `windows.send('open_server_window')` and +// `onEvent('open_server_window', ...)` both typechecked with nothing at the far +// end of either. `EVENTS_TO_RENDERER` and `EVENTS_TO_MAIN` are what the four +// helpers take now, and the split is what gives this rule two populations to +// read instead of one list it cannot tell apart. +// +// An event is read by the name of the call it appears in rather than by where +// the file sits, because `windows.send` is called from main's modules as +// `this._windows.send` and both spellings are the same sender. + +describe('every event has a sender and a listener', () => { + const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) + + /** The string literal first argument of every call to `name`, under `root`. */ + const firstArgumentsOf = (root: string, name: string): Set => { + const found = new Set() + for (const file of sourceFiles(join(repoRoot, root))) { + const source = parse(file) + eachNode(source, (node) => { + if (!ts.isCallExpression(node)) return + const callee = ts.isPropertyAccessExpression(node.expression) + ? node.expression.name.text + : ts.isIdentifier(node.expression) + ? node.expression.text + : null + if (callee !== name) return + const first = node.arguments[0] + if (first && ts.isStringLiteral(first)) found.add(first.text) + }) + } + return found + } + + const toRenderer = literalsIn(spec, 'EVENTS_TO_RENDERER') + const toMain = literalsIn(spec, 'EVENTS_TO_MAIN') + + const sentByMain = firstArgumentsOf('src/main', 'send') + const heardByRenderer = firstArgumentsOf('src/renderer/src', 'onEvent') + const sentByRenderer = firstArgumentsOf('src/renderer/src', 'sendEvent') + const heardByMain = firstArgumentsOf('src/main', 'onIpcEvent') + + const missing = (events: Set, found: Set): string[] => + [...events].filter((event) => !found.has(event)).sort() + + it('finds events and calls to check', () => { + expect(toRenderer.size).toBeGreaterThan(5) + expect(toMain.size).toBeGreaterThan(0) + expect(sentByMain.size).toBeGreaterThan(5) + expect(heardByRenderer.size).toBeGreaterThan(5) + }) + + it('has a sender in main and a listener in the renderer for each one main pushes', () => { + expect(missing(toRenderer, sentByMain)).toEqual([]) + expect(missing(toRenderer, heardByRenderer)).toEqual([]) + }) + + it('has a sender in the renderer and a listener in main for each one a window pushes', () => { + expect(missing(toMain, sentByRenderer)).toEqual([]) + expect(missing(toMain, heardByMain)).toEqual([]) + }) +}) + // // ─── Every channel that reaches main's client is placed ────────────────────── // diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 860f1d84..7b04756b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,7 +3,7 @@ import { ScanRegistersParameters, ConnectionConfigSchema, IpcHandlerMap, - IpcEvent, + EventToMain, IpcEventPayloadMap, formatZodError, WriteParametersSchema, @@ -317,10 +317,10 @@ export const initIpc: InitIpcFn = (app, state, client, server, windows) => { /** * Register a listener for an IPC event on the main process: - * - E must be one of the keys in IpcEvent. + * - E must be one of `EVENTS_TO_MAIN`, which is the direction this hears. * - listener receives the IpcMainEvent plus the payload tuple defined in IpcEventPayloadMap[E]. */ -export function onIpcEvent( +export function onIpcEvent( event: E, listener: (event: IpcMainEvent, ...args: IpcEventPayloadMap[E]) => void ): void { diff --git a/src/main/windows.ts b/src/main/windows.ts index c4d4ab4b..d89ce662 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,4 +1,4 @@ -import { IpcEvent, IpcEventPayloadMap } from '@shared' +import { EventToRenderer, IpcEventPayloadMap } from '@shared' import { BrowserWindow, WebContents } from 'electron' interface WindowsObject { @@ -53,7 +53,7 @@ export class Windows { * first, so without it a stale main handle is what the server window's * `window_update` goes missing behind. */ - public send = ( + public send = ( event: E, payload: IpcEventPayloadMap[E][0], to?: IpcEventTarget diff --git a/src/renderer/src/events/index.ts b/src/renderer/src/events/index.ts index 300e0dc1..ea77a1d8 100644 --- a/src/renderer/src/events/index.ts +++ b/src/renderer/src/events/index.ts @@ -1,25 +1,28 @@ // renderer/ipcEvents.ts import { IpcRendererListener } from '@electron-toolkit/preload' -import type { IpcEvent, IpcEventPayloadMap } from '@shared' +import type { EventToMain, EventToRenderer, IpcEventPayloadMap } from '@shared' /** * Send an IPC event from the renderer to the main process. - * - E must be one of the keys in IpcEvent. + * - E must be one of `EVENTS_TO_MAIN`, which is the direction this goes. * - args must match the tuple defined in IpcEventPayloadMap[E]. */ -export const sendEvent = (event: E, ...args: IpcEventPayloadMap[E]): void => { +export const sendEvent = ( + event: E, + ...args: IpcEventPayloadMap[E] +): void => { window.electron.ipcRenderer.send(event, ...args) } /** * Register a listener for an IPC event in the renderer. - * - E must be one of the keys in IpcEvent. + * - E must be one of `EVENTS_TO_RENDERER`, which is the direction this hears. * - listener receives the payload tuple defined in IpcEventPayloadMap[E]. * * Returns a function that removes this specific listener. */ -export const onEvent = ( +export const onEvent = ( event: E, listener: (...args: IpcEventPayloadMap[E]) => void ): (() => void) => { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 5993e6dc..10af8c3b 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -378,9 +378,16 @@ export type IpcHandlerMap = { } /** - * Events emitted + * The events main pushes to a window. + * + * `windows.send` takes one of these and `onEvent` in the renderer listens for + * one, so the pair that carries an event is named by the list it is in. They + * sat in one list with the other direction, and a single list makes + * `windows.send('open_server_window', undefined)` and + * `onEvent('open_server_window', ...)` both typecheck, neither of which has + * anything at the far end. */ -export const IPC_EVENTS = [ +export const EVENTS_TO_RENDERER = [ 'backend_message', 'client_state', 'register_data', @@ -389,11 +396,17 @@ export const IPC_EVENTS = [ 'scan_progress', 'register_value', 'window_update', - 'open_server_window', 'address_groups', 'rtu_server_status' ] as const +/** The events a window pushes to main. `sendEvent` there, `onIpcEvent` here. */ +export const EVENTS_TO_MAIN = ['open_server_window'] as const + +export const IPC_EVENTS = [...EVENTS_TO_RENDERER, ...EVENTS_TO_MAIN] as const + +export type EventToRenderer = (typeof EVENTS_TO_RENDERER)[number] +export type EventToMain = (typeof EVENTS_TO_MAIN)[number] export type IpcEvent = (typeof IPC_EVENTS)[number] export interface IpcEventPayloadMap { From 7ee27df6ccf43bfb6e9428b372c8617af8cefd7a Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 12:16:41 +0200 Subject: [PATCH 029/137] refactor: the event list nothing reads, and the send that is not one What `/code-review` found on `ce65858`, both of them against the meter rather than the code. `IPC_EVENTS` and `IpcEvent` had no reader left. The three helpers that named `IpcEvent` moved to a direction list in that commit and the conformance rule reads both lists, so what was left is the name a contributor would reach for first: an event added to `IPC_EVENTS` would widen `IpcEvent`, require no payload entry, require no sender and no listener, and stay green through all 47 rules. That is the dead channel shape CONTRIBUTING already legislates against, reopened on the event side, so both exports are gone. The reason the commit gave for keeping them was wrong: `IpcEventPayloadMap` is a hand written interface and never referenced `IPC_EVENTS`. The sender half matched any callee named `send`, and `send` is not this project's word. Measured with a `probe.send('nobody_sends_this')` in `windows.ts` and a listener in the renderer: the bare matcher passed all 47, and matching the receiver as well turns it red naming the event. `this` is in the pattern for `Windows._sendUpdate`, which calls its own `send`; main's other two senders spell it `this._windows.send`. lint 0, typecheck 0, 72 files and 1453 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- src/__tests__/conformance.test.ts | 25 ++++++++++++++++++------- src/shared/types/ipc.ts | 3 --- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index d1c5e168..ce66d356 100644 --- a/src/__tests__/conformance.test.ts +++ b/src/__tests__/conformance.test.ts @@ -811,8 +811,7 @@ describe('the lists that name a channel agree', () => { const channels = literalsOf('IPC_CHANNELS') const specced = membersOf('IpcHandlerSpec') - // `IPC_EVENTS` is the two direction lists spread into one, so it holds no - // string literal of its own to read. + // One list per direction, and an event is in exactly one of them. const events = new Set([...literalsOf('EVENTS_TO_RENDERER'), ...literalsOf('EVENTS_TO_MAIN')]) const payloads = membersOf('IpcEventPayloadMap') @@ -892,19 +891,31 @@ describe('the lists that name a channel agree', () => { describe('every event has a sender and a listener', () => { const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) - /** The string literal first argument of every call to `name`, under `root`. */ - const firstArgumentsOf = (root: string, name: string): Set => { + /** + * The string literal first argument of every call to `name`, under `root`. + * + * `on` says which object the call has to be on, because `send` is a name the + * platform uses too: a `socket.send('register_data', ...)` added to main would + * otherwise read as the event being pushed to a window. The other three names + * are this project's own, so they are matched bare. + * + * Bare `this` is in the pattern for `Windows._sendUpdate`, which calls its own + * `send`. Main's other two senders spell it `this._windows.send`. + */ + const firstArgumentsOf = (root: string, name: string, on?: RegExp): Set => { const found = new Set() for (const file of sourceFiles(join(repoRoot, root))) { const source = parse(file) eachNode(source, (node) => { if (!ts.isCallExpression(node)) return - const callee = ts.isPropertyAccessExpression(node.expression) - ? node.expression.name.text + const property = ts.isPropertyAccessExpression(node.expression) ? node.expression : null + const callee = property + ? property.name.text : ts.isIdentifier(node.expression) ? node.expression.text : null if (callee !== name) return + if (on && !(property && on.test(property.expression.getText(source)))) return const first = node.arguments[0] if (first && ts.isStringLiteral(first)) found.add(first.text) }) @@ -915,7 +926,7 @@ describe('every event has a sender and a listener', () => { const toRenderer = literalsIn(spec, 'EVENTS_TO_RENDERER') const toMain = literalsIn(spec, 'EVENTS_TO_MAIN') - const sentByMain = firstArgumentsOf('src/main', 'send') + const sentByMain = firstArgumentsOf('src/main', 'send', /^(?:this|(?:this\.)?_?windows)$/i) const heardByRenderer = firstArgumentsOf('src/renderer/src', 'onEvent') const sentByRenderer = firstArgumentsOf('src/renderer/src', 'sendEvent') const heardByMain = firstArgumentsOf('src/main', 'onIpcEvent') diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 10af8c3b..7eed0603 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -403,11 +403,8 @@ export const EVENTS_TO_RENDERER = [ /** The events a window pushes to main. `sendEvent` there, `onIpcEvent` here. */ export const EVENTS_TO_MAIN = ['open_server_window'] as const -export const IPC_EVENTS = [...EVENTS_TO_RENDERER, ...EVENTS_TO_MAIN] as const - export type EventToRenderer = (typeof EVENTS_TO_RENDERER)[number] export type EventToMain = (typeof EVENTS_TO_MAIN)[number] -export type IpcEvent = (typeof IPC_EVENTS)[number] export interface IpcEventPayloadMap { ['backend_message']: [BackendMessage] From b4388ff5446f51b07185db4b93bba6a4b80f4161 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 12:17:45 +0200 Subject: [PATCH 030/137] docs: the two rules the event split added CONTRIBUTING names every rule the conformance suite asserts, and T1 added two it did not name. An event is in the list for the direction it travels, and both ends of that direction exist. The method names `window.api` carries are counted against each other and against `isServerWindow`. The rule above them named `IPC_EVENTS`, which is gone. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CONTRIBUTING.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 597f3e97..0f812d6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,13 +129,23 @@ counts as carrying it. **The three lists that name a channel agree, and so do the two that name an event.** `IPC_CHANNELS`, `IpcHandlerSpec` and the `ipcHandle` calls in -`main/ipc.ts`; `IPC_EVENTS` and `IpcEventPayloadMap`. Typecheck catches one -direction only: a channel in `IPC_CHANNELS` and not in the spec fails to index, +`main/ipc.ts`; `EVENTS_TO_RENDERER` and `EVENTS_TO_MAIN` together against +`IpcEventPayloadMap`. Typecheck catches one direction only: a channel in +`IPC_CHANNELS` and not in the spec fails to index, while a channel in the spec and not in `IPC_CHANNELS` is dropped by the mapped type in silence, and one in both with no handler rejects at runtime instead. A channel and an event name is lowercase segments, because that is where `snakeToCamel` and `CamelCase` cannot disagree: `set_2wire` would be the method -`set_2wire` and the type `set2wire`. +`set_2wire` and the type `set2wire`. Over those segments `snakeToCamel` is +injective, and the method names are counted against each other and against +`isServerWindow`, which is the one key on `window.api` that is not a channel's. + +**An event is in the list for the direction it travels, and both ends exist.** +`EVENTS_TO_RENDERER` is what `windows.send` pushes and `onEvent` hears; +`EVENTS_TO_MAIN` is what `sendEvent` pushes and `onIpcEvent` hears. One list for +both directions typechecked a send nothing listens for, and left "every event +has a sender and a listener" nothing to measure. A `send` counts only on +`windows`, because `send` is a name the platform uses too. **Every channel that carries an object declares a schema.** TypeScript covers a bare primitive, and a channel taking no argument has nothing to guard. The rest From 31c37310e688eaf48c0494e8f9e982fc4fe473ae Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 13:05:41 +0200 Subject: [PATCH 031/137] fix: the range a scan asks for, and the field holding none R6 of the audit's non-blocking half. Both scan dialogs built a range out of two independently bounded fields and cleared state before they asked, so a refusal at the boundary cost the user what was on screen and gave nothing back. Start caps at 255 and Count at 256, so 200 and 100 named unit id 299, which `ScanUnitIDParametersSchema` refuses. Address and Length each stop at 65535, so 60000 and 10000 named address 69999, which `RegisterAddressSchema` refuses. Both ranges are clamped where the request is built, which is where the pair is finally known, and the two ceilings are `MAX_UNIT_ID` and `MAX_REGISTER_ADDRESS` in `ranges.ts`, which the schemas read as well. Clearing a field stores `Number('') === 0`. A count or a length of none gives a range that ends before it starts, which both schemas take and neither scan loop enters, so the dialog flipped scanning on and off and said nothing. A unit id scan's Length and a register scan's Chunk Size of none are refused instead. All four floors sit on the blur, where `clampScanTimeout` put the timeout's. Ten unit tests render the two dialogs, three of each five red without the clamp. `12-scan-unitids` scans 200 through 255 end to end, and takes the row rather than the button: the button reads Start Scanning both before main takes the request and after the scan is done. lint 0, typecheck 0, 74 files and 1463 passed. E2E 06, 11 and 12: 121 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 8 ++ e2e/specs/01-main/12-scan-unitids.spec.ts | 27 +++++ .../client/ScanRegisters/ScanRegisters.tsx | 27 +++-- .../__tests__/ScanRegisters.test.tsx | 105 ++++++++++++++++++ .../client/ScanUnitIds/ScanUnitIds.tsx | 32 ++++-- .../__tests__/ScanUnitIds.test.tsx | 103 +++++++++++++++++ src/shared/types/ranges.ts | 11 +- 7 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx create mode 100644 src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6e03fc..3be20944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A scan that reaches past the last unit ID or register now stops there.** A + Start of 200 with a Count of 100 asked for unit ID 299, and an address of + 60000 with a Length of 10000 asked for register 69999. Neither exists, so the + scan was refused before it began: the results you had were cleared, the grid + was emptied, and nothing said why. Both scans now run up to the last one there + is. Clearing the Count, Length or Chunk Size field left a zero behind, which + scanned nothing in the same silence, and now goes back to 1 when you leave the + field. - **The transaction log now shows the address of a coil read and of a write.** The Addr column was blank for every coil read, every discrete input read and every write Modbux sends, so a log of coil traffic said nothing about which diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index bb4b237d..c0923d97 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -352,6 +352,33 @@ test.describe.serial('Scan Unit IDs', () => { await expect(mainPage.getByTestId('scan-unitid-count-input').locator('input')).toBeEnabled() }) + // ─── A range wider than the unit ids there are ──────────────────── + + // Start caps at 255 and Count at 256, so the pair named unit id 299 and + // `ScanUnitIDParametersSchema` refused the request. The dialog had cleared + // its results by then, so the scan turned up nothing and said nothing. + test('a count reaching past the last unit id scans up to it', async ({ mainPage }) => { + test.setTimeout(60000) + + const startUnit = mainPage.getByTestId('scan-start-unitid-input').locator('input') + await startUnit.fill('200') + const count = mainPage.getByTestId('scan-unitid-count-input').locator('input') + await count.fill('100') + + await mainPage.getByTestId('scan-unitid-start-stop-btn').click() + + // The row rather than the button: the button reads Start Scanning both + // before main takes the request and after it is done, so a refused request + // satisfies it without a scan. + const modal = mainPage.locator('.MuiModal-root') + await expect(modal.locator('.MuiDataGrid-row[data-id="200"]')).toBeVisible({ timeout: 60000 }) + + await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( + 'Start Scanning', + { timeout: 60000 } + ) + }) + // ─── Address base toggle ────────────────────────────────────────── test('address base toggle on address field switches between 0 and 1', async ({ mainPage }) => { diff --git a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx index d9e112f2..7ae699a5 100644 --- a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx @@ -20,7 +20,7 @@ import { ScanTimeoutField } from '../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' -import { maxReadQuantity } from '@shared' +import { MAX_REGISTER_ADDRESS, maxReadQuantity } from '@shared' import { useScanRegistersZustand } from './scanRegisters.zustand' // @@ -76,10 +76,14 @@ const AddressField = meme((): JSX.Element => { // Scan Length field const ScanLengthField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) - const scanLength = useScanRegistersZustand((z) => String(z.scanLength)) + const scanLength = useScanRegistersZustand((z) => z.scanLength) const setScanLength = useScanRegistersZustand.getState().setScanLength + // Clearing the field stores `Number('') === 0`, and a scan of no addresses + // ran its loop zero times while the dialog flipped scanning on and off. The + // floor sits on the blur, where `clampScanTimeout` put the timeout's for a + // measured reason: a bound on the mask rewrites what you type. return ( { variant="outlined" size="small" sx={{ width: 90 }} - value={scanLength} + value={String(scanLength)} + onBlur={() => setScanLength(String(Math.max(1, scanLength)))} data-testid="scan-length-input" slotProps={{ input: { @@ -104,10 +109,12 @@ const ScanLengthField = meme((): JSX.Element => { // Chunk Size field const ChunkSizeField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningRegisters) - const chunkSize = useScanRegistersZustand((z) => String(z.chunkSize)) + const chunkSize = useScanRegistersZustand((z) => z.chunkSize) const type = useClientZustand((z) => z.registerConfig.type) // The protocol's pair, stated once in `ranges.ts`: this field computed it by - // hand and the unit id scan's Length field computed nothing at all. + // hand and the unit id scan's Length field computed nothing at all. The floor + // is the blur's, the way Length's is: `ScanRegistersParametersSchema` takes a + // positive length. const max = maxReadQuantity([type]) const setChunkSize = useScanRegistersZustand.getState().setChunkSize @@ -119,7 +126,8 @@ const ChunkSizeField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 90 }} - value={chunkSize} + value={String(chunkSize)} + onBlur={() => setChunkSize(String(Math.max(1, chunkSize)))} data-testid="scan-chunk-size-input" slotProps={{ input: { @@ -207,8 +215,13 @@ const ScanButton = meme((): JSX.Element => { const { address, scanLength, chunkSize, timeout } = scanRegistersZustand + // Clamped where the request is built rather than left to the boundary. + // Address and Length each stop at 65535, so 60000 and 10000 name address + // 69999, which `RegisterAddressSchema` refuses. Read configuration is off + // and the grid is empty by then, and `advancedMode` is persisted, so that + // write outlives the launch. await window.api.scanRegisters({ - addressRange: [address, address + scanLength - 1], + addressRange: [address, Math.min(MAX_REGISTER_ADDRESS, address + scanLength - 1)], length: chunkSize, timeout }) diff --git a/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx b/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx new file mode 100644 index 00000000..0ce3b5a0 --- /dev/null +++ b/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The root store registers IPC listeners (window.electron.ipcRenderer) and runs +// init() (window.api.*) at import time. Stub both before the store is imported, +// and keep the one channel this file reads back. +const stub = vi.hoisted(() => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const scanRegisters = vi.fn() + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { on: () => () => {}, send: () => {}, invoke: async () => undefined } + } + w.api = new Proxy( + { scanRegisters }, + { get: (target, name) => Reflect.get(target, name) ?? (() => Promise.resolve(undefined)) } + ) + return { scanRegisters } +}) + +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { defaultClientState, ScanRegistersParametersSchema } from '@shared' +import ScanRegisters from '../ScanRegisters' +import { useScanRegistersZustand } from '../scanRegisters.zustand' + +const payload = async (): Promise => { + await waitFor(() => expect(stub.scanRegisters).toHaveBeenCalled()) + return stub.scanRegisters.mock.calls[0]?.[0] +} + +const input = (testId: string): HTMLElement => + within(screen.getByTestId(testId)).getByRole('textbox') + +beforeEach(() => { + stub.scanRegisters.mockClear() + useClientZustand.setState({ + ready: true, + clientState: { ...defaultClientState } + } as never) + useScanRegistersZustand.setState({ + open: true, + address: 60000, + scanLength: 10000, + chunkSize: 100, + timeout: 500 + }) +}) + +// Address and Length are masked to 65535 apiece, so the pair names an address +// neither field can hold. The dialog turns read configuration off, empties the +// grid and turns advanced mode on before it asks, and advanced mode is +// persisted, so a payload the boundary refuses outlives the launch. +describe('ScanRegisters asks for a range the boundary takes', () => { + it('stops the range at the last register address', async () => { + render() + + fireEvent.click(screen.getByTestId('scan-start-stop-btn')) + + expect(await payload()).toMatchObject({ addressRange: [60000, 65535] }) + expect(ScanRegistersParametersSchema.safeParse(await payload()).success).toBe(true) + }) + + it('leaves a range that fits where it is', async () => { + useScanRegistersZustand.setState({ address: 0, scanLength: 1000 }) + + render() + + fireEvent.click(screen.getByTestId('scan-start-stop-btn')) + + expect(await payload()).toMatchObject({ addressRange: [0, 999] }) + }) +}) + +// Clearing a field stores `Number('') === 0`, which is what the store holds +// here. A length of none walked no addresses and a chunk size of none is +// refused by the schema, and both left the dialog flipping scanning on and off +// with nothing said. +describe('ScanRegisters puts a cleared field back on the blur', () => { + it('walks one address rather than none', () => { + useScanRegistersZustand.setState({ scanLength: 0 }) + + render() + fireEvent.blur(input('scan-length-input')) + + expect(useScanRegistersZustand.getState().scanLength).toBe(1) + }) + + it('asks for one register per chunk rather than none', () => { + useScanRegistersZustand.setState({ chunkSize: 0 }) + + render() + fireEvent.blur(input('scan-chunk-size-input')) + + expect(useScanRegistersZustand.getState().chunkSize).toBe(1) + }) + + it('leaves a field that holds a number where it is', () => { + render() + fireEvent.blur(input('scan-length-input')) + + expect(useScanRegistersZustand.getState().scanLength).toBe(10000) + }) +}) diff --git a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx index 757fec95..4b98596a 100644 --- a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -12,7 +12,7 @@ import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInpu import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useClientZustand } from '@renderer/context/client.zustand' -import { maxReadQuantity, RegisterType } from '@shared' +import { MAX_UNIT_ID, maxReadQuantity, RegisterType } from '@shared' import { ElementType, useCallback, useMemo } from 'react' import useScanUnitIdColumns from './_columns' import { useScanUnitIdZustand } from './scanUnitIds.zustand' @@ -52,10 +52,14 @@ const StartUnitIdField = meme((): JSX.Element => { // Count field const CountField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) - const count = useScanUnitIdZustand((z) => String(z.count)) + const count = useScanUnitIdZustand((z) => z.count) const setCount = useScanUnitIdZustand.getState().setCount + // Clearing the field stores `Number('') === 0`, and a count of none scanned + // nothing while the dialog flipped scanning on and off. The floor sits on the + // blur, where `clampScanTimeout` put the timeout's for a measured reason: a + // bound on the mask rewrites what you type. return ( { variant="outlined" size="small" sx={{ width: 80 }} - value={count} + value={String(count)} + onBlur={() => setCount(String(Math.max(1, count)))} data-testid="scan-unitid-count-input" slotProps={{ input: { @@ -100,12 +105,13 @@ const AddressField = meme((): JSX.Element => { // Length field const LengthField = meme((): JSX.Element => { const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) - const length = useScanUnitIdZustand((z) => String(z.length)) + const length = useScanUnitIdZustand((z) => z.length) // The field passed no `max`, so `UintInput`'s default of 65535 was typeable // and went to the socket as the quantity. `maxReadQuantity` is the protocol's // own pair, and it reads the types selected because one length goes out for - // every one of them. + // every one of them. The floor is the blur's, the way Count's is: + // `ScanUnitIDParametersSchema` takes a positive length. const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const max = maxReadQuantity(registerTypes) @@ -118,7 +124,8 @@ const LengthField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 60 }} - value={length} + value={String(length)} + onBlur={() => setLength(String(Math.max(1, length)))} data-testid="scan-unitid-length-input" slotProps={{ input: { @@ -220,15 +227,16 @@ const ScanButton = meme((): JSX.Element => { const { address, length, startUnitId, count, registerTypes, timeout } = scanUnitIdZustand - // Clamped where the request is built rather than left to the boundary. The - // field masks to the same ceiling, and a length typed under one set of - // register types stays in the store when another is selected, so this is - // the moment the pair is finally known. The boundary refuses what it is - // given and the results are already cleared by then. + // Clamped where the request is built rather than left to the boundary. Each + // field masks to its own ceiling and the request is bounded by a pair: a + // length typed under one set of register types stays in the store when + // another is selected, and Start caps at 255 with Count at 256, so 200 and + // 100 name unit id 299. The boundary refuses what it is given and the + // results are already cleared by then. window.api.scanUnitIds({ address, length: Math.min(length, maxReadQuantity(registerTypes)), - range: [startUnitId, startUnitId + count - 1], + range: [startUnitId, Math.min(MAX_UNIT_ID, startUnitId + count - 1)], registerTypes, timeout }) diff --git a/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx new file mode 100644 index 00000000..c5141e0c --- /dev/null +++ b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The root store registers IPC listeners (window.electron.ipcRenderer) and runs +// init() (window.api.*) at import time. Stub both before the store is imported, +// and keep the one channel this file reads back. +const stub = vi.hoisted(() => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const scanUnitIds = vi.fn() + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { on: () => () => {}, send: () => {}, invoke: async () => undefined } + } + w.api = new Proxy( + { scanUnitIds }, + { get: (target, name) => Reflect.get(target, name) ?? (() => Promise.resolve(undefined)) } + ) + return { scanUnitIds } +}) + +import { fireEvent, render, screen, within } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { defaultClientState, ScanUnitIDParametersSchema } from '@shared' +import ScanUnitIds from '../ScanUnitIds' +import { useScanUnitIdZustand } from '../scanUnitIds.zustand' + +const payload = (): unknown => stub.scanUnitIds.mock.calls[0]?.[0] + +const input = (testId: string): HTMLElement => + within(screen.getByTestId(testId)).getByRole('textbox') + +beforeEach(() => { + stub.scanUnitIds.mockClear() + useClientZustand.setState({ + ready: true, + clientState: { ...defaultClientState } + } as never) + useScanUnitIdZustand.setState({ + open: true, + address: 0, + length: 2, + startUnitId: 200, + count: 100, + registerTypes: ['holding_registers'], + timeout: 500 + }) +}) + +// Start caps at 255 and Count at 256, so the pair names a unit id neither +// field can hold. The dialog clears its results before it asks, so a payload +// the boundary refuses costs the user the results and gives back nothing. +describe('ScanUnitIds asks for a range the boundary takes', () => { + it('stops the range at the last unit id', () => { + render() + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ range: [200, 255] }) + expect(ScanUnitIDParametersSchema.safeParse(payload()).success).toBe(true) + }) + + it('leaves a range that fits where it is', () => { + useScanUnitIdZustand.setState({ startUnitId: 1, count: 6 }) + + render() + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ range: [1, 6] }) + }) +}) + +// Clearing a field stores `Number('') === 0`, which is what the store holds +// here. A count of none scanned nothing and a length of none is refused by the +// schema, and both left the dialog flipping scanning on and off with nothing +// said. +describe('ScanUnitIds puts a cleared field back on the blur', () => { + it('scans one unit id rather than none', () => { + useScanUnitIdZustand.setState({ count: 0 }) + + render() + fireEvent.blur(input('scan-unitid-count-input')) + + expect(useScanUnitIdZustand.getState().count).toBe(1) + }) + + it('asks for one register rather than none', () => { + useScanUnitIdZustand.setState({ length: 0 }) + + render() + fireEvent.blur(input('scan-unitid-length-input')) + + expect(useScanUnitIdZustand.getState().length).toBe(1) + }) + + it('leaves a field that holds a number where it is', () => { + render() + fireEvent.blur(input('scan-unitid-count-input')) + + expect(useScanUnitIdZustand.getState().count).toBe(100) + }) +}) diff --git a/src/shared/types/ranges.ts b/src/shared/types/ranges.ts index f60619ba..130d1221 100644 --- a/src/shared/types/ranges.ts +++ b/src/shared/types/ranges.ts @@ -17,8 +17,11 @@ import { isNumberRegister, RegisterType } from './register' * server needs a port between 1 and 65535", which is worth more than the * boundary's "Invalid request, nothing was changed". */ -export const RegisterAddressSchema = z.number().int().min(0).max(65535) -export const UnitIdSchema = z.number().int().min(0).max(255) +export const MAX_REGISTER_ADDRESS = 65535 +export const MAX_UNIT_ID = 255 + +export const RegisterAddressSchema = z.number().int().min(0).max(MAX_REGISTER_ADDRESS) +export const UnitIdSchema = z.number().int().min(0).max(MAX_UNIT_ID) export const PortSchema = z.number().int().min(0).max(65535) /** @@ -60,4 +63,6 @@ export const maxReadQuantity = (registerTypes: readonly RegisterType[]): number export const RegisterAddressKeySchema = z .string() .regex(/^\d+$/) - .refine((key) => Number(key) <= 65535, { message: 'Number must be less than or equal to 65535' }) + .refine((key) => Number(key) <= MAX_REGISTER_ADDRESS, { + message: `Number must be less than or equal to ${MAX_REGISTER_ADDRESS}` + }) From 70160f26cdafd9414d17031eec824344fa395acd Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 13:18:17 +0200 Subject: [PATCH 032/137] fix: the floor a field never gets to apply, and the pair the mask keeps What `/code-review` found on `8fa47c3`, two of them reachable and one in the prose. The floors were on the blur alone, and a field can unmount without one: Escape closes the dialog while the input has focus, so a cleared Count, Length or Chunk Size keeps its `Number('') === 0` and the next press sends it. Both request sites hold the floor now, beside the ceiling that commit added. The blur stays, because that is where the user sees the correction. The comment said a length typed under one set of register types stays in the store when another is selected. Measured, it does not: with the field mounted, `setRegisterTypes(['holding_registers'])` rewrites 2000 to 125 through the mask. With the dialog closed the store keeps 2000, and the register types are only selectable from inside the dialog. So `maxReadQuantity` at the request restates a bound the mask already keeps, and the comment says that instead. One test per dialog, both red against `8fa47c3`. lint 0, typecheck 0, 74 files and 1465 passed. E2E 06, 11 and 12: 121 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../client/ScanRegisters/ScanRegisters.tsx | 32 +++++++++++++------ .../__tests__/ScanRegisters.test.tsx | 13 ++++++++ .../client/ScanUnitIds/ScanUnitIds.tsx | 30 ++++++++++------- .../__tests__/ScanUnitIds.test.tsx | 13 ++++++++ 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx index 7ae699a5..6a199260 100644 --- a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx @@ -82,8 +82,9 @@ const ScanLengthField = meme((): JSX.Element => { // Clearing the field stores `Number('') === 0`, and a scan of no addresses // ran its loop zero times while the dialog flipped scanning on and off. The - // floor sits on the blur, where `clampScanTimeout` put the timeout's for a - // measured reason: a bound on the mask rewrites what you type. + // floor is on the blur, where `clampScanTimeout` put the timeout's for a + // measured reason: a bound on the mask rewrites what you type. `ScanButton` + // holds the same floor for the field that never gets a blur. return ( { const type = useClientZustand((z) => z.registerConfig.type) // The protocol's pair, stated once in `ranges.ts`: this field computed it by // hand and the unit id scan's Length field computed nothing at all. The floor - // is the blur's, the way Length's is: `ScanRegistersParametersSchema` takes a - // positive length. + // is on the blur, the way Length's is: `ScanRegistersParametersSchema` takes + // a positive length. const max = maxReadQuantity([type]) const setChunkSize = useScanRegistersZustand.getState().setChunkSize @@ -215,14 +216,25 @@ const ScanButton = meme((): JSX.Element => { const { address, scanLength, chunkSize, timeout } = scanRegistersZustand - // Clamped where the request is built rather than left to the boundary. + // Clamped where the request is built rather than left to the boundary. Read + // configuration is off and the grid is empty by the time the boundary + // answers, and `advancedMode` is persisted, so that write outlives the + // launch. + // // Address and Length each stop at 65535, so 60000 and 10000 name address - // 69999, which `RegisterAddressSchema` refuses. Read configuration is off - // and the grid is empty by then, and `advancedMode` is persisted, so that - // write outlives the launch. + // 69999, which `RegisterAddressSchema` refuses. + // + // The floors are here as well as on each field's blur, because Escape + // closes the dialog and the field unmounts without one, leaving the + // `Number('') === 0` of a cleared field in the store. A length of none + // gives a range ending before it starts, which the schema takes and the + // scan loop never enters. await window.api.scanRegisters({ - addressRange: [address, Math.min(MAX_REGISTER_ADDRESS, address + scanLength - 1)], - length: chunkSize, + addressRange: [ + address, + Math.min(MAX_REGISTER_ADDRESS, address + Math.max(1, scanLength) - 1) + ], + length: Math.max(1, chunkSize), timeout }) }, [scanning]) diff --git a/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx b/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx index 0ce3b5a0..2e4325c8 100644 --- a/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx +++ b/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx @@ -62,6 +62,19 @@ describe('ScanRegisters asks for a range the boundary takes', () => { expect(ScanRegistersParametersSchema.safeParse(await payload()).success).toBe(true) }) + // Escape closes the dialog and the field unmounts with it, so the blur that + // puts a cleared field back never fires and the request carries the zero. + it('asks for one of each when a cleared field never got its blur', async () => { + useScanRegistersZustand.setState({ address: 100, scanLength: 0, chunkSize: 0 }) + + render() + + fireEvent.click(screen.getByTestId('scan-start-stop-btn')) + + expect(await payload()).toMatchObject({ addressRange: [100, 100], length: 1 }) + expect(ScanRegistersParametersSchema.safeParse(await payload()).success).toBe(true) + }) + it('leaves a range that fits where it is', async () => { useScanRegistersZustand.setState({ address: 0, scanLength: 1000 }) diff --git a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx index 4b98596a..a097a9cc 100644 --- a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -57,9 +57,10 @@ const CountField = meme((): JSX.Element => { const setCount = useScanUnitIdZustand.getState().setCount // Clearing the field stores `Number('') === 0`, and a count of none scanned - // nothing while the dialog flipped scanning on and off. The floor sits on the + // nothing while the dialog flipped scanning on and off. The floor is on the // blur, where `clampScanTimeout` put the timeout's for a measured reason: a - // bound on the mask rewrites what you type. + // bound on the mask rewrites what you type. `ScanButton` holds the same floor + // for the field that never gets a blur. return ( { // The field passed no `max`, so `UintInput`'s default of 65535 was typeable // and went to the socket as the quantity. `maxReadQuantity` is the protocol's // own pair, and it reads the types selected because one length goes out for - // every one of them. The floor is the blur's, the way Count's is: + // every one of them. The floor is on the blur, the way Count's is: // `ScanUnitIDParametersSchema` takes a positive length. const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const max = maxReadQuantity(registerTypes) @@ -227,16 +228,23 @@ const ScanButton = meme((): JSX.Element => { const { address, length, startUnitId, count, registerTypes, timeout } = scanUnitIdZustand - // Clamped where the request is built rather than left to the boundary. Each - // field masks to its own ceiling and the request is bounded by a pair: a - // length typed under one set of register types stays in the store when - // another is selected, and Start caps at 255 with Count at 256, so 200 and - // 100 name unit id 299. The boundary refuses what it is given and the - // results are already cleared by then. + // Clamped where the request is built rather than left to the boundary, + // because the boundary refuses what it is given and the results are already + // cleared by then. + // + // Each field masks to its own ceiling and the request is bounded by a pair: + // Start caps at 255 and Count at 256, so 200 and 100 name unit id 299. The + // length is the mask's, measured: selecting another register type rewrites + // the mounted field, and 2000 typed under coils reads 125 under holding + // registers. `maxReadQuantity` restates that bound here. + // + // The floors are here as well as on each field's blur, because Escape + // closes the dialog and the field unmounts without one, leaving the + // `Number('') === 0` of a cleared field in the store. window.api.scanUnitIds({ address, - length: Math.min(length, maxReadQuantity(registerTypes)), - range: [startUnitId, Math.min(MAX_UNIT_ID, startUnitId + count - 1)], + length: Math.min(Math.max(1, length), maxReadQuantity(registerTypes)), + range: [startUnitId, Math.min(MAX_UNIT_ID, startUnitId + Math.max(1, count) - 1)], registerTypes, timeout }) diff --git a/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx index c5141e0c..5962bb22 100644 --- a/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx @@ -60,6 +60,19 @@ describe('ScanUnitIds asks for a range the boundary takes', () => { expect(ScanUnitIDParametersSchema.safeParse(payload()).success).toBe(true) }) + // Escape closes the dialog and the field unmounts with it, so the blur that + // puts a cleared field back never fires and the request carries the zero. + it('asks for one of each when a cleared field never got its blur', () => { + useScanUnitIdZustand.setState({ startUnitId: 0, count: 0, length: 0 }) + + render() + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ range: [0, 0], length: 1 }) + expect(ScanUnitIDParametersSchema.safeParse(payload()).success).toBe(true) + }) + it('leaves a range that fits where it is', () => { useScanUnitIdZustand.setState({ startUnitId: 1, count: 6 }) From 9663207f42d5304c4efcb1bb116cc1a7e3d43bb2 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 13:25:50 +0200 Subject: [PATCH 033/137] fix: one read, two ceilings, each in the wrong place R7 of the audit's non-blocking half. What one response carries and how much is there are two questions, and a read is bounded by both. `LengthInput` was `max={Math.min(125, max)}`, so the `max` a caller passed could only lower it. 125 is FC03 and FC04; FC01 and FC02 answer 2000, so a coil read stopped at a sixteenth of what the protocol allows. The field takes the pair it is given now, and `RegisterConfig` computes it: `maxReadQuantity` by register type, against `registersFrom` by address. `registersFrom` is the second, and `_read` had neither. Its groups come from the mapping or from the toolbar, and neither is bounded by the range it reads from: an int64 is four registers wide wherever it is mapped, so one at 65534 asked for 65534 through 65537, and `RegisterConfigSchema` takes a length of 65535 at any address, which a persisted store carries into the toolbar's group. Each group stops at the last register there is, the way `_scanRegister` already did. `_scanRegister`'s own `65536 - address` reads `registersFrom` now. Five tests: `registersFrom` over four addresses, the two `_read` sources, and the field at both ceilings. Red without the fix: both `_read` tests, and the 2000 of coils. The two that stay green are the pair, because 125 and the address ceiling were already held. lint 0, typecheck 0, 74 files and 1474 passed. E2E 06, 15 and 21: 126 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 8 +++ .../modules/__tests__/modbusClient.test.ts | 35 ++++++++++++ src/main/modules/modbusClient.ts | 18 ++++++- .../client/RegisterConfig/RegisterConfig.tsx | 11 +++- .../__tests__/RegisterConfig.test.tsx | 53 ++++++++++++++++++- .../components/shared/inputs/LengthInput.tsx | 13 ++++- src/shared/__tests__/schemas.test.ts | 21 +++++++- src/shared/types/ranges.ts | 11 ++++ 8 files changed, 162 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be20944..04a4ee4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A coil read can now ask for the 2000 bits the protocol allows.** The Length + field stopped at 125 whatever the type selected, which is the ceiling for + holding and input registers, so reading a thousand coils took eight reads + rather than one. It now stops at what the selected type answers, and at what + is left from the address: a read starting at 65500 offers 36. A read no longer + runs off the end of the range either. A 64-bit value mapped at 65534 is four + registers wide, and the read that covered it asked for two addresses that do + not exist. - **A scan that reaches past the last unit ID or register now stops there.** A Start of 200 with a Count of 100 asked for unit ID 299, and an address of 60000 with a Length of 10000 asked for register 69999. Neither exists, so the diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 7be2584c..a3d74ab8 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1283,6 +1283,41 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(100, 1) }) + // Neither source of a group is bounded by the range it reads from. + // `RegisterConfigSchema` takes a length of 65535 at any address, so a + // persisted store carries the first one; an int64 is four registers wide + // wherever it is mapped, so a config file carries the second. + it('stops the toolbar read at the last register', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'holding_registers', address: 65500, length: 125 }) + setupHoldingRegisterReadMock([100]) + + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65500, 36) + }) + + it('stops a configured group at the last register', async () => { + await connectClient() + appState.setReadConfiguration(true) + appState.setRegisterMapping({ + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 65534: { dataType: 'int64' } + } + }) + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [100], + buffer: Buffer.from([0x00, 0x64]) + }) + + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65534, 2) + }) + // The pair for the group above: a data type on a coil address parses, // because the mapping schema is one object schema for all four types, and // it is the only way a bit mapping gets one. diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index ba85a95f..d033a585 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -14,6 +14,7 @@ import { PROTOCOL_LABELS, RawTransaction, RegisterData, + registersFrom, RegisterType, ScanRegistersParameters, ScanUnitIDParameters, @@ -606,7 +607,20 @@ export class ModbusClient { this._appState.readConfiguration && groupable ? groupAddressInfos(this._appState.registerMapping?.[type]) : [] - const groups = configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) + // Neither source is bounded by the range it reads from. A group's length is + // the data type's width, so an int64 mapped at 65534 asks for 65534 through + // 65537, and `RegisterConfigSchema` takes a length of 65535 at any address, + // which a persisted store carries into the toolbar's own group. A read + // stops at the last register there is, the way `_scanRegister` already + // does. + const groups = ( + configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) + ).map( + ([groupAddress, groupLength]): AddressGroup => [ + groupAddress, + Math.min(groupLength, registersFrom(groupAddress)) + ] + ) for (const [groupIndex, [groupAddress, groupLength]] of groups.entries()) { // Per group: `_logTransaction` below runs whether the group threw or not, @@ -1138,7 +1152,7 @@ export class ModbusClient { private _scanRegister = async (address: number, length: number): Promise => { const type = this._appState.registerConfig.type - if (address + length > 65536) length = 65536 - address + length = Math.min(length, registersFrom(address)) let data: RegisterData[] | undefined let errorMessage: string | undefined diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 84ac3a04..4a6cc1ab 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -14,7 +14,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import { useDataZustand } from '@renderer/context/data.zustand' import { flushRegisterMappingToMain, useClientZustand } from '@renderer/context/client.zustand' -import { RegisterType } from '@shared' +import { maxReadQuantity, registersFrom, RegisterType } from '@shared' import { showMapping } from '@renderer/context/data.zustand' import { ElementType, useCallback, useEffect, useRef } from 'react' @@ -77,10 +77,17 @@ const Length = meme(() => { const length = useClientZustand((z) => String(z.registerConfig.length)) const lengthValid = useClientZustand((z) => z.valid.length) const address = useClientZustand((z) => z.registerConfig.address) + const type = useClientZustand((z) => z.registerConfig.type) const readConfiguration = useClientZustand((z) => z.readConfiguration) const setLength = useClientZustand.getState().setLength + // Both ceilings a read has: what one response carries, by register type, and + // how many registers are left from the address. `LengthInput` held the first + // at 125 whatever it was passed, so a coil read stopped at 125 of the 2000 + // FC01 answers. + const max = Math.min(maxReadQuantity([type]), registersFrom(address)) + return ( { slotProps={{ input: { inputComponent: LengthInput as unknown as ElementType, - inputProps: maskInputProps({ set: setLength, max: 65536 - address }) + inputProps: maskInputProps({ set: setLength, max }) } }} /> diff --git a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx index 58c7775b..146a25e1 100644 --- a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx +++ b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx @@ -13,7 +13,8 @@ vi.hoisted(() => { w.api = new Proxy({}, { get: () => () => Promise.resolve(undefined) }) }) -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' import { useClientZustand } from '@renderer/context/client.zustand' import { defaultClientState, RegisterType } from '@shared' import RegisterConfig from '../RegisterConfig' @@ -122,6 +123,56 @@ describe('RegisterConfig read configuration', () => { }) }) +// A read has two ceilings and the field held the wrong one. `LengthInput` was +// `Math.min(125, max)`, so what the caller passed could only lower it and a +// coil read stopped at 125 of the 2000 FC01 answers. +describe('RegisterConfig length field', () => { + // The mask is what holds the ceiling, so the field is what this reads. An + // emptied field stores 0 and renders it, and the digits land behind it, which + // is why the number rather than the string. + const typeLength = async (text: string): Promise => { + render() + const input = within(screen.getByTestId('reg-length-input')).getByRole('textbox') + const user = userEvent.setup() + await user.clear(input) + await user.type(input, text) + return Number((input as HTMLInputElement).value) + } + + it('takes 2000 bits of coils', async () => { + useClientZustand.setState({ + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'coils', address: 0 } + } as never) + + expect(await typeLength('2000')).toBe(2000) + }) + + it('holds a register read at 125', async () => { + useClientZustand.setState({ + registerConfig: { + ...useClientZustand.getState().registerConfig, + type: 'holding_registers', + address: 0 + } + } as never) + + expect(await typeLength('2000')).toBe(125) + }) + + // The other ceiling: how many registers are left from the address. + it('holds a read near the end of the range to what is there', async () => { + useClientZustand.setState({ + registerConfig: { + ...useClientZustand.getState().registerConfig, + type: 'coils', + address: 65500 + } + } as never) + + expect(await typeLength('2000')).toBe(36) + }) +}) + // Turning it on hands main the mapping and then asks it to read out of that // mapping, so a refused mapping would read out of the one before it. describe('RegisterConfig turning read configuration on', () => { diff --git a/src/renderer/src/components/shared/inputs/LengthInput.tsx b/src/renderer/src/components/shared/inputs/LengthInput.tsx index 91cb6653..f5df1a27 100644 --- a/src/renderer/src/components/shared/inputs/LengthInput.tsx +++ b/src/renderer/src/components/shared/inputs/LengthInput.tsx @@ -3,16 +3,25 @@ import { forwardRef } from 'react' import { integerMask } from './integerMask' import { meme } from './meme' import { MaskInputProps } from './types' +import { MAX_READ_REGISTERS } from '@shared' +/** + * The length of a read, bounded by the caller. + * + * The ceiling was `Math.min(125, max)`, so the `max` a caller passed could only + * lower it and a coil read was held to 125 of the 2000 FC01 answers. What one + * read can carry is `maxReadQuantity`, by register type, and how much is there + * is `registersFrom`. The caller knows both. + */ const LengthInputForward = forwardRef((props, ref) => { - const { set, max = 125, ...other } = props + const { set, max = MAX_READ_REGISTERS, ...other } = props return ( set(value, Number(value) > 0)} /> diff --git a/src/shared/__tests__/schemas.test.ts b/src/shared/__tests__/schemas.test.ts index 496563a1..b3ffa6c4 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -10,7 +10,13 @@ import { RegisterParamsSchema, RemoveRegisterParamsSchema } from '../types/serve import { DataBitsSchema, SerialPortOptionsSchema, StopBitsSchema } from '../types/serial' import { MAX_UTF8_LENGTH } from '../utils' import { ScanUnitIDParametersSchema } from '../types/scan' -import { MAX_READ_BITS, MAX_READ_REGISTERS, maxReadQuantity } from '../types/ranges' +import { + MAX_READ_BITS, + MAX_READ_REGISTERS, + MAX_REGISTER_ADDRESS, + maxReadQuantity, + registersFrom +} from '../types/ranges' import { isBooleanRegister, isNumberRegister, @@ -579,6 +585,19 @@ describe('maxReadQuantity', () => { }) }) +// The other half of what a read asks for: `maxReadQuantity` says how much one +// response carries, this says how much is there. +describe('registersFrom', () => { + it.each([ + [0, 65536], + [65500, 36], + [65534, 2], + [MAX_REGISTER_ADDRESS, 1] + ])('answers %i with %i', (address, expected) => { + expect(registersFrom(address)).toBe(expected) + }) +}) + describe('SerialPortOptionsSchema', () => { const withOptions = (options: Record): unknown => ({ baudRate: '9600', diff --git a/src/shared/types/ranges.ts b/src/shared/types/ranges.ts index 130d1221..180a1d44 100644 --- a/src/shared/types/ranges.ts +++ b/src/shared/types/ranges.ts @@ -53,6 +53,17 @@ export const MAX_READ_REGISTERS = 125 export const maxReadQuantity = (registerTypes: readonly RegisterType[]): number => registerTypes.some(isNumberRegister) ? MAX_READ_REGISTERS : MAX_READ_BITS +/** + * How many registers there are from an address to the end of the range. + * + * The other half of what a read asks for. `maxReadQuantity` says how much one + * response can carry; this says how much is there, and a read takes the + * smaller. `_read` had neither, so a mapping with an int64 at 65534 asked a + * device for 65534 through 65537, and a length of 65535 at address 65535 asked + * for twice the range that exists. + */ +export const registersFrom = (address: number): number => MAX_REGISTER_ADDRESS - address + 1 + /** * The same range for a register map keyed by address. * From 6d5a8d9cbb7962842008585bbe8a2dd95ffa9e90 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 13:42:34 +0200 Subject: [PATCH 034/137] fix: what the raised ceiling let through, and the group that must stay whole What `/code-review` found on `a75b768`, all three of them about the same move: raising the coil read to 2000 and clamping a group in main. **The write dialog writes over the window the toolbar read.** A coil Length of 2000 seeded 2000 checkboxes and `handleWrite` sent every one of them from the button pressed, which is 250 data bytes in a 253 byte PDU and past the 1968 FC15 carries. `MAX_WRITE_BITS` is that number, the slice stops there, and `WriteParametersSchema` refuses more. `ranges.ts` said nothing read the write pair; one half of it does now. **`_read` applied one ceiling of the two.** `maxReadQuantity` was the renderer's mask alone, and the type and the length are two channels: switching from Coils at 2000 to Holding Registers sends the type first, so main holds `{holding_registers, 2000}` for one round trip and a poll tick landing there asks a device for 2000 registers. The toolbar's group takes both ceilings now, and `_scanRegister` takes the same pair, because `ScanRegistersParametersSchema` bounds the chunk size with `positive()` alone. **A configured group is left whole again.** Cutting it reads part of a value: `conversion.ts` computes `inRange64 = i < registers - 3`, so an int64 at 65534 truncated to two registers came back as 0 rather than as the error row a refused read leaves. A mapping that runs off the end is worth saying out loud. Five tests over the three, of which three go red without their half, and the test for the truncated group now asserts the opposite of what it did. lint 0, typecheck 0, 75 files and 1479 passed. E2E 06, 09 and 15: 130 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- CHANGELOG.md | 8 +- src/main/__tests__/ipc.test.ts | 16 ++++ .../modules/__tests__/modbusClient.test.ts | 26 +++++-- src/main/modules/modbusClient.ts | 35 +++++---- .../columns/WriteModal/WriteModal.tsx | 12 ++- .../__tests__/CoilFunctionSelect.test.tsx | 77 +++++++++++++++++++ src/shared/types/client.ts | 13 +++- src/shared/types/ranges.ts | 7 +- 8 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a4ee4c..44f2a5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 field stopped at 125 whatever the type selected, which is the ceiling for holding and input registers, so reading a thousand coils took eight reads rather than one. It now stops at what the selected type answers, and at what - is left from the address: a read starting at 65500 offers 36. A read no longer - runs off the end of the range either. A 64-bit value mapped at 65534 is four - registers wide, and the read that covered it asked for two addresses that do - not exist. + is left from the address: a read starting at 65500 offers 36. Writing those + coils back stops at 1968, which is what one request carries. A read no longer + runs off the end of the range either, and neither does a scan: a length left + over from a wider read asked a device for addresses that do not exist. - **A scan that reaches past the last unit ID or register now stops there.** A Start of 200 with a Count of 100 asked for unit ID 299, and an address of 60000 with a Length of 10000 asked for register 69999. Neither exists, so the diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index 5b3f2f23..8599342b 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -13,6 +13,7 @@ import { AddRegisterParamsSchema, ConnectionConfigSchema, CreateServerParamsSchema, + MAX_WRITE_BITS, PortSchema, PrivilegedPortFixModeSchema, RemoveRegisterParamsSchema, @@ -287,6 +288,21 @@ describe('write-path schemas', () => { ).toBe(true) }) + // FC01 answers 2000 bits and FC15 writes 1968, because the request carries + // the data as well as the address and the quantity. The dialog writes over + // the window the toolbar read, and that window is 2000 wide. + it('rejects more coils than one FC15 carries', () => { + const coils = (count: number): unknown => ({ + address: 0, + single: false, + type: 'coils', + value: new Array(count).fill(true) + }) + + expect(WriteParametersSchema.safeParse(coils(MAX_WRITE_BITS)).success).toBe(true) + expect(WriteParametersSchema.safeParse(coils(MAX_WRITE_BITS + 1)).success).toBe(false) + }) + it('rejects an address outside the Modbus range', () => { const result = WriteParametersSchema.safeParse({ address: 70000, diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index a3d74ab8..5b0fff37 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1283,10 +1283,10 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(100, 1) }) - // Neither source of a group is bounded by the range it reads from. - // `RegisterConfigSchema` takes a length of 65535 at any address, so a - // persisted store carries the first one; an int64 is four registers wide - // wherever it is mapped, so a config file carries the second. + // The toolbar's group is bounded by neither ceiling here. + // `RegisterConfigSchema` takes a length of 65535 at any address, and the + // type and the length travel on two channels, so main holds the old length + // for one round trip when the type changes under it. it('stops the toolbar read at the last register', async () => { await connectClient() appState.updateRegisterConfig({ type: 'holding_registers', address: 65500, length: 125 }) @@ -1297,7 +1297,21 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65500, 36) }) - it('stops a configured group at the last register', async () => { + it('stops the toolbar read at what one response carries', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'holding_registers', address: 0, length: 2000 }) + setupHoldingRegisterReadMock([100]) + + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(0, 125) + }) + + // A configured group is left whole. Its length is the data type's width, so + // cutting it reads part of a value and `convertRegisterData` answers 0 to a + // 64 bit type it has not got the registers for. Refused, the address gets + // an error row, which is the truth about a mapping that runs off the end. + it('leaves a configured group that runs off the end whole', async () => { await connectClient() appState.setReadConfiguration(true) appState.setRegisterMapping({ @@ -1315,7 +1329,7 @@ describe('ModbusClient', () => { await client.read() - expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65534, 2) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65534, 4) }) // The pair for the group above: a data type on a coil address parses, diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index d033a585..db56684b 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -11,6 +11,7 @@ import { groupAddressInfos, humanizeSerialError, isBooleanRegister, + maxReadQuantity, PROTOCOL_LABELS, RawTransaction, RegisterData, @@ -607,20 +608,22 @@ export class ModbusClient { this._appState.readConfiguration && groupable ? groupAddressInfos(this._appState.registerMapping?.[type]) : [] - // Neither source is bounded by the range it reads from. A group's length is - // the data type's width, so an int64 mapped at 65534 asks for 65534 through - // 65537, and `RegisterConfigSchema` takes a length of 65535 at any address, - // which a persisted store carries into the toolbar's own group. A read - // stops at the last register there is, the way `_scanRegister` already - // does. - const groups = ( - configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) - ).map( - ([groupAddress, groupLength]): AddressGroup => [ - groupAddress, - Math.min(groupLength, registersFrom(groupAddress)) - ] - ) + // The toolbar's group is bounded by neither ceiling. `RegisterConfigSchema` + // takes a length of 65535 at any address, so a persisted store carries a + // read past the last register there is; and the type and the length are two + // channels, so main holds the old length for one round trip when the type + // changes under it, which is how 2000 coils became 2000 holding registers. + // + // A configured group is left whole on purpose. Its length is the data + // type's width, so cutting it reads part of a value: an int64 at 65534 came + // back as 2 registers, and `convertRegisterData` answers 0 to a 64 bit type + // it has not got the registers for. Refused, the address gets an error row + // instead, which is the truth about a mapping that runs off the end. + const toolbarGroup: AddressGroup = [ + address, + Math.min(length, maxReadQuantity([type]), registersFrom(address)) + ] + const groups = configGroups.length > 0 ? configGroups : [toolbarGroup] for (const [groupIndex, [groupAddress, groupLength]] of groups.entries()) { // Per group: `_logTransaction` below runs whether the group threw or not, @@ -1152,7 +1155,9 @@ export class ModbusClient { private _scanRegister = async (address: number, length: number): Promise => { const type = this._appState.registerConfig.type - length = Math.min(length, registersFrom(address)) + // `ScanRegistersParametersSchema` takes any positive chunk size, and the + // field that builds it is masked by register type in the renderer alone. + length = Math.min(length, maxReadQuantity([type]), registersFrom(address)) let data: RegisterData[] | undefined let errorMessage: string | undefined diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index e1d3899f..0aad64ed 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -14,7 +14,7 @@ import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inpu import { useClientZustand } from '@renderer/context/client.zustand' import { useDataZustand } from '@renderer/context/data.zustand' import { useMinMaxInteger } from '@renderer/hooks' -import { notEmpty, RegisterType } from '@shared' +import { MAX_WRITE_BITS, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { decimalMask } from '@renderer/components/shared/inputs/decimalMask' import { IMaskInput } from 'react-imask' @@ -140,7 +140,7 @@ export const WriteRegistersButton = meme(() => { ) }) -const CoilFunctionSelect = meme(() => { +export const CoilFunctionSelect = meme(() => { const address = useValueInputZustand((z) => z.address) const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const coils = useValueInputZustand((z) => z.coils) @@ -152,11 +152,17 @@ const CoilFunctionSelect = meme(() => { valueInputZustand.setCoilFunction(value) }, []) + // FC15 writes from the button you pressed to the end of the window the + // toolbar read, and that window is 2000 coils wide since the Length field + // took the ceiling FC01 answers. FC15 stops 32 bits short of it, because the + // request carries the data too, so the tail of a full window is cut here + // rather than refused by the boundary. const handleWrite = useCallback(() => { + const from = address - registerConfigAddress window.api.write({ address, type: 'coils', - value: coils.slice(address - registerConfigAddress), + value: coils.slice(from, from + MAX_WRITE_BITS), single: coilFunction === 5 }) }, [address, coilFunction, coils, registerConfigAddress]) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx new file mode 100644 index 00000000..36881aef --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +/// +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_WRITE_BITS } from '@shared' + +// WriteModal reaches both root stores, and each registers ipcRenderer listeners +// on import. This one reads the address the toolbar last read from. +vi.mock('@renderer/context/client.zustand', () => ({ + useClientZustand: Object.assign( + (selector: (state: { registerConfig: { address: number } }) => unknown) => + selector({ registerConfig: { address: 0 } }), + { getState: () => ({}) } + ) +})) +vi.mock('@renderer/context/data.zustand', () => ({ + useDataZustand: Object.assign(() => undefined, { getState: () => ({ registerData: [] }) }) +})) + +import { CoilFunctionSelect } from '../WriteModal' +import { useValueInputZustand } from '../writeModal.zustand' + +const mockWrite = vi.fn() + +// @ts-expect-error - Mocking window.api for tests +global.window.api = { write: mockWrite } + +const written = (): boolean[] => { + const payload = mockWrite.mock.calls[0]?.[0] as { value: boolean[] } | undefined + return payload?.value ?? [] +} + +// The Length field took the 2000 FC01 answers, and the dialog writes over the +// window the toolbar read. FC15 carries the data as well as the address and the +// quantity, so it stops 32 bits short of that. +describe('the coil write', () => { + beforeEach(() => { + mockWrite.mockClear() + useValueInputZustand.setState({ + address: 0, + coilFunction: 15, + coils: new Array(2000).fill(true) + }) + }) + + it('sends what one FC15 carries', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(written().length).toBe(MAX_WRITE_BITS) + }) + + it('sends a window that fits whole', async () => { + useValueInputZustand.setState({ coils: new Array(125).fill(true) }) + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(written().length).toBe(125) + }) + + // The button you press is where the write starts, so the tail it can reach is + // shorter the further in it sits. + it('starts at the coil you pressed', async () => { + useValueInputZustand.setState({ address: 40 }) + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(written().length).toBe(1960) + }) +}) diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index f7b3e8c2..71fffb18 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -1,7 +1,13 @@ import z from 'zod' import { BaseDataTypeSchema, DataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' -import { PortSchema, RegisterAddressKeySchema, RegisterAddressSchema, UnitIdSchema } from './ranges' +import { + MAX_WRITE_BITS, + PortSchema, + RegisterAddressKeySchema, + RegisterAddressSchema, + UnitIdSchema +} from './ranges' import { RegisterType, RegisterTypeSchema } from './register' import { SerialPortOptionsSchema } from './serial' @@ -157,7 +163,10 @@ export const WriteParametersSchema = z z.union([ z.object({ type: z.literal('coils'), - value: z.array(z.boolean()), + // FC15 carries the data as well as the address and the quantity, so it + // stops 32 bits short of what FC01 answers. The dialog writes over the + // window the toolbar read, and that window is 2000 wide. + value: z.array(z.boolean()).max(MAX_WRITE_BITS), dataType: z.undefined() }), z.object({ diff --git a/src/shared/types/ranges.ts b/src/shared/types/ranges.ts index 180a1d44..7d58a875 100644 --- a/src/shared/types/ranges.ts +++ b/src/shared/types/ranges.ts @@ -33,11 +33,14 @@ export const PortSchema = z.number().int().min(0).max(65535) * so 251 bytes are left: 125 registers of two bytes, and 250 bytes of bits. * * A write is a different pair, 1968 bits and 123 registers, because the request - * carries the data as well as the address and the quantity. Nothing reads that - * here, because every field these bound builds a read. + * carries the data as well as the address and the quantity. `WriteModal` sends + * one FC15 over the window the toolbar read, and that window is now 2000 wide, + * so the bit half has a reader. The register half has none: a register write is + * one value of one data type, four registers at the widest. */ export const MAX_READ_BITS = 2000 export const MAX_READ_REGISTERS = 125 +export const MAX_WRITE_BITS = 1968 /** * The ceiling one read of these register types has. From c6eee685b63a896c1a9171fc76af245cc04894f8 Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 13:51:22 +0200 Subject: [PATCH 035/137] fix: the chunk is the stride, and the coils a write leaves alone What `/code-review` found on `b030d2d`. The first is the commit's own doing. **A scan asked for 125 and jumped 1000.** The clamp went into `_scanRegister`, which is one of three places the chunk size is read: the loop strides by it and `_totalScans` divides by it, and both still saw the number the payload carried. A scan of 0 to 65535 at a chunk of 1000 then read 125 of every 1000, reported itself done and left seven eighths of the range unasked. The clamp is in `scanRegisters` now, above both readers, and `_scanRegister` keeps the `registersFrom` half alone, which shortens the last chunk and no stride. Nothing measured it: no `scanRegisters` case in the suite asked for a chunk above 100. The new test walks 0 to 999 at a chunk of 1000 and counts the requests. **The coil write said nothing about the tail it cut.** The grid goes on showing what the read left there, so 32 coils nothing was written to read the same as the 1968 that were. It says so now, in the window the write went out from. lint 0, typecheck 0, 75 files and 1481 passed. E2E 06, 09 and 11: 113 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../modules/__tests__/modbusClient.test.ts | 25 +++++++++++++++++++ src/main/modules/modbusClient.ts | 20 +++++++++------ .../columns/WriteModal/WriteModal.tsx | 19 +++++++++++--- .../__tests__/CoilFunctionSelect.test.tsx | 22 ++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 5b0fff37..b68a5a57 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1038,6 +1038,31 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(60, 10) }) + // The chunk is the stride, the progress divisor and the quantity of every + // request. Clamped in one of the three, a scan asks for 125 and jumps 1000, + // reports itself done and leaves seven eighths of the range unread. + it('walks the range in chunks one response can carry', async () => { + await connectClient() + appState.updateRegisterConfig({ type: 'holding_registers' }) + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + + const scanPromise = client.scanRegisters({ + addressRange: [0, 999], + length: 1000, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(5000) + await scanPromise + + const asked = mockModbusRTU.readHoldingRegisters.mock.calls + expect(asked[0]).toEqual([0, 125]) + expect(asked[1]).toEqual([125, 125]) + expect(asked.length).toBe(8) + }) + it('scanUnitIds refuses while disconnected', async () => { await client.scanUnitIds({ range: [1, 3], diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index db56684b..7b6ed0fe 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -1129,15 +1129,20 @@ export class ModbusClient { this._client.setID(unitId) this._client.setTimeout(params.timeout) - this._totalScans = Math.ceil( - (params.addressRange[1] - params.addressRange[0] + 1) / params.length - ) + // The chunk is the stride, the divisor of the progress and the quantity of + // every request, so it is one number here. `ScanRegistersParametersSchema` + // takes any positive chunk size, and `ChunkSizeField`'s mask reads the + // register type selected, so a chunk of 2000 typed under coils reaches this + // with holding registers selected. + const { addressRange } = params + const length = Math.min(params.length, maxReadQuantity([this._appState.registerConfig.type])) + + this._totalScans = Math.ceil((addressRange[1] - addressRange[0] + 1) / length) this._scansDone = 0 this._clientState.scanningRegisters = true this._sendClientState() - const { addressRange, length } = params for (let address = addressRange[0]; address <= addressRange[1]; address += length) { await this._scanRegister(address, length) await this._sendScanProgress() @@ -1155,9 +1160,10 @@ export class ModbusClient { private _scanRegister = async (address: number, length: number): Promise => { const type = this._appState.registerConfig.type - // `ScanRegistersParametersSchema` takes any positive chunk size, and the - // field that builds it is masked by register type in the renderer alone. - length = Math.min(length, maxReadQuantity([type]), registersFrom(address)) + // The last chunk alone, so the stride its caller walks is untouched. What + // one response carries is clamped there, where the same number is the + // stride and the progress divisor. + length = Math.min(length, registersFrom(address)) let data: RegisterData[] | undefined let errorMessage: string | undefined diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index 0aad64ed..3ed54d97 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -18,6 +18,7 @@ import { MAX_WRITE_BITS, notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { decimalMask } from '@renderer/components/shared/inputs/decimalMask' import { IMaskInput } from 'react-imask' +import { useSnackbar } from 'notistack' import { seedCoils, useValueInputZustand, writeDataTypeFor } from './writeModal.zustand' const ValueInputForward = forwardRef((props, ref) => { @@ -141,6 +142,7 @@ export const WriteRegistersButton = meme(() => { }) export const CoilFunctionSelect = meme(() => { + const { enqueueSnackbar } = useSnackbar() const address = useValueInputZustand((z) => z.address) const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const coils = useValueInputZustand((z) => z.coils) @@ -155,17 +157,26 @@ export const CoilFunctionSelect = meme(() => { // FC15 writes from the button you pressed to the end of the window the // toolbar read, and that window is 2000 coils wide since the Length field // took the ceiling FC01 answers. FC15 stops 32 bits short of it, because the - // request carries the data too, so the tail of a full window is cut here - // rather than refused by the boundary. + // request carries the data too. The tail is cut here rather than refused by + // the boundary, and the grid goes on showing coils nothing was written to, so + // the cut is worth saying. const handleWrite = useCallback(() => { const from = address - registerConfigAddress + const value = coils.slice(from, from + MAX_WRITE_BITS) + const dropped = coils.length - from - value.length + if (dropped > 0) { + enqueueSnackbar({ + message: `One request writes ${MAX_WRITE_BITS} coils, so the last ${dropped} were left alone`, + variant: 'warning' + }) + } window.api.write({ address, type: 'coils', - value: coils.slice(from, from + MAX_WRITE_BITS), + value, single: coilFunction === 5 }) - }, [address, coilFunction, coils, registerConfigAddress]) + }, [address, coilFunction, coils, registerConfigAddress, enqueueSnackbar]) return ( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx index 36881aef..c64dd3c9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx @@ -18,6 +18,11 @@ vi.mock('@renderer/context/data.zustand', () => ({ useDataZustand: Object.assign(() => undefined, { getState: () => ({ registerData: [] }) }) })) +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ + useSnackbar: (): { enqueueSnackbar: typeof enqueueSnackbar } => ({ enqueueSnackbar }) +})) + import { CoilFunctionSelect } from '../WriteModal' import { useValueInputZustand } from '../writeModal.zustand' @@ -37,6 +42,7 @@ const written = (): boolean[] => { describe('the coil write', () => { beforeEach(() => { mockWrite.mockClear() + enqueueSnackbar.mockClear() useValueInputZustand.setState({ address: 0, coilFunction: 15, @@ -53,6 +59,21 @@ describe('the coil write', () => { expect(written().length).toBe(MAX_WRITE_BITS) }) + // The grid goes on showing what the read left there, so a coil nothing was + // written to reads the same as one that was. + it('says what it left alone', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(enqueueSnackbar).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'One request writes 1968 coils, so the last 32 were left alone' + }) + ) + }) + it('sends a window that fits whole', async () => { useValueInputZustand.setState({ coils: new Array(125).fill(true) }) const user = userEvent.setup() @@ -61,6 +82,7 @@ describe('the coil write', () => { await user.click(screen.getByTestId('write-submit-btn')) expect(written().length).toBe(125) + expect(enqueueSnackbar).not.toHaveBeenCalled() }) // The button you press is where the write starts, so the tail it can reach is From 3bd2c851af465f67464bf20b2bbf903206867def Mon Sep 17 00:00:00 2001 From: Harted Date: Sat, 19 Sep 2026 14:00:37 +0200 Subject: [PATCH 036/137] fix: the message FC5 does not owe, and the type a scan reads twice What `/code-review` found on `6923d53`. **One button sends both function codes.** The warning about the coils a write left alone fired on FC5 too, where it said 1968 went out and one did. It is FC15's message. **A scan reads the register type at the start and again per chunk.** The clamp `6923d53` moved into `scanRegisters` takes the type as it stands when the scan begins; `_scanRegister` reads the same field for every chunk, to pick the reader. Nothing in main stopped the two disagreeing: `update_register_config` merges what it is given and `setType` sends it the moment the select changes. A switch from coils at a chunk of 2000 to holding registers landing between two chunks asked a device for 2000 registers, which is the request the clamp exists to stop. The scan dialog disables every field it owns while it runs, and this one is the toolbar's: what kept it out of reach was the overlay drawn over it. It is disabled now, so the type a scan starts on is the type it ends on. Two tests, both red without their half. A third finding is in TODO.md rather than here: a scan of 0 to 10 at a chunk of 125 reads 0 through 124, so the grid fills with addresses nobody asked for. It is older than this branch and it changes what the grid shows. lint 0, typecheck 0, 75 files and 1484 passed. E2E 06, 09, 11 and 15: 160 passed. Claude-Session: https://claude.ai/code/session_018YrcWbyHtV9JPT4ppY4EZP --- .../columns/WriteModal/WriteModal.tsx | 5 +++- .../__tests__/CoilFunctionSelect.test.tsx | 12 +++++++++ .../client/RegisterConfig/RegisterConfig.tsx | 8 ++++++ .../__tests__/RegisterConfig.test.tsx | 26 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx index 3ed54d97..2beb9f1d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal.tsx @@ -164,7 +164,10 @@ export const CoilFunctionSelect = meme(() => { const from = address - registerConfigAddress const value = coils.slice(from, from + MAX_WRITE_BITS) const dropped = coils.length - from - value.length - if (dropped > 0) { + // FC5 writes the first coil and nothing else, so the tail it leaves is the + // whole window rather than this remainder, and a count here would be a + // lie. One button sends both function codes. + if (dropped > 0 && coilFunction === 15) { enqueueSnackbar({ message: `One request writes ${MAX_WRITE_BITS} coils, so the last ${dropped} were left alone`, variant: 'warning' diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx index c64dd3c9..b34bdee8 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx @@ -74,6 +74,18 @@ describe('the coil write', () => { ) }) + // FC5 writes the first coil and leaves the other 1999, so the count the + // message carries is about the request FC15 would have sent. + it('says nothing about a tail when it writes one coil', async () => { + useValueInputZustand.setState({ coilFunction: 5 }) + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(enqueueSnackbar).not.toHaveBeenCalled() + }) + it('sends a window that fits whole', async () => { useValueInputZustand.setState({ coils: new Array(125).fill(true) }) const user = userEvent.setup() diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 4a6cc1ab..e59380f1 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -23,6 +23,13 @@ const TypeSelect = meme(() => { const labelId = 'register-type-select' const type = useClientZustand((z) => z.registerConfig.type) + // A register scan reads this field once, for the chunk size one response + // carries, and `_scanRegister` reads it again for every chunk. Changing it + // between the two asks a device for 2000 holding registers. The scan dialog + // disables every field it owns while it runs; this one is the toolbar's, and + // what kept it out of reach was the overlay drawn over it. + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const handleChange = useCallback((type: RegisterType) => { if (!useClientZustand.getState().readConfiguration) { useDataZustand.getState().setRegisterData([]) @@ -34,6 +41,7 @@ const TypeSelect = meme(() => { Type { - const value = e.target.value - set(value, isConnectionAddressGiven(value)) + const typed = e.target.value + setText(typed) + set(typed, isConnectionAddressGiven(typed)) + }} + onBlur={(e) => { + setText(storeText) + other.onBlur?.(e) }} /> ) diff --git a/src/renderer/src/components/shared/inputs/__tests__/hostInput.test.tsx b/src/renderer/src/components/shared/inputs/__tests__/hostInput.test.tsx new file mode 100644 index 00000000..c1d95697 --- /dev/null +++ b/src/renderer/src/components/shared/inputs/__tests__/hostInput.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +/// +// +// The host field holds its own text while it has focus, because the store +// answers after main does and a value written from outside empties the field's +// undo history. A store value that arrives while the user types must not +// overwrite the text; one that arrives with no focus must reach the field. +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import TextField from '@mui/material/TextField' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import { ElementType } from 'react' + +import HostInput from '../HostInput' +import { maskInputProps } from '../types' +import { MaskSetFn } from '@renderer/context/client.zustand.types' + +const hostField = (value: string, set: MaskSetFn): JSX.Element => ( + , + inputProps: maskInputProps({ set }) + } + }} + /> +) + +describe('the host field', () => { + it('hands the setter every key with whether the host is given', async () => { + const set = vi.fn() + render(hostField('127.0.0.1', set)) + const input = screen.getByRole('textbox') + + const user = userEvent.setup() + await user.clear(input) + await user.type(input, '10') + + expect(set).toHaveBeenLastCalledWith('10', true) + expect(set).toHaveBeenCalledWith('', false) + }) + + it('keeps what is typed while a store value from before arrives, and shows the store on blur', async () => { + const set = vi.fn() + const { rerender } = render(hostField('127.0.0.1', set)) + const input = screen.getByRole('textbox') + + const user = userEvent.setup() + await user.clear(input) + await user.type(input, '10') + rerender(hostField('1', set)) + + expect(input).toHaveValue('10') + + await user.tab() + + expect(input).toHaveValue('1') + }) + + it('shows a store value that arrives while it has no focus', () => { + const set = vi.fn() + const { rerender } = render(hostField('127.0.0.1', set)) + + rerender(hostField('192.168.1.1', set)) + + expect(screen.getByRole('textbox')).toHaveValue('192.168.1.1') + }) +}) From 0c4ae95589a79d0a1462995b05fba1ad729afc73 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 21:18:19 +0200 Subject: [PATCH 100/137] feat: undo and redo for the client configuration The client store records a step for every write that leaves it holding another value, and `clientUndo` replays one through the setter the edit went through, so the value reaches main the same way and passes the same guards. Nothing calls `undoClient` or `redoClient` yet; the keys come in their own commit. A step carries only the value from before. Undo reads what is there now and hands that across as the step redo replays, so a Load or a Clear Config step holds one copy of a configuration rather than two. - A field is one step per run of writes to it, merged until another field or action comes, or an undo or a redo. An invalid value kept in the store and never sent opens the run too, so clearing the host and typing another undoes back to the host from before the clear. - A mapping edit keeps the register's whole entry, because a data type of `none` deletes the entry with every other column in it. The replay shows the register type the entry belongs to before it writes, through `setMappingEntry`, which writes under the type named rather than the one on screen. - Load and Clear Config run through `asOneClientStep`, which quiets the setters and records one step for the name, the byte order and the mapping. - A field step is done when the store holds its value afterwards, which is what puts back an invalid value a setter kept and answered `false` for. A step the store does not take, such as a connection field while connected, answers `refused` and stays on the stack. A Load or Clear Config step is put back whole or not at all: the byte order goes first, and back again if main then refuses the mapping. - Recording is quiet while a step replays or an action runs as one step, and quiet is a count, because a Load can start while an undo waits on main. The replayed step is moved off the stack by identity, since that Load's step may have landed on top of it meanwhile. An edit made in that window is not recorded, and it ends the open run so the next edit does not merge across it. - A Load or Clear Config step is recorded in a `finally`, so a Load that threw after the name went in still has one, and it is compared by content, so Clear Config on an empty configuration records nothing. - A missing parity, which a config from before parity existed carries, reads as `none` in a step, because that is what the replay writes back. Each stack keeps its newest `UNDO_LIMIT` steps. Nothing is persisted. `undoStack.test.ts` covers the merge, the limit and the move between the stacks, and `clientUndo.test.ts` drives the store through the renderer stub. A mutation per rule turned that rule's test red. --- .../RegisterGridToolbar/ClearConfigButton.tsx | 7 +- .../RegisterGridToolbar/LoadButton.tsx | 11 +- .../src/context/__tests__/clientUndo.test.ts | 342 ++++++++++++++++++ .../src/context/__tests__/undoStack.test.ts | 76 ++++ src/renderer/src/context/client.zustand.ts | 62 +++- .../src/context/client.zustand.types.ts | 9 + src/renderer/src/context/clientUndo.ts | 179 +++++++++ .../src/context/undo.zustand.helpers.ts | 128 +++++++ src/renderer/src/context/undo.zustand.ts | 27 ++ .../src/context/undo.zustand.types.ts | 108 ++++++ 10 files changed, 941 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/context/__tests__/clientUndo.test.ts create mode 100644 src/renderer/src/context/__tests__/undoStack.test.ts create mode 100644 src/renderer/src/context/clientUndo.ts create mode 100644 src/renderer/src/context/undo.zustand.helpers.ts create mode 100644 src/renderer/src/context/undo.zustand.ts create mode 100644 src/renderer/src/context/undo.zustand.types.ts diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx index 521f398e..80f3ddfc 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx @@ -9,6 +9,7 @@ import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' +import { asOneClientStep } from '@renderer/context/clientUndo' import { RegisterMapping, RegisterMapValue, RegisterTypeSchema } from '@shared' import { useCallback, useState } from 'react' @@ -38,8 +39,10 @@ const mappedRegisterCount = (registerMapping: RegisterMapping): number => const clearConfiguration = (): void => { const clientZustand = useClientZustand.getState() - clientZustand.setName('') - void clientZustand.clearRegisterMapping() + void asOneClientStep(async () => { + clientZustand.setName('') + await clientZustand.clearRegisterMapping() + }) } interface ConfirmProps { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx index db505b83..52fa9dac 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton.tsx @@ -6,6 +6,7 @@ import { migrateClientConfig, resetMessage } from '@shared' import { useSnackbar } from 'notistack' import { useRef, useState, useCallback } from 'react' import { showMapping } from '@renderer/context/data.zustand' +import { asOneClientStep } from '@renderer/context/clientUndo' import { meme } from '@renderer/components/shared/inputs/meme' const LoadButton = meme((): JSX.Element => { @@ -30,10 +31,12 @@ const LoadButton = meme((): JSX.Element => { const migrationResult = migrateClientConfig(content) const { config, migrated, futureVersion } = migrationResult - // Set name, endianness and register mapping - if (config.name) clientZustand.setName(config.name) - clientZustand.setLittleEndian(config.littleEndian) - await clientZustand.replaceRegisterMapping(config.registerMapping) + // Set name, endianness and register mapping, as one step to undo + await asOneClientStep(async () => { + if (config.name) clientZustand.setName(config.name) + await clientZustand.setLittleEndian(config.littleEndian) + await clientZustand.replaceRegisterMapping(config.registerMapping) + }) // Show success notification if (migrated) { diff --git a/src/renderer/src/context/__tests__/clientUndo.test.ts b/src/renderer/src/context/__tests__/clientUndo.test.ts new file mode 100644 index 00000000..85da5a56 --- /dev/null +++ b/src/renderer/src/context/__tests__/clientUndo.test.ts @@ -0,0 +1,342 @@ +// @vitest-environment happy-dom +// +// An undo replays the setter a user's edit went through, so what it puts back +// reaches main the same way and passes the same guards. These drive the client +// store through the renderer stub and read what the store holds after. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState } from '@shared' +import { stubRenderer } from './stubRenderer' + +vi.mock('notistack', () => ({ enqueueSnackbar: vi.fn() })) + +/** Main answers `undefined` on one channel from here on, as it does for a payload it refuses. */ +const refuse = (method: string): void => { + const underneath = window.api as unknown as Record + window.api = new Proxy( + {}, + { + get: (_target, name: string): unknown => + name === method ? (): Promise => Promise.resolve(undefined) : underneath[name] + } + ) as never +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +const load = async (): Promise<{ + client: () => ReturnType + undo: () => ReturnType + clientUndo: typeof import('../clientUndo') + setConnected: (connected: boolean) => void +}> => { + const { useClientZustand } = await import('../client.zustand') + const { useUndoZustand } = await import('../undo.zustand') + const { useDataZustand } = await import('../data.zustand') + const clientUndo = await import('../clientUndo') + return { + client: () => useClientZustand.getState(), + undo: () => useUndoZustand.getState(), + clientUndo, + setConnected: (connected) => + useDataZustand.getState().setClientState({ + ...defaultClientState, + connectState: connected ? 'connected' : 'disconnected' + }) + } +} + +describe('a field', () => { + it('undoes a typed run in one step and redoes it', async () => { + const { client, clientUndo } = await load() + const before = client().connectionConfig.tcp.host + + for (const typed of ['1', '10', '10.0.0.5']) await client().setHost(typed, true) + + expect(await clientUndo.undoClient()).toBe('done') + expect(client().connectionConfig.tcp.host).toBe(before) + + expect(await clientUndo.redoClient()).toBe('done') + expect(client().connectionConfig.tcp.host).toBe('10.0.0.5') + }) + + it('starts the run from the host there was before the field was cleared', async () => { + const { client, undo, clientUndo } = await load() + const before = client().connectionConfig.tcp.host + + await client().setHost('', false) + await client().setHost('1', true) + await client().setHost('10', true) + expect(undo().client.past).toHaveLength(1) + await clientUndo.undoClient() + + expect(client().connectionConfig.tcp.host).toBe(before) + }) + + it('records nothing for a value the store held already', async () => { + const { client, undo } = await load() + await client().setUnitId('7') + const steps = undo().client.past.length + + await client().setUnitId('7') + + expect(undo().client.past).toHaveLength(steps) + }) + + it('records nothing for a write that leaves the value where it was', async () => { + const { client, undo } = await load() + + await client().setPollRate(client().registerConfig.pollRate) + + expect(undo().client.past).toEqual([]) + }) + + it('records nothing for a write main refused', async () => { + const { client, undo } = await load() + + await client().setUnitId('1,5') + + expect(undo().client.past).toEqual([]) + }) + + it('records nothing until the last of two quiet stretches has ended', async () => { + const { client, undo } = await load() + undo().beginQuiet() + undo().beginQuiet() + undo().endQuiet() + + await client().setPollRate(5000) + + expect(undo().client.past).toEqual([]) + }) + + it('records nothing while it replays', async () => { + const { client, undo, clientUndo } = await load() + await client().setPollRate(5000) + await client().setTimeout(3000) + + await clientUndo.undoClient() + + expect(undo().client.past).toHaveLength(1) + expect(undo().client.future).toHaveLength(1) + }) + + it('refuses a connection field while a connection stands, and keeps the step', async () => { + const { client, undo, clientUndo, setConnected } = await load() + const before = client().connectionConfig.tcp.host + await client().setHost('10.0.0.5', true) + setConnected(true) + + expect(await clientUndo.undoClient()).toBe('refused') + expect(client().connectionConfig.tcp.host).toBe('10.0.0.5') + expect(undo().client.past).toHaveLength(1) + + setConnected(false) + expect(await clientUndo.undoClient()).toBe('done') + expect(client().connectionConfig.tcp.host).toBe(before) + }) + + it('puts back an invalid value it kept, and reaches the step behind it', async () => { + const { client, clientUndo } = await load() + await client().setType('coils') + await client().setLength('0', false) + await client().setType('holding_registers') + await client().setLength('5', true) + + expect(await clientUndo.undoClient()).toBe('done') + expect(client().registerConfig.length).toBe(0) + expect(await clientUndo.undoClient()).toBe('done') + expect(client().registerConfig.type).toBe('coils') + }) + + it('puts back a parity a config from before it existed never carried', async () => { + const { client, clientUndo } = await load() + const { useClientZustand } = await import('../client.zustand') + const { baudRate, dataBits, stopBits } = client().connectionConfig.rtu.options + useClientZustand.setState({ + connectionConfig: { + ...client().connectionConfig, + rtu: { ...client().connectionConfig.rtu, options: { baudRate, dataBits, stopBits } } + } + }) + await client().setParity('even') + + expect(await clientUndo.undoClient()).toBe('done') + expect(client().connectionConfig.rtu.options.parity).toBe('none') + }) + + it('ends the open run on a write made while quiet', async () => { + const { client, undo, clientUndo } = await load() + await client().setPollRate(5000) + undo().beginQuiet() + await client().setPollRate(6000) + undo().endQuiet() + + await client().setPollRate(7000) + await clientUndo.undoClient() + + expect(client().registerConfig.pollRate).toBe(6000) + }) + + it('leaves nothing to redo once a new edit comes after an undo', async () => { + const { client, clientUndo } = await load() + await client().setPollRate(5000) + await clientUndo.undoClient() + + await client().setTimeout(3000) + + expect(await clientUndo.redoClient()).toBe('empty') + }) + + it('answers empty when there is nothing to undo', async () => { + const { clientUndo } = await load() + + expect(await clientUndo.undoClient()).toBe('empty') + expect(await clientUndo.redoClient()).toBe('empty') + }) +}) + +describe('a mapping entry', () => { + it('goes back under the type it was edited in, and shows that type', async () => { + const { client, undo, clientUndo } = await load() + await client().setType('holding_registers') + client().setRegisterMapping(3, 'comment', 'pump') + // Another type on screen, with no step of its own in between. + undo().beginQuiet() + await client().setType('coils') + undo().endQuiet() + + await clientUndo.undoClient() + + expect(client().registerConfig.type).toBe('holding_registers') + expect(client().registerMapping.holding_registers[3]).toBeUndefined() + + await clientUndo.redoClient() + expect(client().registerMapping.holding_registers[3]).toEqual({ comment: 'pump' }) + }) + + it('comes back whole after a data type of none removed it', async () => { + const { client, clientUndo } = await load() + await client().setType('holding_registers') + client().setRegisterMapping(3, 'dataType', 'int16') + client().setRegisterMapping(3, 'comment', 'pump') + + client().setRegisterMapping(3, 'dataType', 'none') + expect(client().registerMapping.holding_registers[3]).toBeUndefined() + + await clientUndo.undoClient() + expect(client().registerMapping.holding_registers[3]).toEqual({ + dataType: 'int16', + comment: 'pump' + }) + }) +}) + +describe('Load and Clear Config', () => { + it('are one step that puts the name, the byte order and the mapping back', async () => { + const { client, undo, clientUndo } = await load() + await client().setType('holding_registers') + client().setName('boiler') + client().setRegisterMapping(3, 'comment', 'pump') + await client().setLittleEndian(true) + const steps = undo().client.past.length + + await clientUndo.asOneClientStep(async () => { + client().setName('') + await client().setLittleEndian(false) + await client().clearRegisterMapping() + }) + expect(undo().client.past).toHaveLength(steps + 1) + + expect(await clientUndo.undoClient()).toBe('done') + expect(client().name).toBe('boiler') + expect(client().registerConfig.littleEndian).toBe(true) + expect(client().registerMapping.holding_registers[3]).toEqual({ comment: 'pump' }) + }) + + it('leaves the byte order where it was when main refuses the mapping', async () => { + const { client, clientUndo } = await load() + await client().setType('holding_registers') + client().setRegisterMapping(3, 'comment', 'pump') + await client().setLittleEndian(true) + await clientUndo.asOneClientStep(async () => { + await client().setLittleEndian(false) + await client().clearRegisterMapping() + }) + refuse('setRegisterMapping') + + expect(await clientUndo.undoClient()).toBe('refused') + expect(client().registerConfig.littleEndian).toBe(false) + expect(client().registerMapping.holding_registers[3]).toBeUndefined() + }) + + it('leaves the mapping where it was when main refuses the byte order', async () => { + const { client, clientUndo } = await load() + await client().setType('holding_registers') + client().setRegisterMapping(3, 'comment', 'pump') + await client().setLittleEndian(true) + await clientUndo.asOneClientStep(async () => { + await client().setLittleEndian(false) + await client().clearRegisterMapping() + }) + refuse('updateRegisterConfig') + + expect(await clientUndo.undoClient()).toBe('refused') + expect(client().registerMapping.holding_registers[3]).toBeUndefined() + }) + + it('records its step while an undo that started first is still quiet', async () => { + const { client, undo, clientUndo } = await load() + client().setName('boiler') + undo().beginQuiet() + + await clientUndo.asOneClientStep(async () => { + client().setName('') + }) + undo().endQuiet() + + expect(undo().client.past.at(-1)).toEqual({ + kind: 'configuration', + value: expect.objectContaining({ name: 'boiler' }) + }) + }) + + it('records the step of an action that threw after it changed something', async () => { + const { client, undo, clientUndo } = await load() + client().setName('boiler') + + await expect( + clientUndo.asOneClientStep(async () => { + client().setName('') + throw new Error('the file went away') + }) + ).rejects.toThrow('the file went away') + + expect(undo().client.past.at(-1)).toEqual({ + kind: 'configuration', + value: expect.objectContaining({ name: 'boiler' }) + }) + }) + + it('records nothing for a Clear Config with nothing to clear', async () => { + const { client, undo, clientUndo } = await load() + + await clientUndo.asOneClientStep(async () => { + client().setName('') + await client().clearRegisterMapping() + }) + + expect(undo().client.past).toEqual([]) + }) + + it('records nothing when the action changed nothing', async () => { + const { undo, clientUndo } = await load() + + await clientUndo.asOneClientStep(async () => {}) + + expect(undo().client.past).toEqual([]) + }) +}) diff --git a/src/renderer/src/context/__tests__/undoStack.test.ts b/src/renderer/src/context/__tests__/undoStack.test.ts new file mode 100644 index 00000000..f40e2607 --- /dev/null +++ b/src/renderer/src/context/__tests__/undoStack.test.ts @@ -0,0 +1,76 @@ +// A run of writes to one field is one step, and anything else in between ends +// the run. An undo or a redo ends it too, so typing after an undo is a step of +// its own rather than a merge into the one just undone. +import { describe, expect, it } from 'vitest' +import { emptyStack, moveStep, pushStep, UNDO_LIMIT } from '../undo.zustand.helpers' + +describe('pushing a step', () => { + it('merges a write into the open run and keeps the value from before the run', () => { + let stack = pushStep(emptyStack(), 'before the run', 'field.host') + stack = pushStep(stack, 'halfway', 'field.host') + + expect(stack.past).toEqual(['before the run']) + }) + + it('starts a new step once another key has come in between', () => { + let stack = pushStep(emptyStack(), 'host 1', 'field.host') + stack = pushStep(stack, 'port', 'field.port') + stack = pushStep(stack, 'host 2', 'field.host') + + expect(stack.past).toEqual(['host 1', 'port', 'host 2']) + }) + + it('never merges a step with no key', () => { + let stack = pushStep(emptyStack(), 'load 1', undefined) + stack = pushStep(stack, 'load 2', undefined) + + expect(stack.past).toEqual(['load 1', 'load 2']) + }) + + it('leaves nothing to redo, merged or not', () => { + let stack = pushStep(emptyStack(), 'host', 'field.host') + stack = moveStep(stack, 'undo', 'host', 'host after') + expect(stack.future).toEqual(['host after']) + + expect(pushStep(stack, 'port', 'field.port').future).toEqual([]) + }) + + it(`keeps the newest ${UNDO_LIMIT} and drops the oldest`, () => { + let stack = emptyStack() + for (let step = 0; step <= UNDO_LIMIT; step++) stack = pushStep(stack, step, `field.${step}`) + + expect(stack.past).toHaveLength(UNDO_LIMIT) + expect(stack.past[0]).toBe(1) + expect(stack.past.at(-1)).toBe(UNDO_LIMIT) + }) +}) + +describe('moving a step', () => { + it('takes the step that was replayed, not whatever landed on top meanwhile', () => { + let stack = pushStep(emptyStack(), 'replayed', 'field.host') + stack = pushStep(stack, 'a load', undefined) + + stack = moveStep(stack, 'undo', 'replayed', 'host after') + + expect(stack.past).toEqual(['a load']) + expect(stack.future).toEqual(['host after']) + }) + + it('carries the replaced value across, so the opposite move writes it back', () => { + let stack = pushStep(emptyStack(), 'old', 'field.host') + stack = moveStep(stack, 'undo', 'old', 'new') + expect(stack).toEqual({ past: [], future: ['new'], openKey: undefined }) + + stack = moveStep(stack, 'redo', 'new', 'old') + expect(stack).toEqual({ past: ['old'], future: [], openKey: undefined }) + }) + + it('closes the run, so the next write to the same field is a step of its own', () => { + let stack = pushStep(emptyStack(), 'first run', 'field.host') + stack = pushStep(stack, 'second run', 'field.port') + stack = moveStep(stack, 'undo', 'second run', 'port after') + stack = pushStep(stack, 'third run', 'field.port') + + expect(stack.past).toEqual(['first run', 'third run']) + }) +}) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 2f36b2d3..edf0883e 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -27,6 +27,9 @@ import { import { showMapping, useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' +import { useUndoZustand } from './undo.zustand' +import { clientFieldReaders, clientFieldSteps } from './undo.zustand.helpers' +import { ClientField, ClientFieldValues } from './undo.zustand.types' /** * The version the blob on disk carried, set by `migrate` and read once below. @@ -126,6 +129,22 @@ const clearRegisterDataWhenIdle = (readsTheMapping: boolean): void => { const isDisconnected = (): boolean => useDataZustand.getState().clientState.connectState === 'disconnected' +/** + * Records the value a field had, when a write left the store holding another. + * + * Called after the `set`, including where an invalid value is kept and never + * sent: clearing the host and typing a new one is one run, and the run starts + * from the host there was before the field was cleared. + */ +const recordField = ( + field: Field, + before: ClientFieldValues[Field], + after: ClientFieldValues[Field] +): void => { + if (before === after) return + useUndoZustand.getState().recordClient(clientFieldSteps[field](before)) +} + /** * One serial option, sent and then written where main took it. * @@ -142,12 +161,14 @@ const setSerialOption = async ( if (!get().ready) return false if (!isDisconnected()) return false + const before = clientFieldReaders[key](get()) if (!(await window.api.updateConnectionConfig({ rtu: { options: { [key]: value } } }))) return false set((state) => { state.connectionConfig.rtu.options[key] = value }) + recordField(key, before, clientFieldReaders[key](get())) return true } @@ -166,11 +187,13 @@ const setRegisterConfigField = async ( value: RegisterConfig[Key] ): Promise => { if (!get().ready) return false + const before = get().registerConfig[key] if (!(await window.api.updateRegisterConfig({ [key]: value }))) return false set((state) => { state.registerConfig[key] = value }) + recordField(key, before, value) return true } @@ -222,10 +245,13 @@ export const useClientZustand = create< connectionConfig: defaultConnectionConfig, registerConfig: defaultRegisterConfig, name: '', - setName: (name) => + setName: (name) => { + const before = get().name set((state) => { state.name = name - }), + }) + recordField('name', before, name) + }, configReset: undefined, acknowledgeConfigReset: () => set((state) => { @@ -234,6 +260,7 @@ export const useClientZustand = create< registerMapping: emptyRegisterMapping(), setRegisterMapping: (register, key, value) => { const type = get().registerConfig.type + const before = get().registerMapping[type][register] set((state) => { // Remove register from mapping when data type is set to 'none' @@ -255,6 +282,18 @@ export const useClientZustand = create< state.registerMapping[type][register][key] = value }) + if (get().registerMapping[type][register] !== before) { + useUndoZustand + .getState() + .recordClient({ kind: 'mapping', type, register, column: key, value: before }) + } + syncRegisterMappingToMain() + }, + setMappingEntry: (type, register, entry) => { + set((state) => { + if (entry === undefined) delete state.registerMapping[type][register] + else state.registerMapping[type][register] = entry + }) syncRegisterMappingToMain() }, replaceRegisterMapping: async (registerMapping) => { @@ -310,11 +349,13 @@ export const useClientZustand = create< if (!get().ready) return false if (!isDisconnected()) return false + const before = get().connectionConfig.protocol if (!(await window.api.updateConnectionConfig({ protocol }))) return false set((state) => { state.connectionConfig.protocol = protocol }) + recordField('protocol', before, protocol) return true }, // @@ -325,18 +366,22 @@ export const useClientZustand = create< if (!isDisconnected()) return false const newPort = Number(port) + const before = get().connectionConfig.tcp.options.port if (!(await window.api.updateConnectionConfig({ tcp: { options: { port: newPort } } }))) return false set((state) => { state.connectionConfig.tcp.options.port = newPort }) + recordField('port', before, newPort) return true }, setHost: async (host, valid) => { if (!get().ready) return false if (!isDisconnected()) return false + const before = get().connectionConfig.tcp.host + // The field reads its text from the store, so an invalid host is kept // here and never sent. What the boundary never sees needs no answer. if (!valid) { @@ -344,6 +389,7 @@ export const useClientZustand = create< state.valid.host = false state.connectionConfig.tcp.host = host }) + recordField('host', before, host) return false } @@ -353,6 +399,7 @@ export const useClientZustand = create< state.valid.host = true state.connectionConfig.tcp.host = host }) + recordField('host', before, host) return true }, // @@ -366,11 +413,13 @@ export const useClientZustand = create< // kept here and never sent. `ConnectionConfigRtuSchema` types `com` as // a string and takes a blank one, so the boundary had nothing to refuse // and main held a connection config naming no port. + const before = get().connectionConfig.rtu.com if (!valid) { set((state) => { state.valid.com = false state.connectionConfig.rtu.com = com }) + recordField('com', before, com) return false } @@ -380,6 +429,7 @@ export const useClientZustand = create< state.valid.com = true state.connectionConfig.rtu.com = com }) + recordField('com', before, com) return true }, setBaudRate: (baudRate) => setSerialOption(set, get, 'baudRate', baudRate), @@ -411,6 +461,7 @@ export const useClientZustand = create< set((state) => { state.connectionConfig.unitId = newUnitId }) + recordField('unitId', currentState.connectionConfig.unitId, newUnitId) clearRegisterDataWhenIdle(true) return true }, @@ -426,6 +477,7 @@ export const useClientZustand = create< set((state) => { state.registerConfig.address = newAddress }) + recordField('address', currentState.registerConfig.address, newAddress) clearRegisterDataWhenIdle(false) return true }, @@ -442,6 +494,7 @@ export const useClientZustand = create< state.valid.length = false state.registerConfig.length = newLength }) + recordField('length', currentState.registerConfig.length, newLength) return false } @@ -451,26 +504,31 @@ export const useClientZustand = create< state.valid.length = true state.registerConfig.length = newLength }) + recordField('length', currentState.registerConfig.length, newLength) clearRegisterDataWhenIdle(false) return true }, setType: async (type) => { if (!get().ready) return false + const before = get().registerConfig.type if (!(await window.api.updateRegisterConfig({ type }))) return false set((state) => { state.registerConfig.type = type }) + recordField('type', before, type) clearRegisterDataWhenIdle(true) return true }, setLittleEndian: async (littleEndian) => { if (!get().ready) return false + const before = get().registerConfig.littleEndian if (!(await window.api.updateRegisterConfig({ littleEndian }))) return false set((state) => { state.registerConfig.littleEndian = littleEndian }) + recordField('littleEndian', before, littleEndian) // The rows on screen were read in the other word order, and the // conversion happens where the reading does, so they stay that way diff --git a/src/renderer/src/context/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 1ff78a89..4928a965 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -41,6 +41,15 @@ export type ClientZustand = { key: K, value: V ) => void + /** + * Puts one register's entry back whole, or removes it, under the type named + * rather than the one on screen. What an undo of a mapping edit replays. + */ + setMappingEntry: ( + type: RegisterType, + register: number, + entry: RegisterMapValue | undefined + ) => void /** Answers once main has the mapping, because the store writes it after that. */ replaceRegisterMapping: (registerMapping: RegisterMapping) => Promise clearRegisterMapping: () => Promise diff --git a/src/renderer/src/context/clientUndo.ts b/src/renderer/src/context/clientUndo.ts new file mode 100644 index 00000000..cdebc4d0 --- /dev/null +++ b/src/renderer/src/context/clientUndo.ts @@ -0,0 +1,179 @@ +import { isConnectionAddressGiven, isReadLengthGiven } from '@shared' +import { deepEqual } from 'fast-equals' +import { useClientZustand } from './client.zustand' +import { showMapping } from './data.zustand' +import { useUndoZustand } from './undo.zustand' +import { + clientFieldReaders, + clientFieldSteps, + clientStepKey, + moveStep, + pushStep +} from './undo.zustand.helpers' +import { + ClientConfiguration, + ClientConfigurationStep, + ClientField, + ClientFieldStep, + ClientFieldStepOf, + ClientFieldValues, + ClientMappingStep, + ClientUndoStep, + UndoOutcome +} from './undo.zustand.types' + +/** + * Each field through the setter a user's edit goes through, so a replay passes + * the same guards and reaches main the same way. The validity a masked field + * reports is read off the value, as the field itself reads it. + */ +const clientFieldWriters: { + [Field in ClientField]: (value: ClientFieldValues[Field]) => Promise +} = { + name: async (value) => { + useClientZustand.getState().setName(value) + return true + }, + protocol: (value) => useClientZustand.getState().setProtocol(value), + unitId: (value) => useClientZustand.getState().setUnitId(String(value)), + host: (value) => useClientZustand.getState().setHost(value, isConnectionAddressGiven(value)), + port: (value) => useClientZustand.getState().setPort(String(value)), + com: (value) => useClientZustand.getState().setCom(value, isConnectionAddressGiven(value)), + baudRate: (value) => useClientZustand.getState().setBaudRate(value), + parity: (value) => useClientZustand.getState().setParity(value), + dataBits: (value) => useClientZustand.getState().setDataBits(value), + stopBits: (value) => useClientZustand.getState().setStopBits(value), + address: (value) => useClientZustand.getState().setAddress(String(value)), + length: (value) => useClientZustand.getState().setLength(String(value), isReadLengthGiven(value)), + type: (value) => useClientZustand.getState().setType(value), + pollRate: (value) => useClientZustand.getState().setPollRate(value), + timeout: (value) => useClientZustand.getState().setTimeout(value), + littleEndian: (value) => useClientZustand.getState().setLittleEndian(value), + advancedMode: (value) => useClientZustand.getState().setAdvancedMode(value), + show64BitValues: (value) => useClientZustand.getState().setShow64BitValues(value), + addressBase: (value) => useClientZustand.getState().setAddressBase(value) +} + +/** + * Writes a step's value, and answers the step that would write back what was + * there, or undefined when the store does not hold the value afterwards. + * + * The store is what is asked, not the setter's answer: an invalid host, COM + * port or length is kept in the store and never sent, and its setter answers + * `false` for that, but the step it came from is put back all the same. + */ +const replayField = async ( + step: ClientFieldStepOf +): Promise => { + const read = clientFieldReaders[step.field] + const replaced = clientFieldSteps[step.field](read(useClientZustand.getState())) + await clientFieldWriters[step.field](step.value) + return read(useClientZustand.getState()) === step.value ? replaced : undefined +} + +/** Shows the register type the entry belongs to first, so the change is on screen. */ +const replayMapping = async (step: ClientMappingStep): Promise => { + const client = useClientZustand.getState() + if (client.registerConfig.type !== step.type && !(await client.setType(step.type))) { + return undefined + } + + const replaced: ClientMappingStep = { + ...step, + value: useClientZustand.getState().registerMapping[step.type][step.register] + } + useClientZustand.getState().setMappingEntry(step.type, step.register, step.value) + return replaced +} + +const currentConfiguration = (): ClientConfiguration => { + const { name, registerConfig, registerMapping } = useClientZustand.getState() + return { name, littleEndian: registerConfig.littleEndian, registerMapping } +} + +/** + * Puts back what Load or Clear Config replaced, as a whole or not at all. + * + * The byte order goes first and is put back if main then refuses the mapping, + * so a refused step leaves both where they were. The refusal still costs read + * configuration, which `replaceRegisterMapping` turns off before it asks. + */ +const replayConfiguration = async ( + step: ClientConfigurationStep +): Promise => { + const replaced: ClientConfigurationStep = { kind: 'configuration', value: currentConfiguration() } + const client = useClientZustand.getState() + + if (!(await client.setLittleEndian(step.value.littleEndian))) return undefined + if (!(await client.replaceRegisterMapping(step.value.registerMapping))) { + await client.setLittleEndian(replaced.value.littleEndian) + return undefined + } + client.setName(step.value.name) + showMapping() + return replaced +} + +const replay = (step: ClientUndoStep): Promise => { + switch (step.kind) { + case 'field': + return replayField(step) + case 'mapping': + return replayMapping(step) + case 'configuration': + return replayConfiguration(step) + } +} + +const move = async (direction: 'undo' | 'redo'): Promise => { + const undo = useUndoZustand.getState() + if (undo.quiet > 0) return 'busy' + + const steps = direction === 'undo' ? undo.client.past : undo.client.future + const step = steps.at(-1) + if (!step) return 'empty' + + undo.beginQuiet() + let replaced: ClientUndoStep | undefined + try { + replaced = await replay(step) + } finally { + undo.endQuiet() + } + if (!replaced) return 'refused' + + undo.setClient(moveStep(useUndoZustand.getState().client, direction, step, replaced)) + return 'done' +} + +export const undoClient = (): Promise => move('undo') +export const redoClient = (): Promise => move('redo') + +/** + * Runs an action that replaces the configuration, and records it as one step. + * + * Load writes the name, the byte order and the mapping, and Clear Config the + * name and the mapping. Each setter would record its own step; quiet while the + * action runs, they record none, and the step recorded after carries all three + * as they were. That step goes on the stack directly rather than through + * `recordClient`, which would drop it while an undo that started first is + * still quiet. + */ +export const asOneClientStep = async (action: () => Promise): Promise => { + const before = currentConfiguration() + const undo = useUndoZustand.getState() + undo.beginQuiet() + try { + await action() + } finally { + // In the `finally`, because a Load that throws after the name and the byte + // order went in has still changed them. + undo.endQuiet() + // By content, because Clear Config hands over a new empty mapping every + // time, and one that was empty already is no step. + if (!deepEqual(currentConfiguration(), before)) { + const step: ClientConfigurationStep = { kind: 'configuration', value: before } + undo.setClient(pushStep(useUndoZustand.getState().client, step, clientStepKey(step))) + } + } +} diff --git a/src/renderer/src/context/undo.zustand.helpers.ts b/src/renderer/src/context/undo.zustand.helpers.ts new file mode 100644 index 00000000..980156a2 --- /dev/null +++ b/src/renderer/src/context/undo.zustand.helpers.ts @@ -0,0 +1,128 @@ +import { ClientZustand } from './client.zustand.types' +import { + ClientField, + ClientFieldStepMap, + ClientFieldValues, + ClientUndoStep, + UndoStack +} from './undo.zustand.types' + +/** What the store holds for each field, as a step records it. */ +export const clientFieldReaders: { + [Field in ClientField]: (state: ClientZustand) => ClientFieldValues[Field] +} = { + name: (state) => state.name, + protocol: (state) => state.connectionConfig.protocol, + unitId: (state) => state.connectionConfig.unitId, + host: (state) => state.connectionConfig.tcp.host, + port: (state) => state.connectionConfig.tcp.options.port, + com: (state) => state.connectionConfig.rtu.com, + baudRate: (state) => state.connectionConfig.rtu.options.baudRate, + parity: (state) => state.connectionConfig.rtu.options.parity ?? 'none', + dataBits: (state) => state.connectionConfig.rtu.options.dataBits, + stopBits: (state) => state.connectionConfig.rtu.options.stopBits, + address: (state) => state.registerConfig.address, + length: (state) => state.registerConfig.length, + type: (state) => state.registerConfig.type, + pollRate: (state) => state.registerConfig.pollRate, + timeout: (state) => state.registerConfig.timeout, + littleEndian: (state) => state.registerConfig.littleEndian, + advancedMode: (state) => state.registerConfig.advancedMode, + show64BitValues: (state) => state.registerConfig.show64BitValues, + addressBase: (state) => state.registerConfig.addressBase +} + +/** Makes the step for a field, typed to the value that field's setter takes. */ +export const clientFieldSteps: { + [Field in ClientField]: (value: ClientFieldStepMap[Field]['value']) => ClientFieldStepMap[Field] +} = { + name: (value) => ({ kind: 'field', field: 'name', value }), + protocol: (value) => ({ kind: 'field', field: 'protocol', value }), + unitId: (value) => ({ kind: 'field', field: 'unitId', value }), + host: (value) => ({ kind: 'field', field: 'host', value }), + port: (value) => ({ kind: 'field', field: 'port', value }), + com: (value) => ({ kind: 'field', field: 'com', value }), + baudRate: (value) => ({ kind: 'field', field: 'baudRate', value }), + parity: (value) => ({ kind: 'field', field: 'parity', value }), + dataBits: (value) => ({ kind: 'field', field: 'dataBits', value }), + stopBits: (value) => ({ kind: 'field', field: 'stopBits', value }), + address: (value) => ({ kind: 'field', field: 'address', value }), + length: (value) => ({ kind: 'field', field: 'length', value }), + type: (value) => ({ kind: 'field', field: 'type', value }), + pollRate: (value) => ({ kind: 'field', field: 'pollRate', value }), + timeout: (value) => ({ kind: 'field', field: 'timeout', value }), + littleEndian: (value) => ({ kind: 'field', field: 'littleEndian', value }), + advancedMode: (value) => ({ kind: 'field', field: 'advancedMode', value }), + show64BitValues: (value) => ({ kind: 'field', field: 'show64BitValues', value }), + addressBase: (value) => ({ kind: 'field', field: 'addressBase', value }) +} + +/** How many steps a stack keeps; the oldest goes first. */ +export const UNDO_LIMIT = 100 + +export const emptyStack = (): UndoStack => ({ + past: [], + future: [], + openKey: undefined +}) + +/** + * The run a client step merges into, or undefined for a step that never merges. + * + * A mapping step is keyed by its column, so typing into one scaling factor is + * one step while the entry it carries is the whole register. Load and Clear + * Config never merge: two loads in a row are two things to undo. + */ +export const clientStepKey = (step: ClientUndoStep): string | undefined => { + switch (step.kind) { + case 'field': + return `field.${step.field}` + case 'mapping': + return `mapping.${step.type}.${step.register}.${step.column}` + case 'configuration': + return undefined + } +} + +/** + * The stack with a new step on it, and nothing left to redo. + * + * A step whose key is the open run's is not added: the run already holds the + * value from before its first write, which is what an undo puts back. + */ +export const pushStep = ( + stack: UndoStack, + step: Step, + key: string | undefined +): UndoStack => { + if (key !== undefined && key === stack.openKey) return { ...stack, future: [] } + return { past: [...stack.past, step].slice(-UNDO_LIMIT), future: [], openKey: key } +} + +/** + * Moves a replayed step off `from` onto `to`, carrying the value it replaced. + * + * Undo moves from `past` to `future` and redo the other way. The value a step + * carries is the one its replay writes, so the step that goes across carries + * the one that was there before the replay: that is what the opposite move + * writes back. Either move closes the open run. + * + * The step is found by identity rather than taken off the top, because a Load + * that finished while the replay waited on main has put its own step there. + */ +export const moveStep = ( + stack: UndoStack, + direction: 'undo' | 'redo', + replayed: Step, + replaced: Step +): UndoStack => { + const from = direction === 'undo' ? stack.past : stack.future + const to = direction === 'undo' ? stack.future : stack.past + const moved = { + from: from.filter((step) => step !== replayed), + to: [...to, replaced].slice(-UNDO_LIMIT) + } + return direction === 'undo' + ? { past: moved.from, future: moved.to, openKey: undefined } + : { past: moved.to, future: moved.from, openKey: undefined } +} diff --git a/src/renderer/src/context/undo.zustand.ts b/src/renderer/src/context/undo.zustand.ts new file mode 100644 index 00000000..65a82f77 --- /dev/null +++ b/src/renderer/src/context/undo.zustand.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' +import { clientStepKey, emptyStack, pushStep } from './undo.zustand.helpers' +import { UndoZustand } from './undo.zustand.types' + +/** + * The undo and redo stacks, one per store. + * + * No persist middleware: history does not outlive a launch. The stores record + * into this one and never read it back, and the modules that replay a step + * import both, so neither store imports its own replay. + */ +export const useUndoZustand = create()((set, get) => ({ + client: emptyStack(), + quiet: 0, + recordClient: (step): void => { + // A write while quiet is not recorded, and it ends the open run, so the + // next write to that field does not merge into a step from before it. + if (get().quiet > 0) { + set({ client: { ...get().client, openKey: undefined } }) + return + } + set({ client: pushStep(get().client, step, clientStepKey(step)) }) + }, + setClient: (client): void => set({ client }), + beginQuiet: (): void => set({ quiet: get().quiet + 1 }), + endQuiet: (): void => set({ quiet: get().quiet - 1 }) +})) diff --git a/src/renderer/src/context/undo.zustand.types.ts b/src/renderer/src/context/undo.zustand.types.ts new file mode 100644 index 00000000..460cc138 --- /dev/null +++ b/src/renderer/src/context/undo.zustand.types.ts @@ -0,0 +1,108 @@ +import { + ConnectionConfig, + Parity, + RegisterConfig, + RegisterMapping, + RegisterMapValue, + RegisterType, + SerialPortOptions +} from '@shared' + +/** + * Every client field an undo can put back, keyed by the name a step carries. + * + * `parity` is optional in the store, because a config from before it existed + * carries none, and it is read here as the `none` a missing one means. A step + * holding `undefined` would be put back as `none` and never compare equal. + */ +export type ClientFieldValues = RegisterConfig & + Omit & { + parity: Parity + name: string + protocol: ConnectionConfig['protocol'] + unitId: ConnectionConfig['unitId'] + host: string + port: number + com: string + } + +export type ClientField = keyof ClientFieldValues + +/** One field, the value it had before the run of writes this step stands for. */ +export type ClientFieldStepMap = { + [Field in ClientField]: { + kind: 'field' + field: Field + value: ClientFieldValues[Field] + } +} + +/** + * A field step, generic over the field so a step and its replay are checked as + * one pair. TypeScript correlates `step.field` with `step.value` only through a + * table indexed by the field, which is why the steps are made and replayed + * through tables rather than built as literals. + */ +export type ClientFieldStepOf = { + [Field in Fields]: ClientFieldStepMap[Field] +}[Fields] + +export type ClientFieldStep = ClientFieldStepOf + +/** + * One register's mapping entry, whole, as it was before a column of it changed. + * + * The entry rather than the column, because setting a data type of `none` + * deletes the entry and every other column in it goes with it. + */ +export interface ClientMappingStep { + kind: 'mapping' + type: RegisterType + register: number + column: keyof RegisterMapValue + value: RegisterMapValue | undefined +} + +/** What Load and Clear Config replace together, so one undo puts all of it back. */ +export interface ClientConfiguration { + name: string + littleEndian: boolean + registerMapping: RegisterMapping +} + +export interface ClientConfigurationStep { + kind: 'configuration' + value: ClientConfiguration +} + +export type ClientUndoStep = ClientFieldStep | ClientMappingStep | ClientConfigurationStep + +/** + * The steps of one store, oldest first. + * + * `openKey` names the run the next write may merge into. A write to the same + * field merges while nothing else has happened in between, and an undo or a + * redo closes the run, so typing after an undo starts a step of its own. + */ +export interface UndoStack { + past: Step[] + future: Step[] + openKey: string | undefined +} + +export type UndoOutcome = 'done' | 'refused' | 'empty' | 'busy' + +export interface UndoZustand { + client: UndoStack + /** + * How many replays and actions that write several fields are running. A + * setter records nothing while it is above zero: the replay is the step, and + * the action records itself once. A count, because a Load can start while an + * undo waits on main, and the first to finish must not end the other's. + */ + quiet: number + recordClient: (step: ClientUndoStep) => void + setClient: (stack: UndoStack) => void + beginQuiet: () => void + endQuiet: () => void +} From 1d3ce376f9b95fafe25339ff43a5d5b1f9cd586c Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 21:44:12 +0200 Subject: [PATCH 101/137] feat: undo and redo for the server configuration The server store records into `undo.zustand` as the client store does, and `serverUndo` replays a step through the store's own actions, with `replayTop` now shared between the two. Nothing calls `undoServer` or `redoServer` yet. A step is one of these, and each replay first shows the server, and the unit where there is one, so what changes is on screen: - A whole server, or its absence: creating, deleting, Clear and Open. The replay deletes it, or resets or creates it and hands main the record through `restoreServer` and `init`. The main server is never recorded as created, since it has no Delete and `init` makes it on a launch with none. - What one unit holds: adding, removing, moving and resetting registers and bools, and a bool's comment. The replay writes the unit back through `restoreUnit`, which hands main the difference: a register removed or added where its params differ, and the bools whole where which ones exist changed. `sync_server_register` would add every register back from its params, and a value a master wrote into one the step never touched would go with it. A register and a coil keep the value written since, a register main adds back shows the words main answers with, and a change is told from a written value by `unitStructure`, which leaves the values out and reads a unit of four empty maps as no unit. - One coil the user switched, through the new `toggleBool`. `setBool` is what the `register_value` batcher calls, and it records nothing. - The name, typed a key at a time and merged, the port and the byte order. A port main keeps rather than binds is refused and stays on the stack. A move in the register dialog, a remove and an add, and server Clear and Open each record one step through `asOneServerUnitStep` and `asOneServerStep`. The main window empties its server steps when it re-reads the store after the split out window closes, because they describe the store from before the split. Carrying the steps across the split is a commit of its own. A replay for a server the store no longer holds is refused. No path in one window reaches that, because the stack describes every change the window made; the test reaches it by writing the store outside the stack. `serverUndo.test.ts` drives the store through the renderer stub and reads what main was handed. A mutation per rule turned that rule's test red. --- .../src/components/server/OpenSaveClear.tsx | 166 ++++---- .../server/ServerGrid/ServerBooleans.tsx | 6 +- .../AddRegister/addRegister.zustand.ts | 34 +- .../src/context/__tests__/serverUndo.test.ts | 371 ++++++++++++++++++ src/renderer/src/context/clientUndo.ts | 31 +- src/renderer/src/context/server.zustand.ts | 184 ++++++++- .../src/context/server.zustand.types.ts | 16 + src/renderer/src/context/serverUndo.ts | 197 ++++++++++ .../src/context/undo.zustand.helpers.ts | 41 ++ src/renderer/src/context/undo.zustand.ts | 51 ++- .../src/context/undo.zustand.types.ts | 72 +++- 11 files changed, 1046 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/context/__tests__/serverUndo.test.ts create mode 100644 src/renderer/src/context/serverUndo.ts diff --git a/src/renderer/src/components/server/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear.tsx index 759cc892..b57a1f43 100644 --- a/src/renderer/src/components/server/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear.tsx @@ -7,6 +7,7 @@ import { downloadJson } from '@renderer/components/shared/downloadJson' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useServerZustand } from '@renderer/context/server.zustand' +import { asOneServerStep } from '@renderer/context/serverUndo' import { checkHasConfig, migrateServerConfig, resetMessage } from '@shared' import { CURRENT_SERVER_CONFIG_VERSION, @@ -43,86 +44,93 @@ const useOpen: UseOpenHook = () => { const serverZustand = useServerZustand.getState() try { - const content = await file.text() - - // Use migration framework to handle all config versions - const migrationResult = migrateServerConfig(content) - const { config, migrated, wasMixedEndianness, futureVersion } = migrationResult - - // A unit the file does not name is not written over on the way in, so - // whatever the previous config left on it would answer a master after - // this one is loaded. Reset takes both sides down to nothing first. - // - // After the migration rather than before it, because `migrateServerConfig` - // needs nothing from the store and throws on both shapes a user picks by - // accident, a client config file and malformed JSON. Reset first, and the - // configuration on screen was gone in main and in the store before - // anything had read the file. - await serverZustand.resetServer(serverZustand.selectedUuid) - - // Set name and littleEndian - serverZustand.setName(config.name) - serverZustand.setLittleEndian(config.littleEndian) - - // Load all unit configs - for (const unitId of UnitIdStringSchema.options) { - const serverRegisters = config.serverRegistersPerUnit[unitId] - if (!serverRegisters) continue - const hasConfig = checkHasConfig(serverRegisters) - if (!hasConfig) continue - serverZustand.replaceServerRegisters(unitId, serverRegisters) - } - - // Show success notification - if (migrated) { - enqueueSnackbar({ - variant: 'info', - message: 'Configuration updated from older format', - autoHideDuration: 5000 - }) - } else { - enqueueSnackbar({ - variant: 'success', - message: 'Configuration opened successfully' - }) - } - - // Show warning for mixed endianness - if (wasMixedEndianness) { - enqueueSnackbar({ - variant: 'warning', - message: `Warning: Config had mixed byte order settings. Now using ${config.littleEndian ? 'Little' : 'Big'}-Endian globally. Please verify.`, - autoHideDuration: 8000 - }) - } - - // A config from a newer Modbux is parsed against the current schema and - // keeps what matches, so the warning says which fields did not come - // across rather than that some feature may not work. - if (futureVersion) { - enqueueSnackbar({ - variant: 'warning', - message: resetMessage('Server', futureVersion), - // A notice that everything came across is not one the user has to - // dismiss, and it sits on top of "Configuration opened - // successfully" either way. - persist: futureVersion.fields.length > 0, - autoHideDuration: 8000 - }) - } - } catch (error) { - const tError = error as Error - enqueueSnackbar({ variant: 'error', message: `Failed to load config: ${tError.message}` }) - console.error('Config load error:', error) + // One step to undo, the reset and the `init` after it included. + await asOneServerStep(serverZustand.selectedUuid, async () => { + try { + const content = await file.text() + + // Use migration framework to handle all config versions + const migrationResult = migrateServerConfig(content) + const { config, migrated, wasMixedEndianness, futureVersion } = migrationResult + + // A unit the file does not name is not written over on the way in, so + // whatever the previous config left on it would answer a master after + // this one is loaded. Reset takes both sides down to nothing first. + // + // After the migration rather than before it, because `migrateServerConfig` + // needs nothing from the store and throws on both shapes a user picks by + // accident, a client config file and malformed JSON. Reset first, and the + // configuration on screen was gone in main and in the store before + // anything had read the file. + await serverZustand.resetServer(serverZustand.selectedUuid) + + // Set name and littleEndian + serverZustand.setName(config.name) + serverZustand.setLittleEndian(config.littleEndian) + + // Load all unit configs + for (const unitId of UnitIdStringSchema.options) { + const serverRegisters = config.serverRegistersPerUnit[unitId] + if (!serverRegisters) continue + const hasConfig = checkHasConfig(serverRegisters) + if (!hasConfig) continue + serverZustand.replaceServerRegisters(unitId, serverRegisters) + } + + // Show success notification + if (migrated) { + enqueueSnackbar({ + variant: 'info', + message: 'Configuration updated from older format', + autoHideDuration: 5000 + }) + } else { + enqueueSnackbar({ + variant: 'success', + message: 'Configuration opened successfully' + }) + } + + // Show warning for mixed endianness + if (wasMixedEndianness) { + enqueueSnackbar({ + variant: 'warning', + message: `Warning: Config had mixed byte order settings. Now using ${config.littleEndian ? 'Little' : 'Big'}-Endian globally. Please verify.`, + autoHideDuration: 8000 + }) + } + + // A config from a newer Modbux is parsed against the current schema and + // keeps what matches, so the warning says which fields did not come + // across rather than that some feature may not work. + if (futureVersion) { + enqueueSnackbar({ + variant: 'warning', + message: resetMessage('Server', futureVersion), + // A notice that everything came across is not one the user has to + // dismiss, and it sits on top of "Configuration opened + // successfully" either way. + persist: futureVersion.fields.length > 0, + autoHideDuration: 8000 + }) + } + } catch (error) { + const tError = error as Error + enqueueSnackbar({ + variant: 'error', + message: `Failed to load config: ${tError.message}` + }) + console.error('Config load error:', error) + } finally { + // `init` runs either way: on a refused file it hands main the + // configuration that is still there, which is the one on screen. + await serverZustand.init(serverZustand.selectedUuid) + } + }) } finally { // In the `finally` because `file.text()` is inside the `try` now, and a // file that is gone by the time it is read left `opening` true and // every server button disabled until the app was restarted. - // - // `init` runs either way: on a refused file it hands main the - // configuration that is still there, which is the one on screen. - await serverZustand.init(serverZustand.selectedUuid) - openingRef.current = false setOpening(false) } @@ -180,8 +188,10 @@ const OpenSaveClear = meme(() => { const clear = useCallback(async () => { const serverZustand = useServerZustand.getState() - serverZustand.setName('') - await serverZustand.resetServer(serverZustand.selectedUuid) + await asOneServerStep(serverZustand.selectedUuid, async () => { + serverZustand.setName('') + await serverZustand.resetServer(serverZustand.selectedUuid) + }) }, []) return ( diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx index 2441b4b4..d1cccea5 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx @@ -38,11 +38,9 @@ const ServerBoolRow = meme(({ address, type }: ServerBoolRowProps) => { // it is the coil's address. const handleToggle = useCallback( (bitAddress: number) => { - useServerZustand - .getState() - .setBool({ registerType: type, address: bitAddress, boolState: !(entry?.value ?? false) }) + useServerZustand.getState().toggleBool(type, bitAddress) }, - [type, entry?.value] + [type] ) const handleCommentChange = useCallback( diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index dc49aedb..3295d296 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { MaskSetFn } from '@renderer/context/client.zustand.types' import { useServerZustand } from '@renderer/context/server.zustand' +import { asOneServerUnitStep } from '@renderer/context/serverUndo' import { BaseDataType, DataType, NumberRegisters, registerWidth, ServerRegister } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' @@ -322,25 +323,28 @@ export const useAddRegisterZustand = create { + if (moved) { + serverZustand.removeRegister({ + uuid, + unitId, + address: moved.address, + registerType, + dataType: moved.dataType, + length: moved.length + }) + } + + added = await serverZustand.addRegister({ uuid, unitId, params }) // The remove has already happened, so a refusal here would leave the // user with neither register and a message about only one of them. // What goes back is what main handed over in the first place, so the // restore is refused only if the schema changed under a running app. - if (moved) await serverZustand.addRegister({ uuid, unitId, params: moved }) - return undefined - } + if (!added && moved) await serverZustand.addRegister({ uuid, unitId, params: moved }) + }) + if (!added) return undefined return { address: params.address, dataType: form.dataType } }, diff --git a/src/renderer/src/context/__tests__/serverUndo.test.ts b/src/renderer/src/context/__tests__/serverUndo.test.ts new file mode 100644 index 00000000..03f4144c --- /dev/null +++ b/src/renderer/src/context/__tests__/serverUndo.test.ts @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom +// +// A server step replays through the store's own actions, and a step that +// changes what a unit holds hands main the difference, register by register. +// What a master or a generator wrote is no step, and a unit put back keeps it +// wherever the register is the same one. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAIN_SERVER_UUID, RegisterParams } from '@shared' +import { fireEvent, recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' +import { getDefaultServer } from '../server.zustand.helpers' + +vi.mock('notistack', () => ({ enqueueSnackbar: vi.fn() })) + +const SECOND_UUID = 'the-server-on-503' + +let calls: ApiCall[] + +/** Answers one channel differently from the stub, and leaves the rest to it. */ +const answerWith = (method: string, answer: (payload: unknown) => Promise): void => { + const underneath = window.api as unknown as Record + window.api = new Proxy( + {}, + { get: (_target, name: string): unknown => (name === method ? answer : underneath[name]) } + ) as never +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() + // Main binds what it is asked for, and answers the port it holds. + answerWith('createServer', (payload) => Promise.resolve((payload as { port: number }).port)) + answerWith('setServerPort', (payload) => Promise.resolve((payload as { port: number }).port)) + calls = [] + recordApiCalls(calls) +}) + +const holding = (address: number, comment: string): RegisterParams => ({ + address, + registerType: 'holding_registers', + dataType: 'uint16', + value: 7, + comment +}) + +const load = async (): Promise<{ + server: () => ReturnType + undo: () => ReturnType + serverUndo: typeof import('../serverUndo') +}> => { + const { useServerZustand } = await import('../server.zustand') + const { useUndoZustand } = await import('../undo.zustand') + const serverUndo = await import('../serverUndo') + useServerZustand.setState({ + selectedUuid: MAIN_SERVER_UUID, + servers: { + [MAIN_SERVER_UUID]: { ...getDefaultServer(), port: '502' }, + [SECOND_UUID]: { ...getDefaultServer(), port: '503' } + }, + ready: { [MAIN_SERVER_UUID]: true, [SECOND_UUID]: true } + }) + // Whatever the module's own `init` left there is not this test's. + useUndoZustand.setState({ server: { past: [], future: [], openKey: undefined } }) + return { + server: () => useServerZustand.getState(), + undo: () => useUndoZustand.getState(), + serverUndo + } +} + +/** The payload the named channel carried last. */ +const lastPayload = (method: string): unknown => + calls.filter((call) => call.method === method).at(-1)?.payload + +describe('a unit', () => { + it('loses a coil that was added, in the store and in main, and gets it back on redo', async () => { + const { server, serverUndo } = await load() + server().addBool('coils', 3) + + expect(await serverUndo.undoServer()).toBe('done') + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]).toBeUndefined() + expect((lastPayload('syncBools') as { coils: boolean[] }).coils[3]).toBe(false) + + expect(await serverUndo.redoServer()).toBe('done') + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]).toEqual({ value: false }) + }) + + it('keeps the value a master wrote into a coil since', async () => { + const { server, undo, serverUndo } = await load() + server().addBool('coils', 5) + server().addBool('coils', 3) + const steps = undo().server.past.length + server().setBool({ + registerType: 'coils', + address: 5, + boolState: true, + optionalUuid: MAIN_SERVER_UUID, + optionalUnitId: '0' + }) + expect(undo().server.past).toHaveLength(steps) + + await serverUndo.undoServer() + + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[5]).toEqual({ value: true }) + expect((lastPayload('syncBools') as { coils: boolean[] }).coils[5]).toBe(true) + }) + + it('gets a removed register back, and hands main that register alone', async () => { + const { server, serverUndo } = await load() + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(10, 'a') }) + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(20, 'b') }) + server().removeRegister({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'holding_registers', + address: 10, + dataType: 'uint16', + length: undefined + }) + calls.length = 0 + + await serverUndo.undoServer() + + expect( + server().servers[MAIN_SERVER_UUID]?.registers['0']?.holding_registers[10]?.params + ).toEqual(holding(10, 'a')) + expect(server().servers[MAIN_SERVER_UUID]?.usedAddresses['0']?.holding_registers).toContain(10) + expect(calls.map((call) => call.method)).toEqual(['addReplaceServerRegister']) + expect((lastPayload('addReplaceServerRegister') as { params: RegisterParams }).params).toEqual( + holding(10, 'a') + ) + }) + + it('keeps the value a master wrote into a register the step never touched', async () => { + const { server, serverUndo } = await load() + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(10, 'a') }) + server().addBool('coils', 3) + // After the step, so the value it holds is not the one it took. + server().setRegisterValue({ + registerType: 'holding_registers', + address: 10, + value: 99, + optionalUuid: MAIN_SERVER_UUID, + optionalUnitId: '0' + }) + calls.length = 0 + + await serverUndo.undoServer() + + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.holding_registers[10]?.value).toBe( + 99 + ) + expect(calls.map((call) => call.method)).toEqual(['syncBools']) + }) + + it('shows what main holds for a register it got back, not the value from before', async () => { + const { server, serverUndo } = await load() + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(10, 'a') }) + server().setRegisterValue({ + registerType: 'holding_registers', + address: 10, + value: 99, + optionalUuid: MAIN_SERVER_UUID, + optionalUnitId: '0' + }) + server().removeRegister({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'holding_registers', + address: 10, + dataType: 'uint16', + length: undefined + }) + // Main adds it back at its configured value and answers with that word. + answerWith('addReplaceServerRegister', () => Promise.resolve([7])) + + await serverUndo.undoServer() + + await vi.waitFor(() => + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.holding_registers[10]?.value).toBe( + 7 + ) + ) + }) + + it('records nothing for a reset of a unit nothing was ever written into', async () => { + const { server, undo } = await load() + + server().resetBools('coils') + server().resetRegisters('holding_registers') + + expect(undo().server.past).toEqual([]) + }) + + it('records nothing for a coil that was there already', async () => { + const { server, undo } = await load() + server().addBool('coils', 3) + + server().addBool('coils', 3) + + expect(undo().server.past).toHaveLength(1) + }) + + it('is refused for a server the store no longer holds', async () => { + const { server, undo, serverUndo } = await load() + const { useServerZustand } = await import('../server.zustand') + server().addBool('coils', 3) + // Written outside the stack, which no step describes. + useServerZustand.setState({ + servers: { [SECOND_UUID]: { ...getDefaultServer(), port: '503' } } + }) + + expect(await serverUndo.undoServer()).toBe('refused') + expect(undo().server.past).toHaveLength(1) + }) + + it('shows the server and the unit the step belongs to', async () => { + const { server, serverUndo } = await load() + server().addBool('coils', 3) + server().setSelectedUuid(SECOND_UUID) + + await serverUndo.undoServer() + + expect(server().selectedUuid).toBe(MAIN_SERVER_UUID) + }) +}) + +describe('the split out window closing', () => { + it('empties the steps this window held, which describe the store from before', async () => { + const { server, undo } = await load() + server().addBool('coils', 3) + + fireEvent('window_update', { server: true }) + fireEvent('window_update', { server: false }) + + expect(undo().server.past).toEqual([]) + }) +}) + +describe('a coil the user switched', () => { + it('switches back, while a switch main made records nothing', async () => { + const { server, undo, serverUndo } = await load() + server().addBool('coils', 3) + server().toggleBool('coils', 3) + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]?.value).toBe(true) + + await serverUndo.undoServer() + + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]?.value).toBe(false) + expect(undo().server.past).toHaveLength(1) + }) +}) + +describe('a server field', () => { + it('undoes a typed name in one step', async () => { + const { server, serverUndo } = await load() + for (const typed of ['b', 'bo', 'boiler']) server().setName(typed) + + await serverUndo.undoServer() + + expect(server().servers[MAIN_SERVER_UUID]?.name).toBe('') + }) + + it('puts the port back', async () => { + const { server, serverUndo } = await load() + await server().setPort('1502') + + expect(await serverUndo.undoServer()).toBe('done') + expect(server().servers[MAIN_SERVER_UUID]?.port).toBe('502') + }) + + it('is refused when main keeps the port it had, and the step stays', async () => { + const { server, undo, serverUndo } = await load() + await server().setPort('1502') + answerWith('setServerPort', () => Promise.resolve(1502)) + + expect(await serverUndo.undoServer()).toBe('refused') + expect(server().servers[MAIN_SERVER_UUID]?.port).toBe('1502') + expect(undo().server.past).toHaveLength(1) + }) +}) + +describe('a whole server', () => { + it('is gone again after an undo of its creation, and back on redo', async () => { + const { server, serverUndo } = await load() + await server().createServer({ uuid: 'a-new-one', port: 1504 }) + + await serverUndo.undoServer() + expect(server().servers['a-new-one']).toBeUndefined() + + await serverUndo.redoServer() + expect(server().servers['a-new-one']?.port).toBe('1504') + }) + + it('comes back after a delete, with its name and its registers', async () => { + const { server, serverUndo } = await load() + server().setSelectedUuid(SECOND_UUID) + server().setName('second') + server().addBool('coils', 3) + await server().deleteServer(SECOND_UUID) + + await serverUndo.undoServer() + + const restored = server().servers[SECOND_UUID] + expect(restored?.name).toBe('second') + expect(restored?.registers['0']?.coils[3]).toEqual({ value: false }) + expect(restored?.port).toBe('503') + }) + + it('is one step for Clear, which puts the name and the registers back', async () => { + const { server, undo, serverUndo } = await load() + server().setName('boiler') + server().addBool('coils', 3) + const steps = undo().server.past.length + + await serverUndo.asOneServerStep(MAIN_SERVER_UUID, async () => { + server().setName('') + await server().resetServer(MAIN_SERVER_UUID) + }) + expect(undo().server.past).toHaveLength(steps + 1) + + await serverUndo.undoServer() + expect(server().servers[MAIN_SERVER_UUID]?.name).toBe('boiler') + expect(server().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]).toEqual({ value: false }) + }) + + it('records nothing for a Clear of a server that holds nothing', async () => { + const { server, undo, serverUndo } = await load() + server().resetBools('coils') + + await serverUndo.asOneServerStep(MAIN_SERVER_UUID, async () => { + server().setName('') + await server().resetServer(MAIN_SERVER_UUID) + }) + + expect(undo().server.past).toEqual([]) + }) + + it('records nothing when the main server is created, which has no Delete', async () => { + const { server, undo } = await load() + + await server().createServer({ uuid: MAIN_SERVER_UUID, port: 502 }) + + expect(undo().server.past).toEqual([]) + }) +}) + +describe('a move in the register dialog', () => { + it('is one step for the remove and the add', async () => { + const { server, undo, serverUndo } = await load() + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(10, 'a') }) + const steps = undo().server.past.length + + await serverUndo.asOneServerUnitStep(MAIN_SERVER_UUID, '0', async () => { + server().removeRegister({ + uuid: MAIN_SERVER_UUID, + unitId: '0', + registerType: 'holding_registers', + address: 10, + dataType: 'uint16', + length: undefined + }) + await server().addRegister({ uuid: MAIN_SERVER_UUID, unitId: '0', params: holding(30, 'a') }) + }) + expect(undo().server.past).toHaveLength(steps + 1) + + await serverUndo.undoServer() + const registers = server().servers[MAIN_SERVER_UUID]?.registers['0']?.holding_registers + expect(Object.keys(registers ?? {})).toEqual(['10']) + }) +}) diff --git a/src/renderer/src/context/clientUndo.ts b/src/renderer/src/context/clientUndo.ts index cdebc4d0..5068f246 100644 --- a/src/renderer/src/context/clientUndo.ts +++ b/src/renderer/src/context/clientUndo.ts @@ -2,12 +2,11 @@ import { isConnectionAddressGiven, isReadLengthGiven } from '@shared' import { deepEqual } from 'fast-equals' import { useClientZustand } from './client.zustand' import { showMapping } from './data.zustand' -import { useUndoZustand } from './undo.zustand' +import { replayTop, useUndoZustand } from './undo.zustand' import { clientFieldReaders, clientFieldSteps, clientStepKey, - moveStep, pushStep } from './undo.zustand.helpers' import { @@ -19,7 +18,8 @@ import { ClientFieldValues, ClientMappingStep, ClientUndoStep, - UndoOutcome + UndoOutcome, + UndoStack } from './undo.zustand.types' /** @@ -125,27 +125,14 @@ const replay = (step: ClientUndoStep): Promise => { } } -const move = async (direction: 'undo' | 'redo'): Promise => { - const undo = useUndoZustand.getState() - if (undo.quiet > 0) return 'busy' - - const steps = direction === 'undo' ? undo.client.past : undo.client.future - const step = steps.at(-1) - if (!step) return 'empty' - - undo.beginQuiet() - let replaced: ClientUndoStep | undefined - try { - replaced = await replay(step) - } finally { - undo.endQuiet() - } - if (!replaced) return 'refused' - - undo.setClient(moveStep(useUndoZustand.getState().client, direction, step, replaced)) - return 'done' +const clientStack = { + read: (): UndoStack => useUndoZustand.getState().client, + write: (stack: UndoStack): void => useUndoZustand.getState().setClient(stack) } +const move = (direction: 'undo' | 'redo'): Promise => + replayTop(clientStack, direction, replay) + export const undoClient = (): Promise => move('undo') export const redoClient = (): Promise => move('redo') diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index d9ba8fd1..8bbde4d3 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -24,7 +24,10 @@ import { MAX_NUMBER_REGISTER_WIDTH, ModbusBaudRate, RegisterType, - RegisterValue + RegisterValue, + ServerRegisters, + BooleanRegisters, + NumberRegisters } from '@shared' import { onEvent } from '@renderer/events' import { enqueueSnackbar } from 'notistack' @@ -46,6 +49,9 @@ import { } from './server.zustand.helpers' import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' +import { useUndoZustand } from './undo.zustand' +import { emptyStack, unitStructure } from './undo.zustand.helpers' +import { deepEqual } from 'fast-equals' /** * The version the blob on disk carried, set by `migrate` and read by @@ -60,6 +66,55 @@ import { repairPersistedStore } from './repairPersistedStore' */ let persistedVersion: number | undefined +/** + * Records what a unit held, when an action changed which addresses it has or + * what they are. A value a master or a generator wrote is no step, so the + * comparison leaves the values out. + */ +const recordUnit = ( + get: () => ServerZustand, + uuid: string, + unitId: UnitIdString, + before: ServerRegisters | undefined +): void => { + const after = get().servers[uuid]?.registers[unitId] + if (deepEqual(unitStructure(before), unitStructure(after))) return + useUndoZustand.getState().recordServer({ kind: 'unit', uuid, unitId, value: before }) +} + +/** + * A unit's registers with the values it holds now kept, so putting a unit back + * puts back its addresses and what they are, not a value a master or a + * generator has written since. A register keeps its value only where it is the + * same register, with the same params. + */ +const keepLiveValues = ( + registers: ServerRegisters, + live: ServerRegisters | undefined +): ServerRegisters => { + const bools = (type: BooleanRegisters): ServerRegisters[BooleanRegisters] => + Object.fromEntries( + Object.entries(registers[type]).map(([address, entry]) => [ + address, + { ...entry, value: live?.[type][Number(address)]?.value ?? entry.value } + ]) + ) + const numbers = (type: NumberRegisters): ServerRegisters[NumberRegisters] => + Object.fromEntries( + Object.entries(registers[type]).map(([address, entry]) => { + const liveEntry = live?.[type][Number(address)] + const same = liveEntry !== undefined && deepEqual(liveEntry.params, entry.params) + return [address, same ? { ...entry, value: liveEntry.value } : entry] + }) + ) + return { + coils: bools('coils'), + discrete_inputs: bools('discrete_inputs'), + input_registers: numbers('input_registers'), + holding_registers: numbers('holding_registers') + } +} + export const useServerZustand = create< ServerZustand, [['zustand/persist', PersistedServerZustand], ['zustand/mutative', never]] @@ -101,10 +156,17 @@ export const useServerZustand = create< state.ready[uuid] = true state.selectedUuid = uuid }) + // The main server has no Delete button, and `init` creates it on a + // launch with no servers, which is no step to undo. + if (uuid !== MAIN_SERVER_UUID) { + useUndoZustand.getState().recordServer({ kind: 'server', uuid, value: undefined }) + } return true }, deleteServer: async (uuid) => { + const before = get().servers[uuid] await window.api.deleteServer(uuid) + if (before) useUndoZustand.getState().recordServer({ kind: 'server', uuid, value: before }) set((state) => { delete state.servers[uuid] delete state.ready[uuid] @@ -219,14 +281,20 @@ export const useServerZustand = create< }), setName: (name) => { const uuid = get().selectedUuid + // A name never set reads as the empty one everywhere it is shown. + const before = get().servers[uuid]?.name ?? '' set((state) => { const server = state.servers[uuid] if (server) server.name = name }) + if ((get().servers[uuid]?.name ?? '') !== before) { + useUndoZustand.getState().recordServer({ kind: 'name', uuid, value: before }) + } }, addBool: (registerType, address) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + const before = get().servers[uuid]?.registers[unitId] let added = false set((state) => { const registers = unitRegisters(state, uuid, unitId) @@ -236,11 +304,13 @@ export const useServerZustand = create< added = true }) if (added) window.api.setBool({ uuid, unitId, registerType, address, state: false }) + recordUnit(get, uuid, unitId, before) return added }, removeBool: (registerType, address) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + const before = get().servers[uuid]?.registers[unitId] let removed = false set((state) => { const registers = state.servers[uuid]?.registers[unitId] @@ -249,8 +319,24 @@ export const useServerZustand = create< removed = true }) if (removed) window.api.setBool({ uuid, unitId, registerType, address, state: false }) + recordUnit(get, uuid, unitId, before) return removed }, + toggleBool: (registerType, address) => { + const uuid = get().selectedUuid + const unitId = get().getUnitId(uuid) + const entry = get().servers[uuid]?.registers[unitId]?.[registerType][address] + if (!entry) return + useUndoZustand.getState().recordServer({ + kind: 'bool', + uuid, + unitId, + registerType, + address, + value: entry.value + }) + get().setBool({ registerType, address, boolState: !entry.value }) + }, setBool: (params) => { const written: SetBooleanParameters[] = [] set((state) => { @@ -274,15 +360,18 @@ export const useServerZustand = create< setBoolComment: (registerType, address, comment) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + const before = get().servers[uuid]?.registers[unitId] set((state) => { const entry = state.servers[uuid]?.registers[unitId]?.[registerType]?.[address] if (!entry) return entry.comment = comment || undefined }) + recordUnit(get, uuid, unitId, before) }, resetBools: (registerType) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + const before = get().servers[uuid]?.registers[unitId] // Read before the store is emptied, because the other type keeps the // values it had and main takes both arrays on every sync. const bools = boolArraysOf(get().servers[uuid]?.registers ?? {}, unitId) @@ -297,6 +386,7 @@ export const useServerZustand = create< [registerType]: new Array(65536).fill(false) } window.api.syncBools(newBools) + recordUnit(get, uuid, unitId, before) }, addRegister: async (addParams) => { const { uuid, unitId, params } = addParams @@ -313,6 +403,7 @@ export const useServerZustand = create< // // Whether it was taken goes back out to the caller, because Add & Next // asks for the next free address and that reads the map written below. + const before = get().servers[uuid]?.registers[unitId] const words = await window.api.addReplaceServerRegister({ uuid, unitId, params }) if (words === undefined) return false @@ -325,6 +416,7 @@ export const useServerZustand = create< Object.values(registers[params.registerType]).map((register) => register.params) ) }) + recordUnit(get, uuid, unitId, before) // The entry exists now, so the words go through the merge that turns // them into the value the grid draws. @@ -342,6 +434,7 @@ export const useServerZustand = create< }, removeRegister: (removeParams) => { const { uuid, unitId, registerType, address } = removeParams + const before = get().servers[uuid]?.registers[unitId] set((state) => { const registers = state.servers[uuid]?.registers[unitId] if (registers === undefined) return @@ -355,6 +448,7 @@ export const useServerZustand = create< ) }) window.api.removeServerRegister(removeParams) + recordUnit(get, uuid, unitId, before) }, setRegisterValue: (params) => { if (!Array.isArray(params)) params = [params] @@ -382,6 +476,7 @@ export const useServerZustand = create< resetRegisters: (registerType) => { const uuid = get().selectedUuid const unitId = get().getUnitId(uuid) + const before = get().servers[uuid]?.registers[unitId] window.api.resetRegisters({ uuid, unitId, registerType }) set((state) => { const registers = unitRegisters(state, uuid, unitId) @@ -390,6 +485,82 @@ export const useServerZustand = create< registers[registerType] = {} addresses[registerType] = [] }) + recordUnit(get, uuid, unitId, before) + }, + restoreUnit: async (uuid, unitId, registers) => { + const live = get().servers[uuid]?.registers[unitId] + const restored = registers && keepLiveValues(registers, live) + set((state) => { + const server = state.servers[uuid] + if (!server) return + if (restored === undefined) { + delete server.registers[unitId] + delete server.usedAddresses[unitId] + return + } + server.registers[unitId] = restored + server.usedAddresses[unitId] = { + input_registers: getUsedAddresses( + Object.values(restored.input_registers).map((register) => register.params) + ), + holding_registers: getUsedAddresses( + Object.values(restored.holding_registers).map((register) => register.params) + ) + } + }) + + // Main is handed the difference, register by register, rather than the + // unit whole: `sync_server_register` adds every register back from its + // params, and a value a master wrote into one the step never touched + // would go with it. Bools go whole, with the values kept above. + const boolStructure = (unit: ServerRegisters | undefined): unknown => + unitStructure(unit && { ...unit, input_registers: {}, holding_registers: {} }) + if (!deepEqual(boolStructure(live), boolStructure(restored))) { + const serverRegisters = get().servers[uuid]?.registers ?? {} + await window.api.syncBools({ uuid, unitId, ...boolArraysOf(serverRegisters, unitId) }) + } + + for (const registerType of ['input_registers', 'holding_registers'] as const) { + const before = live?.[registerType] ?? {} + const after = restored?.[registerType] ?? {} + // Removed first, because a moved register's two spans can overlap. + for (const [address, entry] of Object.entries(before)) { + if (deepEqual(after[Number(address)]?.params, entry.params)) continue + const { dataType, length } = entry.params + await window.api.removeServerRegister({ + uuid, + unitId, + registerType, + address: Number(address), + dataType, + length + }) + } + for (const [address, entry] of Object.entries(after)) { + if (deepEqual(before[Number(address)]?.params, entry.params)) continue + const { params } = entry + const words = await window.api.addReplaceServerRegister({ uuid, unitId, params }) + // What main now holds, folded in as `addRegister` folds it, so the + // grid does not show the value from when the step was taken. + for (const [offset, value] of (words ?? []).entries()) { + applyRegisterValue({ + uuid, + unitId, + registerType, + address: params.address + offset, + value + }) + } + } + } + }, + restoreServer: async (uuid, record) => { + // The port main bound stays, because the server listens on it. + const port = get().servers[uuid]?.port ?? record.port + set((state) => { + state.servers[uuid] = { ...record, port } + }) + await get().init(uuid) }, setPort: async (port) => { const uuid = get().selectedUuid @@ -413,6 +584,7 @@ export const useServerZustand = create< } // Only update port from backend response + const before = servers[uuid]?.port const actualPort = await window.api.setServerPort({ uuid, port: Number(port) }) if (actualPort === undefined) return false @@ -420,6 +592,9 @@ export const useServerZustand = create< const server = state.servers[uuid] if (server) server.port = String(actualPort) }) + if (before !== undefined && get().servers[uuid]?.port !== before) { + useUndoZustand.getState().recordServer({ kind: 'port', uuid, value: before }) + } // Main refuses a port by answering the one the server kept, not // `undefined`, so a refusal is a port other than the one asked for. return actualPort === Number(port) @@ -438,10 +613,14 @@ export const useServerZustand = create< const uuid = currentState.selectedUuid if (!currentState.ready[uuid]) return false + const before = currentState.servers[uuid]?.littleEndian set((state) => { const server = state.servers[uuid] if (server) server.littleEndian = littleEndian }) + if (before !== undefined && before !== littleEndian) { + useUndoZustand.getState().recordServer({ kind: 'littleEndian', uuid, value: before }) + } // Told before the registers are sent, because main encodes them with // the order it holds at that moment. @@ -764,6 +943,9 @@ onEvent('window_update', ({ server }) => { } if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false + // The steps this window holds describe the store from before the split, and + // the key it is about to read holds what the other window made of it since. + useUndoZustand.getState().setServer(emptyStack()) persistedVersion = undefined void Promise.resolve(useServerZustand.persist.rehydrate()) .then(() => { diff --git a/src/renderer/src/context/server.zustand.types.ts b/src/renderer/src/context/server.zustand.types.ts index 717ae575..2c0a7a17 100644 --- a/src/renderer/src/context/server.zustand.types.ts +++ b/src/renderer/src/context/server.zustand.types.ts @@ -115,6 +115,8 @@ export type ServerZustand = { init: (uuid?: string) => Promise addBool: (type: BooleanRegisters, address: number) => boolean removeBool: (type: BooleanRegisters, address: number) => boolean + /** The user's switch of a coil or discrete input, recorded as a step. */ + toggleBool: (type: BooleanRegisters, address: number) => void setBool: (params: SetBoolParameters | Array) => void setBoolComment: (type: BooleanRegisters, address: number, comment: string | undefined) => void resetBools: (type: BooleanRegisters) => void @@ -122,6 +124,20 @@ export type ServerZustand = { removeRegister: (params: RemoveRegisterParams) => void setRegisterValue: (params: SetRegisterValueParameters | Array) => void resetRegisters: (type: NumberRegisters) => void + /** + * Makes a unit hold `registers` again, or nothing, in the store and in main. + * What an undo of a change to a unit replays. + */ + restoreUnit: ( + uuid: string, + unitId: UnitIdString, + registers: ServerRegisters | undefined + ) => Promise + /** + * Makes a uuid hold `record` again on the port it listens on now, and hands + * main all of it. The uuid has to exist, and main hold nothing for it. + */ + restoreServer: (uuid: string, record: PersistedServer) => Promise // Asks the backend and settles on the port it actually got, so callers // that need the result -- the privileged port modal -- can await it. // Answers whether the server now holds the port asked for. `createServer` diff --git a/src/renderer/src/context/serverUndo.ts b/src/renderer/src/context/serverUndo.ts new file mode 100644 index 00000000..4e8012c4 --- /dev/null +++ b/src/renderer/src/context/serverUndo.ts @@ -0,0 +1,197 @@ +import { UnitIdString } from '@shared' +import { deepEqual } from 'fast-equals' +import { useServerZustand } from './server.zustand' +import { PersistedServer } from './server.zustand.types' +import { replayTop, useUndoZustand } from './undo.zustand' +import { pushStep, serverStepKey, unitStructure } from './undo.zustand.helpers' +import { + ServerBoolStep, + ServerLittleEndianStep, + ServerNameStep, + ServerPortStep, + ServerRecordStep, + ServerUndoStep, + ServerUnitStep, + UndoOutcome, + UndoStack +} from './undo.zustand.types' + +/** + * Shows the server a step belongs to, and the unit where it names one, so what + * the replay changes is on screen when it changes. + */ +const show = (uuid: string, unitId?: UnitIdString): void => { + const server = useServerZustand.getState() + if (server.servers[uuid] && server.selectedUuid !== uuid) server.setSelectedUuid(uuid) + if (unitId !== undefined && server.getUnitId(uuid) !== unitId) { + useServerZustand.getState().setUnitId(unitId) + } +} + +const replayName = async (step: ServerNameStep): Promise => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return undefined + show(step.uuid) + const replaced: ServerNameStep = { ...step, value: server.name ?? '' } + useServerZustand.getState().setName(step.value) + return replaced +} + +/** Refused when main binds another port, which is the port the server kept. */ +const replayPort = async (step: ServerPortStep): Promise => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return undefined + show(step.uuid) + const replaced: ServerPortStep = { ...step, value: server.port } + return (await useServerZustand.getState().setPort(step.value)) ? replaced : undefined +} + +const replayLittleEndian = async ( + step: ServerLittleEndianStep +): Promise => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return undefined + show(step.uuid) + const replaced: ServerLittleEndianStep = { ...step, value: server.littleEndian } + return (await useServerZustand.getState().setLittleEndian(step.value)) ? replaced : undefined +} + +/** Refused for a coil that is gone since, which switching would bring back. */ +const replayBool = async (step: ServerBoolStep): Promise => { + const entry = + useServerZustand.getState().servers[step.uuid]?.registers[step.unitId]?.[step.registerType][ + step.address + ] + if (!entry) return undefined + show(step.uuid, step.unitId) + const replaced: ServerBoolStep = { ...step, value: entry.value } + useServerZustand.getState().setBool({ + registerType: step.registerType, + address: step.address, + boolState: step.value, + optionalUuid: step.uuid, + optionalUnitId: step.unitId + }) + return replaced +} + +const replayUnit = async (step: ServerUnitStep): Promise => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return undefined + show(step.uuid, step.unitId) + const replaced: ServerUnitStep = { ...step, value: server.registers[step.unitId] } + await useServerZustand.getState().restoreUnit(step.uuid, step.unitId, step.value) + return replaced +} + +/** + * Makes the uuid hold the record again, or deletes it for a step that says it + * did not exist. + * + * A server that exists is reset first, so main holds nothing for it and + * `restoreServer` hands it everything. One that does not is created on the + * port it had, and refused if main binds none. + */ +const replayServer = async (step: ServerRecordStep): Promise => { + const server = useServerZustand.getState() + const current = server.servers[step.uuid] + const replaced: ServerRecordStep = { ...step, value: current } + + if (step.value === undefined) { + if (current) await server.deleteServer(step.uuid) + return replaced + } + + if (current) { + show(step.uuid) + await server.resetServer(step.uuid) + } else if (!(await server.createServer({ uuid: step.uuid, port: Number(step.value.port) }))) { + return undefined + } + await useServerZustand.getState().restoreServer(step.uuid, step.value) + return replaced +} + +const replay = (step: ServerUndoStep): Promise => { + switch (step.kind) { + case 'name': + return replayName(step) + case 'port': + return replayPort(step) + case 'littleEndian': + return replayLittleEndian(step) + case 'bool': + return replayBool(step) + case 'unit': + return replayUnit(step) + case 'server': + return replayServer(step) + } +} + +const serverStack = { + read: (): UndoStack => useUndoZustand.getState().server, + write: (stack: UndoStack): void => useUndoZustand.getState().setServer(stack) +} + +export const undoServer = (): Promise => replayTop(serverStack, 'undo', replay) +export const redoServer = (): Promise => replayTop(serverStack, 'redo', replay) + +/** What a server is apart from the values in its registers. */ +const serverStructure = (server: PersistedServer | undefined): unknown => + server && { + name: server.name ?? '', + port: server.port, + littleEndian: server.littleEndian, + units: Object.fromEntries( + Object.entries(server.registers) + .map(([unitId, registers]) => [unitId, unitStructure(registers)] as const) + .filter(([, structure]) => Object.keys(structure).length > 0) + ) + } + +/** + * Runs the action quiet and records one step for it, in a `finally` so an + * action that threw after it changed something still has one. + */ +const asOneStep = async ( + action: () => Promise, + changedStep: () => ServerUndoStep | undefined +): Promise => { + const undo = useUndoZustand.getState() + undo.beginQuiet() + try { + await action() + } finally { + undo.endQuiet() + const step = changedStep() + if (step) undo.setServer(pushStep(useUndoZustand.getState().server, step, serverStepKey(step))) + } +} + +/** Clear and Open replace what a server holds: one step for the whole record. */ +export const asOneServerStep = (uuid: string, action: () => Promise): Promise => { + const before = useServerZustand.getState().servers[uuid] + return asOneStep(action, () => + deepEqual(serverStructure(before), serverStructure(useServerZustand.getState().servers[uuid])) + ? undefined + : { kind: 'server', uuid, value: before } + ) +} + +/** Moving a register is a remove and an add: one step for the unit. */ +export const asOneServerUnitStep = ( + uuid: string, + unitId: UnitIdString, + action: () => Promise +): Promise => { + const before = useServerZustand.getState().servers[uuid]?.registers[unitId] + return asOneStep(action, () => + deepEqual( + unitStructure(before), + unitStructure(useServerZustand.getState().servers[uuid]?.registers[unitId]) + ) + ? undefined + : { kind: 'unit', uuid, unitId, value: before } + ) +} diff --git a/src/renderer/src/context/undo.zustand.helpers.ts b/src/renderer/src/context/undo.zustand.helpers.ts index 980156a2..d1c3873e 100644 --- a/src/renderer/src/context/undo.zustand.helpers.ts +++ b/src/renderer/src/context/undo.zustand.helpers.ts @@ -1,9 +1,11 @@ +import { ServerRegisters } from '@shared' import { ClientZustand } from './client.zustand.types' import { ClientField, ClientFieldStepMap, ClientFieldValues, ClientUndoStep, + ServerUndoStep, UndoStack } from './undo.zustand.types' @@ -84,6 +86,45 @@ export const clientStepKey = (step: ClientUndoStep): string | undefined => { } } +/** + * The run a server step merges into: the name, typed a key at a time, and the + * port. The rest are a click each. + */ +export const serverStepKey = (step: ServerUndoStep): string | undefined => { + switch (step.kind) { + case 'name': + case 'port': + return `${step.kind}.${step.uuid}` + case 'server': + case 'unit': + case 'bool': + case 'littleEndian': + return undefined + } +} + +/** + * What a unit holds apart from the values in it: the addresses, what each + * register is, and the comments. Compared to tell a change to a unit from a + * value a master or a generator wrote into it. + * + * A register type with nothing in it is left out, so a unit made as four empty + * maps on its first write reads the same as one that was never made. + */ +export const unitStructure = ( + registers: ServerRegisters | undefined +): Record> => { + const structure: Record> = {} + for (const [registerType, entries] of Object.entries(registers ?? {})) { + const addresses: Record = {} + for (const [address, entry] of Object.entries(entries)) { + addresses[address] = 'params' in entry ? entry.params : { comment: entry.comment } + } + if (Object.keys(addresses).length > 0) structure[registerType] = addresses + } + return structure +} + /** * The stack with a new step on it, and nothing left to redo. * diff --git a/src/renderer/src/context/undo.zustand.ts b/src/renderer/src/context/undo.zustand.ts index 65a82f77..d17a828e 100644 --- a/src/renderer/src/context/undo.zustand.ts +++ b/src/renderer/src/context/undo.zustand.ts @@ -1,6 +1,12 @@ import { create } from 'zustand' -import { clientStepKey, emptyStack, pushStep } from './undo.zustand.helpers' -import { UndoZustand } from './undo.zustand.types' +import { + clientStepKey, + emptyStack, + moveStep, + pushStep, + serverStepKey +} from './undo.zustand.helpers' +import { UndoOutcome, UndoStack, UndoZustand } from './undo.zustand.types' /** * The undo and redo stacks, one per store. @@ -11,6 +17,7 @@ import { UndoZustand } from './undo.zustand.types' */ export const useUndoZustand = create()((set, get) => ({ client: emptyStack(), + server: emptyStack(), quiet: 0, recordClient: (step): void => { // A write while quiet is not recorded, and it ends the open run, so the @@ -22,6 +29,46 @@ export const useUndoZustand = create()((set, get) => ({ set({ client: pushStep(get().client, step, clientStepKey(step)) }) }, setClient: (client): void => set({ client }), + recordServer: (step): void => { + if (get().quiet > 0) { + set({ server: { ...get().server, openKey: undefined } }) + return + } + set({ server: pushStep(get().server, step, serverStepKey(step)) }) + }, + setServer: (server): void => set({ server }), beginQuiet: (): void => set({ quiet: get().quiet + 1 }), endQuiet: (): void => set({ quiet: get().quiet - 1 }) })) + +/** + * Replays the newest step of one stack in one direction, and moves it across + * when the replay took. + * + * Quiet while it runs, so the setters the replay calls record nothing, and + * `busy` while another replay or an action recorded as one step is running. + */ +export const replayTop = async ( + stack: { read: () => UndoStack; write: (stack: UndoStack) => void }, + direction: 'undo' | 'redo', + replay: (step: Step) => Promise +): Promise => { + const undo = useUndoZustand.getState() + if (undo.quiet > 0) return 'busy' + + const current = stack.read() + const step = (direction === 'undo' ? current.past : current.future).at(-1) + if (step === undefined) return 'empty' + + undo.beginQuiet() + let replaced: Step | undefined + try { + replaced = await replay(step) + } finally { + undo.endQuiet() + } + if (replaced === undefined) return 'refused' + + stack.write(moveStep(stack.read(), direction, step, replaced)) + return 'done' +} diff --git a/src/renderer/src/context/undo.zustand.types.ts b/src/renderer/src/context/undo.zustand.types.ts index 460cc138..66244d08 100644 --- a/src/renderer/src/context/undo.zustand.types.ts +++ b/src/renderer/src/context/undo.zustand.types.ts @@ -1,12 +1,16 @@ import { + BooleanRegisters, ConnectionConfig, Parity, RegisterConfig, RegisterMapping, RegisterMapValue, RegisterType, - SerialPortOptions + SerialPortOptions, + ServerRegisters, + UnitIdString } from '@shared' +import { PersistedServer } from './server.zustand.types' /** * Every client field an undo can put back, keyed by the name a step carries. @@ -77,6 +81,69 @@ export interface ClientConfigurationStep { export type ClientUndoStep = ClientFieldStep | ClientMappingStep | ClientConfigurationStep +/** + * A whole server as it was, or `undefined` for a server that did not exist. + * + * Creating, deleting, Clear and Open each replace what one uuid holds, so one + * kind covers all four: its replay makes the uuid hold this again. + */ +export interface ServerRecordStep { + kind: 'server' + uuid: string + value: PersistedServer | undefined +} + +/** + * What one unit of one server held, all four register types. + * + * Adding, removing, moving and resetting registers and bools, and a bool's + * comment, change what a unit holds rather than what a master reads from it. + * The replay keeps the values a master or a generator wrote since, and puts + * back which addresses there are and what they are. + */ +export interface ServerUnitStep { + kind: 'unit' + uuid: string + unitId: UnitIdString + value: ServerRegisters | undefined +} + +/** One coil or discrete input the user switched. */ +export interface ServerBoolStep { + kind: 'bool' + uuid: string + unitId: UnitIdString + registerType: BooleanRegisters + address: number + value: boolean +} + +export interface ServerNameStep { + kind: 'name' + uuid: string + value: string +} + +export interface ServerPortStep { + kind: 'port' + uuid: string + value: string +} + +export interface ServerLittleEndianStep { + kind: 'littleEndian' + uuid: string + value: boolean +} + +export type ServerUndoStep = + | ServerRecordStep + | ServerUnitStep + | ServerBoolStep + | ServerNameStep + | ServerPortStep + | ServerLittleEndianStep + /** * The steps of one store, oldest first. * @@ -94,6 +161,7 @@ export type UndoOutcome = 'done' | 'refused' | 'empty' | 'busy' export interface UndoZustand { client: UndoStack + server: UndoStack /** * How many replays and actions that write several fields are running. A * setter records nothing while it is above zero: the replay is the step, and @@ -103,6 +171,8 @@ export interface UndoZustand { quiet: number recordClient: (step: ClientUndoStep) => void setClient: (stack: UndoStack) => void + recordServer: (step: ServerUndoStep) => void + setServer: (stack: UndoStack) => void beginQuiet: () => void endQuiet: () => void } From 176e483b03417aee0966b4eda7162ba15b41dc67 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 22:06:14 +0200 Subject: [PATCH 102/137] feat: Cmd+Z and Cmd+Shift+Z undo and redo the view on screen `UndoKeys` listens on `window` in the capture phase and runs undo or redo on the stack of the view `appType` names: the client's on the client view, the server's on the server view, including in the split out window, and nothing on Home. The keys are Cmd or Ctrl with Z, and with Shift+Z or Y for redo, on every platform. A focused text field keeps the key, so its own history runs and what it puts back reaches the store through its setter, as it did before. A checkbox, a button or a slider holds no text, and the key goes to the stack there. While a dialog is open the keys do nothing: a dialog is filled from what was on screen when it opened, and its buttons would act on a register an undo took away. On macOS a real key reaches the listener with Electron's default Edit menu in place: `osascript` keystrokes into a probe build counted Cmd+Z and Cmd+Shift+Z. Playwright's keys never pass the native menu, so the e2e cases cover the listener and not the menu. Windows and Linux are not measured yet. `undoKeys.test.tsx` holds each key, the field that keeps it, and Home. `32-undo-redo.spec.ts` undoes and redoes the byte order on the client view and an added coil on the server view. Both cases failed with the listener taken out of `App`. The byte order stands in for a host edit, which passed without the listener: Chromium undoes typing in a field it has just left on its own. --- e2e/specs/01-main/32-undo-redo.spec.ts | 62 +++++++- src/renderer/src/App.tsx | 2 + src/renderer/src/components/UndoKeys.tsx | 66 +++++++++ .../components/__tests__/undoKeys.test.tsx | 136 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/UndoKeys.tsx create mode 100644 src/renderer/src/components/__tests__/undoKeys.test.tsx diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index ac02dcc3..ec8ca590 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -8,7 +8,13 @@ */ import { test, expect } from '../../fixtures/electron-app' import type { Locator, Page } from '@playwright/test' -import { navigateToClient } from '../../fixtures/helpers' +import { + addBool, + cleanServerState, + navigateToClient, + navigateToServer, + selectRegisterType +} from '../../fixtures/helpers' /** What the client store last persisted, read the way the next launch reads it. */ const savedConnection = async (p: Page): Promise<{ host: string; unitId: number }> => @@ -76,3 +82,57 @@ test.describe.serial('Undo and redo in a focused field', () => { await input.fill('0') }) }) + +/** Takes the focus off any field, so the key goes to the stack rather than the field. */ +const leaveTheField = async (p: Page): Promise => { + await p.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} + +test.describe.serial('Undo and redo outside a field', () => { + // The byte order rather than a text field: Chromium undoes typing in a field + // it has just left on its own, so a host edit would pass with no listener. + test('the client view undoes and redoes the byte order, and the config follows', async ({ + mainPage + }) => { + await navigateToClient(mainPage) + // The byte order is shown for the word registers alone. + await selectRegisterType(mainPage, 'Holding Registers') + const savedLittleEndian = (): Promise => + mainPage.evaluate( + () => + JSON.parse(localStorage.getItem('client.zustand') ?? '{}').state?.registerConfig + ?.littleEndian + ) + await mainPage.getByTestId('endian-be-btn').click() + await expect.poll(savedLittleEndian).toBe(false) + await mainPage.getByTestId('endian-le-btn').click() + await expect.poll(savedLittleEndian).toBe(true) + await leaveTheField(mainPage) + + await mainPage.keyboard.press('ControlOrMeta+z') + await expect.poll(savedLittleEndian).toBe(false) + await expect(mainPage.getByTestId('endian-be-btn')).toHaveAttribute('aria-pressed', 'true') + + await mainPage.keyboard.press('ControlOrMeta+Shift+z') + await expect.poll(savedLittleEndian).toBe(true) + + await mainPage.getByTestId('endian-be-btn').click() + }) + + test('the server view undoes and redoes an added coil', async ({ mainPage }) => { + await navigateToServer(mainPage) + await cleanServerState(mainPage) + await addBool(mainPage, 'coils', 3) + const row = mainPage.getByTestId('server-bool-row-coils-3') + await expect(row).toBeVisible() + await leaveTheField(mainPage) + + await mainPage.keyboard.press('ControlOrMeta+z') + await expect(row).toHaveCount(0) + + await mainPage.keyboard.press('ControlOrMeta+Shift+z') + await expect(row).toBeVisible() + + await cleanServerState(mainPage) + }) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a09e9b22..46f590ae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,6 +5,7 @@ import Home from './containers/Home' import Client from './containers/Client' import Server from './containers/Server' import UpdateBanner from './components/UpdateBanner' +import UndoKeys from './components/UndoKeys' const App = meme((): JSX.Element => { const appType = useLayoutZustand((z) => z.appType) @@ -19,6 +20,7 @@ const App = meme((): JSX.Element => { flexDirection: 'column' }} > + {appType === 'client' ? : appType === 'server' ? : } diff --git a/src/renderer/src/components/UndoKeys.tsx b/src/renderer/src/components/UndoKeys.tsx new file mode 100644 index 00000000..20b606cf --- /dev/null +++ b/src/renderer/src/components/UndoKeys.tsx @@ -0,0 +1,66 @@ +import { meme } from '@renderer/components/shared/inputs/meme' +import { redoClient, undoClient } from '@renderer/context/clientUndo' +import { useLayoutZustand } from '@renderer/context/layout.zustand' +import { redoServer, undoServer } from '@renderer/context/serverUndo' +import { UndoOutcome } from '@renderer/context/undo.zustand.types' +import { useEffect } from 'react' + +/** The keys, on any platform: Cmd or Ctrl with Z, with Shift+Z or Y for redo. */ +export const undoKeyOf = (event: KeyboardEvent): 'undo' | 'redo' | undefined => { + if (!event.metaKey && !event.ctrlKey) return undefined + const key = event.key.toLowerCase() + if (key === 'z') return event.shiftKey ? 'redo' : 'undo' + if (key === 'y' && !event.shiftKey) return 'redo' + return undefined +} + +/** + * Whether the element holds text of its own to undo, which is where the key + * stays: the field's history runs, and what it puts back reaches the store + * through the field's setter. + */ +export const holdsText = (element: Element | null): boolean => { + if (element instanceof HTMLTextAreaElement) return true + if (element instanceof HTMLElement && element.isContentEditable) return true + if (!(element instanceof HTMLInputElement)) return false + return !['checkbox', 'radio', 'button', 'submit', 'range', 'file'].includes(element.type) +} + +const replays: Record<'client' | 'server', Record<'undo' | 'redo', () => Promise>> = { + client: { undo: undoClient, redo: redoClient }, + server: { undo: undoServer, redo: redoServer } +} + +/** + * Runs undo and redo on the stack of the view on screen: the client's, the + * server's, or nothing on Home. + * + * A real key reaches this listener with Electron's default Edit menu in place, + * measured on macOS with `osascript`. The listener is on `window` in the + * capture phase, so the grid's own key handling does not see the key first. + */ +const UndoKeys = meme((): null => { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + const direction = undoKeyOf(event) + if (!direction) return + if (holdsText(document.activeElement)) return + // A dialog is filled from what was on screen when it opened, and its + // buttons act on that. Replaying behind it would leave them acting on a + // register or a type that is not there any more. + if (document.querySelector('[role="dialog"]')) return + + const appType = useLayoutZustand.getState().appType + if (!appType) return + + event.preventDefault() + void replays[appType][direction]() + } + window.addEventListener('keydown', onKeyDown, true) + return (): void => window.removeEventListener('keydown', onKeyDown, true) + }, []) + + return null +}) + +export default UndoKeys diff --git a/src/renderer/src/components/__tests__/undoKeys.test.tsx b/src/renderer/src/components/__tests__/undoKeys.test.tsx new file mode 100644 index 00000000..870fa904 --- /dev/null +++ b/src/renderer/src/components/__tests__/undoKeys.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +// +// Cmd or Ctrl with Z undoes, with Shift+Z or Y redoes, on the stack of the view +// on screen. A focused text field keeps the key for its own history, and Home +// has no stack. +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stubRenderer } from '../../context/__tests__/stubRenderer' + +const replays = vi.hoisted(() => ({ + undoClient: vi.fn(async () => 'done'), + redoClient: vi.fn(async () => 'done'), + undoServer: vi.fn(async () => 'done'), + redoServer: vi.fn(async () => 'done') +})) +vi.mock('@renderer/context/clientUndo', () => ({ + undoClient: replays.undoClient, + redoClient: replays.redoClient +})) +vi.mock('@renderer/context/serverUndo', () => ({ + undoServer: replays.undoServer, + redoServer: replays.redoServer +})) + +beforeEach(() => { + vi.resetModules() + stubRenderer() + for (const replay of Object.values(replays)) replay.mockClear() +}) + +const mount = async (appType: 'client' | 'server' | undefined): Promise => { + const { useLayoutZustand } = await import('@renderer/context/layout.zustand') + useLayoutZustand.getState().setAppType(appType) + const { default: UndoKeys } = await import('../UndoKeys') + render() +} + +const press = (init: KeyboardEventInit, target: EventTarget = document.body): void => { + target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init })) +} + +/** Which replays ran, by name. */ +const ran = (): string[] => + Object.entries(replays) + .filter(([, replay]) => replay.mock.calls.length > 0) + .map(([name]) => name) + +describe('on the client view', () => { + it('undoes on Cmd+Z and on Ctrl+Z', async () => { + await mount('client') + + press({ key: 'z', metaKey: true }) + expect(ran()).toEqual(['undoClient']) + replays.undoClient.mockClear() + + press({ key: 'z', ctrlKey: true }) + expect(ran()).toEqual(['undoClient']) + }) + + it('redoes on Cmd+Shift+Z, Ctrl+Shift+Z and Ctrl+Y', async () => { + await mount('client') + + press({ key: 'Z', metaKey: true, shiftKey: true }) + press({ key: 'Z', ctrlKey: true, shiftKey: true }) + press({ key: 'y', ctrlKey: true }) + + expect(replays.redoClient).toHaveBeenCalledTimes(3) + expect(ran()).toEqual(['redoClient']) + }) + + it('leaves the key to a focused text field', async () => { + await mount('client') + const input = document.createElement('input') + document.body.append(input) + input.focus() + + press({ key: 'z', metaKey: true }, input) + + expect(ran()).toEqual([]) + input.remove() + }) + + it('takes the key from a focused checkbox, which holds no text', async () => { + await mount('client') + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + document.body.append(checkbox) + checkbox.focus() + + press({ key: 'z', metaKey: true }, checkbox) + + expect(ran()).toEqual(['undoClient']) + checkbox.remove() + }) + + it('does nothing while a dialog is open', async () => { + await mount('client') + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + + press({ key: 'z', metaKey: true }) + + expect(ran()).toEqual([]) + dialog.remove() + }) + + it('does nothing for Z without a modifier', async () => { + await mount('client') + + press({ key: 'z' }) + + expect(ran()).toEqual([]) + }) +}) + +describe('on the server view', () => { + it('undoes and redoes the server stack, never the client one', async () => { + await mount('server') + + press({ key: 'z', metaKey: true }) + press({ key: 'Z', metaKey: true, shiftKey: true }) + + expect(ran()).toEqual(['undoServer', 'redoServer']) + }) +}) + +describe('on Home', () => { + it('does nothing', async () => { + await mount(undefined) + + press({ key: 'z', metaKey: true }) + + expect(ran()).toEqual([]) + }) +}) From 21091cbb43f9075569c9137fe6ff87c127a7d025 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 22:31:49 +0200 Subject: [PATCH 103/137] feat: the server steps travel with the server view The server view moves to its own window and back, and its undo steps now go with it, so an undo in either window reaches what was done in the other. Until now the main window emptied its server steps when the view came back, because they described the store from before the split. `serverUndoHandover` writes the steps to `SERVER_UNDO_STORAGE_KEY` from the window giving the view up and reads them in the window taking it: the main window writes on `window_update {server: true}` and empties its own, the split out window reads at load and writes on `pagehide`, and the main window reads on `window_update {server: false}`. Both windows are one origin and share the key. Measured before the build, over three rounds each way, with a value of 516942 bytes: every write was there when the other window read. Each write carries the window that made it, and the main window takes back only steps the split out window wrote. One that crashed before `pagehide` leaves the main window's own steps in the key, and those describe the store from before the split, so the main window starts with none, as it did before this commit. Main sends `window_update` whenever a window is set, so `server: false` also arrives at launch; the main window takes steps back only after it handed them away. It removes the key at launch, so no history outlives one. A key that is not a stack is read as none. The client steps stay in the main window, which shows the client view throughout. The handover runs from its own module, loaded by `UndoKeys` in both windows, because `serverUndo` is imported by stores and components that tests load with no `window.api`. `serverUndo.test.ts` covers both windows through the renderer stub, and `32-undo-redo.spec.ts` undoes in the split out window a coil added in the main one, adds another there, and undoes that one in the main window after the close. The e2e case failed with either window's read taken out. --- e2e/specs/01-main/32-undo-redo.spec.ts | 44 +++++++++- src/renderer/src/components/UndoKeys.tsx | 2 + .../src/context/__tests__/serverUndo.test.ts | 88 ++++++++++++++++++- src/renderer/src/context/server.zustand.ts | 5 +- .../src/context/serverUndoHandover.ts | 86 ++++++++++++++++++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/context/serverUndoHandover.ts diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index ec8ca590..856f3919 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -12,8 +12,10 @@ import { addBool, cleanServerState, navigateToClient, + navigateToHome, navigateToServer, - selectRegisterType + selectRegisterType, + splitOutServerWindow } from '../../fixtures/helpers' /** What the client store last persisted, read the way the next launch reads it. */ @@ -136,3 +138,43 @@ test.describe.serial('Undo and redo outside a field', () => { await cleanServerState(mainPage) }) }) + +test.describe.serial('The server steps travel with the server view', () => { + test('a step taken in the main window is undone in the split out one, and back', async ({ + electronApp, + mainPage + }) => { + await navigateToServer(mainPage) + await cleanServerState(mainPage) + await addBool(mainPage, 'coils', 3) + await expect(mainPage.getByTestId('server-bool-row-coils-3')).toBeVisible() + + await navigateToHome(mainPage) + const serverPage = await splitOutServerWindow(electronApp, mainPage) + const splitRow = serverPage.getByTestId('server-bool-row-coils-3') + await expect(splitRow).toBeVisible() + + await serverPage.keyboard.press('ControlOrMeta+z') + await expect(splitRow).toHaveCount(0) + + await addBool(serverPage, 'coils', 5) + await expect(serverPage.getByTestId('server-bool-row-coils-5')).toBeVisible() + await leaveTheField(serverPage) + + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .find((window) => window.getTitle() === 'Server') + ?.close() + }) + await expect(mainPage.getByTestId('home-btn')).toBeVisible() + await navigateToServer(mainPage) + const mainRow = mainPage.getByTestId('server-bool-row-coils-5') + await expect(mainRow).toBeVisible() + await leaveTheField(mainPage) + + await mainPage.keyboard.press('ControlOrMeta+z') + await expect(mainRow).toHaveCount(0) + + await cleanServerState(mainPage) + }) +}) diff --git a/src/renderer/src/components/UndoKeys.tsx b/src/renderer/src/components/UndoKeys.tsx index 20b606cf..060a4cbf 100644 --- a/src/renderer/src/components/UndoKeys.tsx +++ b/src/renderer/src/components/UndoKeys.tsx @@ -2,6 +2,8 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { redoClient, undoClient } from '@renderer/context/clientUndo' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { redoServer, undoServer } from '@renderer/context/serverUndo' +// Carries the server steps across the split, in both windows this listener runs in. +import '@renderer/context/serverUndoHandover' import { UndoOutcome } from '@renderer/context/undo.zustand.types' import { useEffect } from 'react' diff --git a/src/renderer/src/context/__tests__/serverUndo.test.ts b/src/renderer/src/context/__tests__/serverUndo.test.ts index 03f4144c..3c8a9b2d 100644 --- a/src/renderer/src/context/__tests__/serverUndo.test.ts +++ b/src/renderer/src/context/__tests__/serverUndo.test.ts @@ -51,6 +51,7 @@ const load = async (): Promise<{ const { useServerZustand } = await import('../server.zustand') const { useUndoZustand } = await import('../undo.zustand') const serverUndo = await import('../serverUndo') + await import('../serverUndoHandover') useServerZustand.setState({ selectedUuid: MAIN_SERVER_UUID, servers: { @@ -225,16 +226,99 @@ describe('a unit', () => { }) }) -describe('the split out window closing', () => { - it('empties the steps this window held, which describe the store from before', async () => { +const SERVER_UNDO_STORAGE_KEY = 'server.undo' + +/** A stack as `writer` writes it, holding one name step. */ +const handedOverStack = (writer: 'main' | 'server'): string => + JSON.stringify({ + writer, + past: [{ kind: 'name', uuid: MAIN_SERVER_UUID, value: 'from the other window' }], + future: [], + openKey: undefined + }) + +describe('the main window, as the server view leaves and comes back', () => { + it('hands its steps over when the view leaves, and keeps none', async () => { const { server, undo } = await load() server().addBool('coils', 3) fireEvent('window_update', { server: true }) + + const written = JSON.parse(localStorage.getItem(SERVER_UNDO_STORAGE_KEY) ?? '{}') + expect(written.past).toEqual([expect.objectContaining({ kind: 'unit', unitId: '0' })]) + expect(undo().server.past).toEqual([]) + }) + + it('takes the steps the other window handed back when the view returns', async () => { + const { undo } = await load() + fireEvent('window_update', { server: true }) + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, handedOverStack('server')) + + fireEvent('window_update', { server: false }) + + expect(undo().server.past).toEqual([ + { kind: 'name', uuid: MAIN_SERVER_UUID, value: 'from the other window' } + ]) + }) + + it('starts with no steps when the split out window wrote none, as after a crash', async () => { + const { server, undo } = await load() + server().addBool('coils', 3) + fireEvent('window_update', { server: true }) + fireEvent('window_update', { server: false }) expect(undo().server.past).toEqual([]) }) + + it('keeps its steps on the update main sends when no view left', async () => { + const { server, undo } = await load() + server().addBool('coils', 3) + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, handedOverStack('server')) + + fireEvent('window_update', { server: false }) + + expect(undo().server.past).toEqual([expect.objectContaining({ kind: 'unit' })]) + }) + + it('clears the key at launch, so no history outlives one', async () => { + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, handedOverStack('server')) + + await load() + + expect(localStorage.getItem(SERVER_UNDO_STORAGE_KEY)).toBeNull() + }) +}) + +describe('the split out window', () => { + it('starts with the steps the main window handed over, and hands its own back', async () => { + stubRenderer({ isServerWindow: true }) + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, handedOverStack('main')) + const { useUndoZustand } = await import('../undo.zustand') + await import('../serverUndoHandover') + + expect(useUndoZustand.getState().server.past).toEqual([ + { kind: 'name', uuid: MAIN_SERVER_UUID, value: 'from the other window' } + ]) + + useUndoZustand.getState().setServer({ past: [], future: [], openKey: undefined }) + window.dispatchEvent(new Event('pagehide')) + + expect(JSON.parse(localStorage.getItem(SERVER_UNDO_STORAGE_KEY) ?? '{}').past).toEqual([]) + }) + + it('starts with no steps on a key that is not a stack', async () => { + stubRenderer({ isServerWindow: true }) + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, '{"writer": "main", "past": "no", "future": []}') + const { useUndoZustand } = await import('../undo.zustand') + await import('../serverUndoHandover') + + expect(useUndoZustand.getState().server).toEqual({ + past: [], + future: [], + openKey: undefined + }) + }) }) describe('a coil the user switched', () => { diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index 8bbde4d3..1b596df4 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -50,7 +50,7 @@ import { import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' import { useUndoZustand } from './undo.zustand' -import { emptyStack, unitStructure } from './undo.zustand.helpers' +import { unitStructure } from './undo.zustand.helpers' import { deepEqual } from 'fast-equals' /** @@ -943,9 +943,6 @@ onEvent('window_update', ({ server }) => { } if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false - // The steps this window holds describe the store from before the split, and - // the key it is about to read holds what the other window made of it since. - useUndoZustand.getState().setServer(emptyStack()) persistedVersion = undefined void Promise.resolve(useServerZustand.persist.rehydrate()) .then(() => { diff --git a/src/renderer/src/context/serverUndoHandover.ts b/src/renderer/src/context/serverUndoHandover.ts new file mode 100644 index 00000000..026f83bd --- /dev/null +++ b/src/renderer/src/context/serverUndoHandover.ts @@ -0,0 +1,86 @@ +import { onEvent } from '@renderer/events' +import { useUndoZustand } from './undo.zustand' +import { emptyStack } from './undo.zustand.helpers' +import { ServerUndoStep, UndoStack } from './undo.zustand.types' + +/** + * Where the server steps wait while the server view moves between windows. + * + * The steps go with the view: written by the window giving it up, read by the + * window taking it. Both windows are one origin, so both read this key. + * Measured over three rounds each way: the main window writing on + * `window_update {server: true}` is read by the split out window at module + * load, and the split out window writing on `pagehide` is read by the main + * window on `window_update {server: false}`. + */ +export const SERVER_UNDO_STORAGE_KEY = 'server.undo' + +type Writer = 'main' | 'server' + +const isServerWindow = window.api.isServerWindow + +/** + * Writes the steps with the window that wrote them, because the main window + * takes back only what the split out window wrote: a split out window that + * crashed before `pagehide` leaves the main window's own steps in the key, + * and those describe the store from before the split. + */ +const handOver = (writer: Writer): void => { + try { + const { server } = useUndoZustand.getState() + localStorage.setItem(SERVER_UNDO_STORAGE_KEY, JSON.stringify({ writer, ...server })) + } catch (error) { + console.error('The server undo steps were not handed over:', error) + } +} + +/** + * Takes the steps `writer` handed over, or none. + * + * Only the shape is checked: the key is written by this app a moment before it + * is read, and a step that no longer fits the store is refused by its replay. + */ +const takeOver = (writer: Writer): void => { + let stack: UndoStack = emptyStack() + try { + const parsed: unknown = JSON.parse(localStorage.getItem(SERVER_UNDO_STORAGE_KEY) ?? 'null') + if ( + typeof parsed === 'object' && + parsed !== null && + 'writer' in parsed && + parsed.writer === writer && + 'past' in parsed && + 'future' in parsed && + Array.isArray(parsed.past) && + Array.isArray(parsed.future) + ) { + stack = { past: parsed.past, future: parsed.future, openKey: undefined } + } + } catch (error) { + console.error('The server undo steps were not taken over:', error) + } + useUndoZustand.getState().setServer(stack) +} + +if (isServerWindow) { + takeOver('main') + window.addEventListener('pagehide', () => handOver('server')) +} else { + // History does not outlive a launch, and the key is the one place it could. + localStorage.removeItem(SERVER_UNDO_STORAGE_KEY) + // Main sends `window_update` whenever a window is set, so `server: false` + // also arrives at launch. Only a view this window handed away comes back. + let handedAway = false + onEvent('window_update', ({ server }) => { + if (server) { + if (handedAway) return + handedAway = true + handOver('main') + useUndoZustand.getState().setServer(emptyStack()) + return + } + if (!handedAway) return + handedAway = false + takeOver('server') + }) +} From 71682be915a32e81de3638f01f68ee5afe5f6df6 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 23:39:29 +0200 Subject: [PATCH 104/137] feat: an undo that cannot go through says why An undo or a redo with nothing to do, or one refused, now says so in a snackbar. A refused step stays on its stack, so the message says what it takes for it to go through: - An empty stack: "Nothing to undo" or "Nothing to redo". - A connection setting while a connection stands: "Disconnect to undo a connection setting". The replay says so before it writes, for the fields a connect opens with; the unit id is not one of them and goes through. - A server or a coil the step belongs to that is gone. - Any other refusal adds nothing, because what refused it said so already: main answers a payload it refuses with a message, and the port setter names the port that is taken. A refusal because the store or the server is not ready yet says nothing. A server that comes back after a delete on another port than it had, because something took that port meanwhile, says which port it is on now: a master still aims at the old one. A replay answers why it refused, and `UndoKeys` turns that into the message. `undoKeys.test.tsx` holds the messages for each outcome and nothing for one that went through, `clientUndo.test.ts` and `serverUndo.test.ts` the reasons and the port, and `32-undo-redo.spec.ts` sees "Nothing to redo" after a new step. Each failed with its rule taken out. --- e2e/specs/01-main/32-undo-redo.spec.ts | 12 ++++ src/renderer/src/components/UndoKeys.tsx | 39 ++++++++++- .../components/__tests__/undoKeys.test.tsx | 68 +++++++++++++++++-- .../src/context/__tests__/clientUndo.test.ts | 10 ++- .../src/context/__tests__/serverUndo.test.ts | 21 +++++- src/renderer/src/context/clientUndo.ts | 26 ++++++- src/renderer/src/context/serverUndo.ts | 37 +++++++--- src/renderer/src/context/undo.zustand.ts | 9 +-- .../src/context/undo.zustand.types.ts | 9 ++- 9 files changed, 202 insertions(+), 29 deletions(-) diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index 856f3919..e3483220 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -121,6 +121,18 @@ test.describe.serial('Undo and redo outside a field', () => { await mainPage.getByTestId('endian-be-btn').click() }) + test('says there is nothing to redo right after a new step', async ({ mainPage }) => { + await navigateToClient(mainPage) + await selectRegisterType(mainPage, 'Holding Registers') + await mainPage.getByTestId('endian-le-btn').click() + await leaveTheField(mainPage) + + await mainPage.keyboard.press('ControlOrMeta+Shift+z') + + await expect(mainPage.locator('.notistack-SnackbarContainer')).toContainText('Nothing to redo') + await mainPage.getByTestId('endian-be-btn').click() + }) + test('the server view undoes and redoes an added coil', async ({ mainPage }) => { await navigateToServer(mainPage) await cleanServerState(mainPage) diff --git a/src/renderer/src/components/UndoKeys.tsx b/src/renderer/src/components/UndoKeys.tsx index 060a4cbf..56a48296 100644 --- a/src/renderer/src/components/UndoKeys.tsx +++ b/src/renderer/src/components/UndoKeys.tsx @@ -5,6 +5,7 @@ import { redoServer, undoServer } from '@renderer/context/serverUndo' // Carries the server steps across the split, in both windows this listener runs in. import '@renderer/context/serverUndoHandover' import { UndoOutcome } from '@renderer/context/undo.zustand.types' +import { useSnackbar, VariantType } from 'notistack' import { useEffect } from 'react' /** The keys, on any platform: Cmd or Ctrl with Z, with Shift+Z or Y for redo. */ @@ -28,6 +29,35 @@ export const holdsText = (element: Element | null): boolean => { return !['checkbox', 'radio', 'button', 'submit', 'range', 'file'].includes(element.type) } +/** + * What each outcome tells the user, or nothing for one that says it by + * happening. A refused step stays on its stack, so the message says what to + * do for it to go through. + */ +export const undoMessage = ( + outcome: UndoOutcome, + direction: 'undo' | 'redo' +): { message: string; variant: VariantType } | undefined => { + switch (outcome) { + // A plain refusal was said already by what refused it: main answers a + // payload it refuses with a message of its own, and the port setter says + // which port is taken. + case 'done': + case 'busy': + case 'refused': + return undefined + case 'empty': + return { message: `Nothing to ${direction}`, variant: 'info' } + case 'refused-connected': + return { message: `Disconnect to ${direction} a connection setting`, variant: 'warning' } + case 'refused-gone': + return { + message: `Nothing to ${direction} there: that server or coil is gone`, + variant: 'warning' + } + } +} + const replays: Record<'client' | 'server', Record<'undo' | 'redo', () => Promise>> = { client: { undo: undoClient, redo: redoClient }, server: { undo: undoServer, redo: redoServer } @@ -42,6 +72,8 @@ const replays: Record<'client' | 'server', Record<'undo' | 'redo', () => Promise * capture phase, so the grid's own key handling does not see the key first. */ const UndoKeys = meme((): null => { + const { enqueueSnackbar } = useSnackbar() + useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { const direction = undoKeyOf(event) @@ -56,11 +88,14 @@ const UndoKeys = meme((): null => { if (!appType) return event.preventDefault() - void replays[appType][direction]() + void replays[appType][direction]().then((outcome) => { + const said = undoMessage(outcome, direction) + if (said) enqueueSnackbar(said) + }) } window.addEventListener('keydown', onKeyDown, true) return (): void => window.removeEventListener('keydown', onKeyDown, true) - }, []) + }, [enqueueSnackbar]) return null }) diff --git a/src/renderer/src/components/__tests__/undoKeys.test.tsx b/src/renderer/src/components/__tests__/undoKeys.test.tsx index 870fa904..726f9d4d 100644 --- a/src/renderer/src/components/__tests__/undoKeys.test.tsx +++ b/src/renderer/src/components/__tests__/undoKeys.test.tsx @@ -8,10 +8,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { stubRenderer } from '../../context/__tests__/stubRenderer' const replays = vi.hoisted(() => ({ - undoClient: vi.fn(async () => 'done'), - redoClient: vi.fn(async () => 'done'), - undoServer: vi.fn(async () => 'done'), - redoServer: vi.fn(async () => 'done') + undoClient: vi.fn(async (): Promise => 'done'), + redoClient: vi.fn(async (): Promise => 'done'), + undoServer: vi.fn(async (): Promise => 'done'), + redoServer: vi.fn(async (): Promise => 'done') +})) +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ + useSnackbar: (): { enqueueSnackbar: typeof enqueueSnackbar } => ({ enqueueSnackbar }) })) vi.mock('@renderer/context/clientUndo', () => ({ undoClient: replays.undoClient, @@ -25,7 +29,8 @@ vi.mock('@renderer/context/serverUndo', () => ({ beforeEach(() => { vi.resetModules() stubRenderer() - for (const replay of Object.values(replays)) replay.mockClear() + for (const replay of Object.values(replays)) replay.mockReset().mockResolvedValue('done') + enqueueSnackbar.mockClear() }) const mount = async (appType: 'client' | 'server' | undefined): Promise => { @@ -125,6 +130,59 @@ describe('on the server view', () => { }) }) +describe('what the user is told', () => { + it('says there is nothing to undo, or to redo, on an empty stack', async () => { + await mount('client') + replays.undoClient.mockResolvedValue('empty') + replays.redoClient.mockResolvedValue('empty') + + press({ key: 'z', metaKey: true }) + press({ key: 'Z', metaKey: true, shiftKey: true }) + + await vi.waitFor(() => + expect(enqueueSnackbar.mock.calls.map(([said]) => said)).toEqual([ + { message: 'Nothing to undo', variant: 'info' }, + { message: 'Nothing to redo', variant: 'info' } + ]) + ) + }) + + it('says to disconnect for a connection setting', async () => { + await mount('client') + replays.undoClient.mockResolvedValue('refused-connected') + + press({ key: 'z', metaKey: true }) + + await vi.waitFor(() => + expect(enqueueSnackbar).toHaveBeenCalledWith({ + message: 'Disconnect to undo a connection setting', + variant: 'warning' + }) + ) + }) + + it('adds nothing to a refusal that was reported where it happened', async () => { + await mount('client') + replays.undoClient.mockResolvedValue('refused') + + press({ key: 'z', metaKey: true }) + await vi.waitFor(() => expect(replays.undoClient).toHaveBeenCalled()) + await Promise.resolve() + + expect(enqueueSnackbar).not.toHaveBeenCalled() + }) + + it('says nothing for a step that went through', async () => { + await mount('client') + + press({ key: 'z', metaKey: true }) + await vi.waitFor(() => expect(replays.undoClient).toHaveBeenCalled()) + await Promise.resolve() + + expect(enqueueSnackbar).not.toHaveBeenCalled() + }) +}) + describe('on Home', () => { it('does nothing', async () => { await mount(undefined) diff --git a/src/renderer/src/context/__tests__/clientUndo.test.ts b/src/renderer/src/context/__tests__/clientUndo.test.ts index 85da5a56..cd2005c7 100644 --- a/src/renderer/src/context/__tests__/clientUndo.test.ts +++ b/src/renderer/src/context/__tests__/clientUndo.test.ts @@ -130,7 +130,7 @@ describe('a field', () => { await client().setHost('10.0.0.5', true) setConnected(true) - expect(await clientUndo.undoClient()).toBe('refused') + expect(await clientUndo.undoClient()).toBe('refused-connected') expect(client().connectionConfig.tcp.host).toBe('10.0.0.5') expect(undo().client.past).toHaveLength(1) @@ -181,6 +181,14 @@ describe('a field', () => { expect(client().registerConfig.pollRate).toBe(6000) }) + it('puts back a field that is no connection setting while a connection stands', async () => { + const { client, clientUndo, setConnected } = await load() + await client().setUnitId('7') + setConnected(true) + + expect(await clientUndo.undoClient()).toBe('done') + }) + it('leaves nothing to redo once a new edit comes after an undo', async () => { const { client, clientUndo } = await load() await client().setPollRate(5000) diff --git a/src/renderer/src/context/__tests__/serverUndo.test.ts b/src/renderer/src/context/__tests__/serverUndo.test.ts index 3c8a9b2d..c9602827 100644 --- a/src/renderer/src/context/__tests__/serverUndo.test.ts +++ b/src/renderer/src/context/__tests__/serverUndo.test.ts @@ -9,7 +9,8 @@ import { MAIN_SERVER_UUID, RegisterParams } from '@shared' import { fireEvent, recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' import { getDefaultServer } from '../server.zustand.helpers' -vi.mock('notistack', () => ({ enqueueSnackbar: vi.fn() })) +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ enqueueSnackbar })) const SECOND_UUID = 'the-server-on-503' @@ -27,6 +28,7 @@ const answerWith = (method: string, answer: (payload: unknown) => Promise { vi.resetModules() localStorage.clear() + enqueueSnackbar.mockClear() stubRenderer() // Main binds what it is asked for, and answers the port it holds. answerWith('createServer', (payload) => Promise.resolve((payload as { port: number }).port)) @@ -211,7 +213,7 @@ describe('a unit', () => { servers: { [SECOND_UUID]: { ...getDefaultServer(), port: '503' } } }) - expect(await serverUndo.undoServer()).toBe('refused') + expect(await serverUndo.undoServer()).toBe('refused-gone') expect(undo().server.past).toHaveLength(1) }) @@ -391,6 +393,21 @@ describe('a whole server', () => { expect(restored?.port).toBe('503') }) + it('says so when it comes back on another port than it had', async () => { + const { server, serverUndo } = await load() + await server().deleteServer(SECOND_UUID) + // Something took 503 meanwhile, so main walks up to the next one free. + answerWith('createServer', () => Promise.resolve(504)) + + await serverUndo.undoServer() + + expect(server().servers[SECOND_UUID]?.port).toBe('504') + expect(enqueueSnackbar).toHaveBeenCalledWith({ + message: 'Port 503 is taken, so the server is back on port 504', + variant: 'warning' + }) + }) + it('is one step for Clear, which puts the name and the registers back', async () => { const { server, undo, serverUndo } = await load() server().setName('boiler') diff --git a/src/renderer/src/context/clientUndo.ts b/src/renderer/src/context/clientUndo.ts index 5068f246..a470e183 100644 --- a/src/renderer/src/context/clientUndo.ts +++ b/src/renderer/src/context/clientUndo.ts @@ -1,7 +1,7 @@ import { isConnectionAddressGiven, isReadLengthGiven } from '@shared' import { deepEqual } from 'fast-equals' import { useClientZustand } from './client.zustand' -import { showMapping } from './data.zustand' +import { showMapping, useDataZustand } from './data.zustand' import { replayTop, useUndoZustand } from './undo.zustand' import { clientFieldReaders, @@ -19,6 +19,7 @@ import { ClientMappingStep, ClientUndoStep, UndoOutcome, + UndoRefusal, UndoStack } from './undo.zustand.types' @@ -54,6 +55,24 @@ const clientFieldWriters: { addressBase: (value) => useClientZustand.getState().setAddressBase(value) } +/** + * The fields a connect opens with, which their setters refuse while a + * connection stands. Named here so the refusal can say so. + */ +const CONNECTION_FIELDS: ReadonlySet = new Set([ + 'protocol', + 'host', + 'port', + 'com', + 'baudRate', + 'parity', + 'dataBits', + 'stopBits' +]) + +const isDisconnected = (): boolean => + useDataZustand.getState().clientState.connectState === 'disconnected' + /** * Writes a step's value, and answers the step that would write back what was * there, or undefined when the store does not hold the value afterwards. @@ -64,7 +83,8 @@ const clientFieldWriters: { */ const replayField = async ( step: ClientFieldStepOf -): Promise => { +): Promise => { + if (CONNECTION_FIELDS.has(step.field) && !isDisconnected()) return 'refused-connected' const read = clientFieldReaders[step.field] const replaced = clientFieldSteps[step.field](read(useClientZustand.getState())) await clientFieldWriters[step.field](step.value) @@ -114,7 +134,7 @@ const replayConfiguration = async ( return replaced } -const replay = (step: ClientUndoStep): Promise => { +const replay = (step: ClientUndoStep): Promise => { switch (step.kind) { case 'field': return replayField(step) diff --git a/src/renderer/src/context/serverUndo.ts b/src/renderer/src/context/serverUndo.ts index 4e8012c4..f9880aba 100644 --- a/src/renderer/src/context/serverUndo.ts +++ b/src/renderer/src/context/serverUndo.ts @@ -1,5 +1,6 @@ import { UnitIdString } from '@shared' import { deepEqual } from 'fast-equals' +import { enqueueSnackbar } from 'notistack' import { useServerZustand } from './server.zustand' import { PersistedServer } from './server.zustand.types' import { replayTop, useUndoZustand } from './undo.zustand' @@ -13,6 +14,7 @@ import { ServerUndoStep, ServerUnitStep, UndoOutcome, + UndoRefusal, UndoStack } from './undo.zustand.types' @@ -28,9 +30,9 @@ const show = (uuid: string, unitId?: UnitIdString): void => { } } -const replayName = async (step: ServerNameStep): Promise => { +const replayName = async (step: ServerNameStep): Promise => { const server = useServerZustand.getState().servers[step.uuid] - if (!server) return undefined + if (!server) return 'refused-gone' show(step.uuid) const replaced: ServerNameStep = { ...step, value: server.name ?? '' } useServerZustand.getState().setName(step.value) @@ -38,9 +40,11 @@ const replayName = async (step: ServerNameStep): Promise => { +const replayPort = async ( + step: ServerPortStep +): Promise => { const server = useServerZustand.getState().servers[step.uuid] - if (!server) return undefined + if (!server) return 'refused-gone' show(step.uuid) const replaced: ServerPortStep = { ...step, value: server.port } return (await useServerZustand.getState().setPort(step.value)) ? replaced : undefined @@ -48,21 +52,21 @@ const replayPort = async (step: ServerPortStep): Promise => { +): Promise => { const server = useServerZustand.getState().servers[step.uuid] - if (!server) return undefined + if (!server) return 'refused-gone' show(step.uuid) const replaced: ServerLittleEndianStep = { ...step, value: server.littleEndian } return (await useServerZustand.getState().setLittleEndian(step.value)) ? replaced : undefined } /** Refused for a coil that is gone since, which switching would bring back. */ -const replayBool = async (step: ServerBoolStep): Promise => { +const replayBool = async (step: ServerBoolStep): Promise => { const entry = useServerZustand.getState().servers[step.uuid]?.registers[step.unitId]?.[step.registerType][ step.address ] - if (!entry) return undefined + if (!entry) return 'refused-gone' show(step.uuid, step.unitId) const replaced: ServerBoolStep = { ...step, value: entry.value } useServerZustand.getState().setBool({ @@ -75,9 +79,9 @@ const replayBool = async (step: ServerBoolStep): Promise => { +const replayUnit = async (step: ServerUnitStep): Promise => { const server = useServerZustand.getState().servers[step.uuid] - if (!server) return undefined + if (!server) return 'refused-gone' show(step.uuid, step.unitId) const replaced: ServerUnitStep = { ...step, value: server.registers[step.unitId] } await useServerZustand.getState().restoreUnit(step.uuid, step.unitId, step.value) @@ -109,10 +113,21 @@ const replayServer = async (step: ServerRecordStep): Promise => { +const replay = (step: ServerUndoStep): Promise => { switch (step.kind) { case 'name': return replayName(step) diff --git a/src/renderer/src/context/undo.zustand.ts b/src/renderer/src/context/undo.zustand.ts index d17a828e..89419c67 100644 --- a/src/renderer/src/context/undo.zustand.ts +++ b/src/renderer/src/context/undo.zustand.ts @@ -6,7 +6,7 @@ import { pushStep, serverStepKey } from './undo.zustand.helpers' -import { UndoOutcome, UndoStack, UndoZustand } from './undo.zustand.types' +import { UndoOutcome, UndoRefusal, UndoStack, UndoZustand } from './undo.zustand.types' /** * The undo and redo stacks, one per store. @@ -48,10 +48,10 @@ export const useUndoZustand = create()((set, get) => ({ * Quiet while it runs, so the setters the replay calls record nothing, and * `busy` while another replay or an action recorded as one step is running. */ -export const replayTop = async ( +export const replayTop = async ( stack: { read: () => UndoStack; write: (stack: UndoStack) => void }, direction: 'undo' | 'redo', - replay: (step: Step) => Promise + replay: (step: Step) => Promise ): Promise => { const undo = useUndoZustand.getState() if (undo.quiet > 0) return 'busy' @@ -61,13 +61,14 @@ export const replayTop = async ( if (step === undefined) return 'empty' undo.beginQuiet() - let replaced: Step | undefined + let replaced: Step | UndoRefusal | undefined try { replaced = await replay(step) } finally { undo.endQuiet() } if (replaced === undefined) return 'refused' + if (typeof replaced === 'string') return replaced stack.write(moveStep(stack.read(), direction, step, replaced)) return 'done' diff --git a/src/renderer/src/context/undo.zustand.types.ts b/src/renderer/src/context/undo.zustand.types.ts index 66244d08..df9ceadf 100644 --- a/src/renderer/src/context/undo.zustand.types.ts +++ b/src/renderer/src/context/undo.zustand.types.ts @@ -157,7 +157,14 @@ export interface UndoStack { openKey: string | undefined } -export type UndoOutcome = 'done' | 'refused' | 'empty' | 'busy' +/** + * Why a step stayed on its stack: a connection setting while a connection + * stands, a server or a coil that is gone, or a refusal with no reason known + * here, which main may have reported itself. + */ +export type UndoRefusal = 'refused' | 'refused-connected' | 'refused-gone' + +export type UndoOutcome = 'done' | 'empty' | 'busy' | UndoRefusal export interface UndoZustand { client: UndoStack From 0f09556a21c2f8a56f0167d5a3b742d5978ad629 Mon Sep 17 00:00:00 2001 From: Harted Date: Tue, 22 Sep 2026 23:40:59 +0200 Subject: [PATCH 105/137] docs: the changelog lines for undo and redo One Added line for undo and redo, and one Fixed line for the host field. No button was added, so the manual and the presentation scenes have nothing new to show. --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fa68d8..b2276dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Undo and redo.** Cmd+Z or Ctrl+Z undoes the last change to the view you + are on, and Cmd+Shift+Z, Ctrl+Shift+Z or Ctrl+Y redoes it. On the client that + is the connection and register settings and the mapping, with a Load or a + Clear Config as one step. On the server it is the registers and coils you + add, edit, remove or switch, a server's name, port and byte order, and a + server you create, delete, clear or open. An undo shows you what it changes, + and the server's steps go with the server view into its own window and back. + A connection setting waits until you disconnect, and Modbux says so, as it + does when there is nothing left to undo. In a text field the keys undo your + typing, as before. Each view keeps its last 100 steps until Modbux closes. + ### Fixed - **A scan no longer stalls the window on a large configuration.** Every step a @@ -412,6 +425,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 first then came up with no port at all: a blank button in the row at the top, answering nothing, with its registers still there behind it. Each one now opens on the first free port instead. +- **Cmd+Z or Ctrl+Z in the host field now undoes what you typed.** It did + nothing there, where the unit ID field beside it undoes a typed run in one + step. ### Changed From b5ce78f5abdc0d08ab0f6793ec47f1c62144f381 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 23 Sep 2026 01:04:24 +0200 Subject: [PATCH 106/137] feat: modbux follows the system's setting to reduce motion The theme sets `motion.reducedMotion` to `system`, so with the setting on MUI ends its transitions at once, takes a click's ripple out without its 550 ms timer, and stops the animation of an indeterminate spinner or progress bar. MUI's default is `never`, which ignores the setting. It is also what lets the e2e suite run without motion: an emulated `prefers-reduced-motion` changed nothing under `never`, and the host undo case still failed at a delay that put the ripple's removal in the typed run. With `system` the same delay passed three runs of three. --- CHANGELOG.md | 3 +++ src/renderer/src/theme/index.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2276dc9..7c65c65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -442,6 +442,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the serial layer Modbux uses has no setting for them on those platforms. A saved configuration that carries one now comes back on none, with the com port and baud rate beside it kept. +- **Modbux follows your system's setting to reduce motion.** With it on, a + button no longer ripples when you click it, dialogs, menus and tooltips appear + at once, and a busy spinner stands still. ## [2.3.0] - 2026-08-30 diff --git a/src/renderer/src/theme/index.ts b/src/renderer/src/theme/index.ts index 6d15249e..37a1c3e9 100644 --- a/src/renderer/src/theme/index.ts +++ b/src/renderer/src/theme/index.ts @@ -3,6 +3,7 @@ import '@mui/x-data-grid/themeAugmentation' import { createTheme } from '@mui/material/styles' const base = createTheme({ + motion: { reducedMotion: 'system' }, breakpoints: { values: { xs: 0, From 3fa549face1e7c20acdffdcdab83bf0bd3226493 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 23 Sep 2026 01:04:24 +0200 Subject: [PATCH 107/137] test: the e2e suite runs with reduced motion, and the host run is one insert `launchElectron` in `fixtures/launch.ts` launches the app and emulates `prefers-reduced-motion` in every window it has and every one it opens, so the split out window gets it too. Every spec that launched through `electron.launch(launchOptions())` now launches through it; the macOS dock spec's second launch, which only loses the single instance lock, does not. The host undo case typed its run a key at a time, and the keys are one undo step only while nothing else changes the DOM between them. A click's ripple, removed 550 ms after the TCP button, did: on macOS, with a delay after the click, it failed every run at 150, 250 and 350 ms. Reduced motion removed that one, and the full suite then failed the case once more, with Cmd+Z putting back "10." rather than "127.0.0.1". The run is now one `insertText`. It passed three runs of three, and failed both runs with `HostInput` from before f7c22b0, keeping "10.0.0.5" as that defect did. --- e2e/fixtures/electron-app.ts | 11 +++-------- e2e/fixtures/launch.ts | 19 ++++++++++++++++++- e2e/specs/01-main/32-undo-redo.spec.ts | 7 ++++++- .../02-standalone/01-persistence.spec.ts | 12 +++--------- e2e/specs/02-standalone/02-macos-dock.spec.ts | 4 ++-- .../03-split-view-persistence.spec.ts | 12 +++--------- .../01-privileged-port-modal.spec.ts | 12 +++--------- .../99-hardware/02-iem3000-reconnect.spec.ts | 12 +++--------- 8 files changed, 41 insertions(+), 48 deletions(-) diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 4e0ba676..d9ad10d2 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,10 +1,5 @@ -import { - test as base, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' -import { launchOptions } from './launch' +import { test as base, type ElectronApplication, type Page } from '@playwright/test' +import { launchElectron } from './launch' import { createWriteStream, mkdirSync } from 'fs' import { join } from 'path' @@ -48,7 +43,7 @@ export const test = base.extend<{}, ElectronFixtures>({ electronApp: [ // eslint-disable-next-line no-empty-pattern async ({}, use): Promise => { - const app = await electron.launch(launchOptions()) + const app = await launchElectron() keepOutput(app) await app.evaluate((ctx) => diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index 15a489a2..e904b7be 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -1,4 +1,4 @@ -import { _electron as electron } from '@playwright/test' +import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test' import { existsSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' @@ -125,3 +125,20 @@ export function launchOptions(userDataDir?: string): LaunchOptions { return { executablePath: packagedBinary(), args, env } } + +/** + * Launches the app with reduced motion emulated in every window it has and + * every window it opens. The theme follows that setting, so MUI's transitions + * and a click's ripple end at once rather than on timers the fixture's CSS does + * not reach. + */ +export async function launchElectron(userDataDir?: string): Promise { + const app = await electron.launch(launchOptions(userDataDir)) + const reduceMotion = (page: Page): Promise => page.emulateMedia({ reducedMotion: 'reduce' }) + app.on('window', (page) => { + // A window closed before the emulation reached it has no motion left. + reduceMotion(page).catch(() => undefined) + }) + await Promise.all(app.windows().map(reduceMotion)) + return app +} diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index e3483220..375483e2 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -49,7 +49,12 @@ test.describe.serial('Undo and redo in a focused field', () => { await input.fill('127.0.0.1') await expect.poll(async () => (await savedConnection(mainPage)).host).toBe('127.0.0.1') - await typeOver(mainPage, input, '10.0.0.5') + // One insert rather than a key at a time: typed keys are one undo step only + // while nothing else changes the DOM between them, and in the full suite + // something did. + await input.click() + await mainPage.keyboard.press('ControlOrMeta+a') + await mainPage.keyboard.insertText('10.0.0.5') await expect.poll(async () => (await savedConnection(mainPage)).host).toBe('10.0.0.5') await mainPage.keyboard.press('ControlOrMeta+z') diff --git a/e2e/specs/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index 0eeebc9b..f511ec54 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -1,13 +1,7 @@ -import { - test, - expect, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' import { resolve } from 'path' import { loadServerConfig, selectUnitId } from '../../fixtures/helpers' -import { launchOptions } from '../../fixtures/launch' +import { launchElectron } from '../../fixtures/launch' import { keepOutput } from '../../fixtures/electron-app' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') @@ -18,7 +12,7 @@ let app: ElectronApplication let page: Page async function launchApp(clearStorage = true): Promise { - app = await electron.launch(launchOptions()) + app = await launchElectron() // This spec launches its own app, so it needs the same output kept as the // shared fixture keeps. Its launches race the worker's app for the single // instance lock, which is where the log has to be able to speak. diff --git a/e2e/specs/02-standalone/02-macos-dock.spec.ts b/e2e/specs/02-standalone/02-macos-dock.spec.ts index dff32cd0..6fb39126 100644 --- a/e2e/specs/02-standalone/02-macos-dock.spec.ts +++ b/e2e/specs/02-standalone/02-macos-dock.spec.ts @@ -7,7 +7,7 @@ import { } from '@playwright/test' import net from 'net' import { keepOutput } from '../../fixtures/electron-app' -import { launchOptions, ownProfileDir } from '../../fixtures/launch' +import { launchElectron, launchOptions, ownProfileDir } from '../../fixtures/launch' import { connectClient, navigateToClient, navigateToServer } from '../../fixtures/helpers' /** @@ -98,7 +98,7 @@ test.describe.serial('macOS dock — the app outlives its windows', () => { test.beforeAll(async () => { await startDevice() profile = ownProfileDir() - app = await electron.launch(launchOptions(profile)) + app = await launchElectron(profile) keepOutput(app) page = await waitForWindow() await page.waitForTimeout(500) diff --git a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts index 5f5e1761..50afa095 100644 --- a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -1,17 +1,11 @@ -import { - test, - expect, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' import { addRegister, cleanServerState, navigateToHome, splitOutServerWindow } from '../../fixtures/helpers' -import { launchOptions } from '../../fixtures/launch' +import { launchElectron } from '../../fixtures/launch' import { keepOutput } from '../../fixtures/electron-app' let app: ElectronApplication @@ -21,7 +15,7 @@ async function launchApp(clearStorage: boolean): Promise { // Cleared first, so the search below has to find this launch's own window // rather than passing on the one the previous launch left here. page = undefined as unknown as Page - app = await electron.launch(launchOptions()) + app = await launchElectron() keepOutput(app) if (clearStorage) { await app.evaluate((ctx) => diff --git a/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts b/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts index bd801276..144ad57a 100644 --- a/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts +++ b/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts @@ -13,14 +13,8 @@ * in `session` mode — testing the real pkexec path and restoring the machine in * one go. It raises a PolicyKit password prompt. */ -import { - test, - expect, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' -import { launchOptions } from '../../fixtures/launch' +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import { launchElectron } from '../../fixtures/launch' import { navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { readFileSync } from 'fs' @@ -36,7 +30,7 @@ function kernelFloor(): number { } async function launchApp(clearStorage = true): Promise { - app = await electron.launch(launchOptions()) + app = await launchElectron() if (clearStorage) { await app.evaluate((ctx) => ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) diff --git a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts index f6ad1234..c4aa88e1 100644 --- a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts +++ b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts @@ -10,13 +10,7 @@ * The port is found by USB vendor ID, so the run is unattended: * yarn test:e2e:hardware */ -import { - test, - expect, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' import { resolve } from 'path' import { enableAdvancedMode, @@ -26,7 +20,7 @@ import { scrollCell, expectCellContains } from '../../fixtures/helpers' -import { launchOptions } from '../../fixtures/launch' +import { launchElectron } from '../../fixtures/launch' import { findArduinoPort, selectComPort } from '../../fixtures/arduino-port' @@ -37,7 +31,7 @@ let page: Page let arduinoPort = '' async function launchApp(clearStorage = true): Promise { - app = await electron.launch(launchOptions()) + app = await launchElectron() if (clearStorage) { await app.evaluate((ctx) => ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) From 2ee63a62c793a85fb8252087287008457b3cb9b2 Mon Sep 17 00:00:00 2001 From: Jens De Ketelaere Date: Wed, 23 Sep 2026 01:42:19 +0200 Subject: [PATCH 108/137] test: check only the line settings a linux pty keeps On kernel 7.0.0-30 a pty reads back cs8 -parenb whatever tcsetattr asked for, so spec 27 failed on Linux at its first server case. The speed, cstopb and parodd come back as set and are still asserted there; the data bits and parity are asserted on macOS alone. Measured with tcsetattr on a socat pty, without the app. On Linux the kept assertions can still fail: expecting -cstopb for 7E2 and -parodd for 8O1 both go red. --- .../01-main/27-serial-line-settings.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/e2e/specs/01-main/27-serial-line-settings.spec.ts b/e2e/specs/01-main/27-serial-line-settings.spec.ts index 972faa06..a11e551b 100644 --- a/e2e/specs/01-main/27-serial-line-settings.spec.ts +++ b/e2e/specs/01-main/27-serial-line-settings.spec.ts @@ -35,9 +35,9 @@ const CFLAGS = [ * The line settings the port at `path` is currently open with. * * `tcsetattr` writes them onto the pty, so `stty` reads back what the binding - * applied rather than what Modbux believes it sent. Measured on a socat pair: - * opening one end at 19200 7E2 gives `speed 19200` and `cs7 parenb cstopb`, - * while the other end stays at the 9600 cs8 socat left it. + * applied rather than what Modbux believes it sent. On macOS, opening one end of + * a socat pair at 19200 7E2 gives `speed 19200` and `cs7 parenb cstopb`, while + * the other end stays at the 9600 cs8 socat left it. * * `-a` is what makes an off flag appear as `-parenb`; without it both stty * implementations print only what differs from their own idea of sane. BSD @@ -53,6 +53,14 @@ const lineSettings = (path: string): { speed: number; flags: string[] } => { } } +/** + * The flags a Linux pty does not keep. Measured with `tcsetattr` on a socat pty + * on kernel 7.0.0-30: every frame this spec sets reads back `cs8 -parenb`, while + * the speed, `cstopb` and `parodd` read back as set. + */ +const PTY_DROPS: readonly string[] = + process.platform === 'linux' ? ['cs5', 'cs6', 'cs7', 'cs8', 'parenb', '-parenb'] : [] + /** * Assert what a port opened with, retrying while the server restarts. * @@ -64,10 +72,11 @@ const expectLineSettings = async ( path: string, expected: { speed: number; flags: string[] } ): Promise => { + const kept = expected.flags.filter((flag) => !PTY_DROPS.includes(flag)) await expect(async () => { const actual = lineSettings(path) expect(actual.speed).toBe(expected.speed) - for (const flag of expected.flags) expect(actual.flags).toContain(flag) + for (const flag of kept) expect(actual.flags).toContain(flag) }).toPass({ timeout: 10_000 }) } From 5fe9d3857c40d2a05106039d27544d064d36cc13 Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 23 Sep 2026 03:41:49 +0200 Subject: [PATCH 109/137] fix: a stopped register scan shows its last rows with the stop The renderer holds a scan's rows and writes them on a 100 ms timer. Main sends the rows of the chunk in flight before the `client_state` that ends the scan, but that state was written at once and the rows up to 100 ms later, so the button read Start Scanning while a row was still to come. The scan registers spec's stop case counted 4 rows after the stop and 5 a second later, in one full `yarn test:e2e` run on Windows. The `client_state` handler now writes what is waiting when the push says no register scan runs, before it writes the state. `scanRowBatching.test.ts` holds the rows in the grid on the push that ends the scan, and a push that says the scan still runs leaves them to the timer. With the flush removed the first failed; with its condition inverted both did. `11-scan-registers.spec.ts` passed three runs of three, and the full `yarn test:e2e` passed. --- .../context/__tests__/scanRowBatching.test.ts | 26 +++++++++++++++++++ src/renderer/src/context/data.zustand.ts | 3 +++ 2 files changed, 29 insertions(+) diff --git a/src/renderer/src/context/__tests__/scanRowBatching.test.ts b/src/renderer/src/context/__tests__/scanRowBatching.test.ts index 8d142138..dd441833 100644 --- a/src/renderer/src/context/__tests__/scanRowBatching.test.ts +++ b/src/renderer/src/context/__tests__/scanRowBatching.test.ts @@ -62,6 +62,32 @@ describe('rows a scan finds', () => { }) }) +// Main sends the rows of the chunk in flight before the state that says the +// scan ended, and the button reads that state. +describe('rows a scan found when it ends', () => { + it('reach the grid with the state that ends it', async () => { + const { addresses } = await loaded(true) + fireEvent('register_data', rows([0, 1])) + + fireEvent('client_state', { ...defaultClientState, scanningRegisters: false }) + + expect(addresses()).toEqual([0, 1]) + vi.advanceTimersByTime(SCAN_FLUSH_MS) + expect(addresses()).toEqual([0, 1]) + }) + + it('wait for the flush while a state says the scan still runs', async () => { + const { addresses } = await loaded(true) + fireEvent('register_data', rows([0, 1])) + + fireEvent('client_state', { ...defaultClientState, scanningRegisters: true }) + + expect(addresses()).toEqual([]) + vi.advanceTimersByTime(SCAN_FLUSH_MS) + expect(addresses()).toEqual([0, 1]) + }) +}) + // A poll replaces the grid, so anything a scan left waiting answers a question // nobody is asking any more. describe('rows a poll reads', () => { diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 6ea16bb5..53d85093 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -210,6 +210,9 @@ onEvent('address_groups', (addressGroups) => { // Client state, like polling, scanning, etc. onEvent('client_state', (clientState) => { clientStatePushed = true + // Main sends a scan's last rows before the state that ends it, so they are + // written before the button says the scan stopped, not up to a flush later. + if (!clientState.scanningRegisters) flushScanRows() const dataZustand = useDataZustand.getState() dataZustand.setClientState(clientState) }) From 7ee4264bb5ebb33a251f6cab448563eb2c133cf5 Mon Sep 17 00:00:00 2001 From: Jens De Ketelaere Date: Wed, 23 Sep 2026 01:49:12 +0200 Subject: [PATCH 110/137] test: keep the ipc tests from running pkexec and logout for real ipc.test.ts reached the real privilegedPort and serialGroup modules. On a Linux desktop with pkexec installed, 'lets a valid apply_privileged_port_fix payload through' ran pkexec for the port floor, and 'covers every channel that guards its payload' ran it for usermod, with request_logout and the desktop's logout commands next in line. Both timed out at 5000ms waiting on the PolicyKit prompt. Both modules are mocked now, and a new test asserts the handler hands applyPrivilegedPortFix the mode it was sent; mapping 'persist' to another mode in ipc.ts turns it red. --- src/main/__tests__/ipc.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index 52d403de..20e533de 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -9,6 +9,20 @@ vi.mock('electron', () => ({ } })) +// The real modules run pkexec and the desktop's logout commands. On a Linux +// desktop with pkexec installed that puts a password prompt on screen for +// `usermod` and the port floor, and the test times out waiting on it. +const applyPrivilegedPortFix = vi.fn() +vi.mock('../modules/privilegedPort', () => ({ + applyPrivilegedPortFix: (...args: unknown[]): unknown => applyPrivilegedPortFix(...args), + getPrivilegedPortStatus: vi.fn() +})) +vi.mock('../modules/serialGroup', () => ({ + applySerialGroupFix: vi.fn(), + getSerialGroupStatus: vi.fn(), + requestLogout: vi.fn() +})) + import { AddRegisterParamsSchema, ConnectionConfigSchema, @@ -621,6 +635,15 @@ describe('each guarded channel got its own schema', () => { expect(sent.map(({ message }) => message.error)).toEqual([]) }) + it('hands the privileged port fix the mode it was sent', async () => { + start() + applyPrivilegedPortFix.mockClear() + + await invoke('apply_privileged_port_fix', 'persist') + + expect(applyPrivilegedPortFix).toHaveBeenCalledWith('persist') + }) + // `undefined` is the payload every schema here refuses. A string is not: // `delete_server` and `reset_server` take one. // From b5f79aeb20eb6201109166fb66600714cd44db3a Mon Sep 17 00:00:00 2001 From: Harted Date: Wed, 23 Sep 2026 11:28:16 +0200 Subject: [PATCH 111/137] test: ask main again when its inspector drops an evaluate answer Main's Node inspector can answer `Promise was collected` to the call Playwright awaits, and Playwright reports that as "Execution context was destroyed" while main keeps running (microsoft/playwright#33737). That was the flake in 02-standalone/01-persistence, reopening the app without clearing storage. evaluateMain in e2e/fixtures/launch.ts retries on that error with a pause, since retries without one were dropped as well, and every app.evaluate under e2e/ goes through it. 01-persistence ran green at --repeat-each 20, and the full e2e suite passed. --- e2e/fixtures/electron-app.ts | 14 +++++--- e2e/fixtures/launch.ts | 24 +++++++++++++ e2e/fixtures/presentation-app.ts | 14 +++++--- e2e/specs/01-main/01-home-navigation.spec.ts | 11 +++--- e2e/specs/01-main/05-file-io.spec.ts | 25 +++++++------ e2e/specs/01-main/10-split-view.spec.ts | 35 +++++++++++-------- e2e/specs/01-main/14-client-config-io.spec.ts | 13 ++++--- .../01-main/18-huawei-manual-client.spec.ts | 13 ++++--- e2e/specs/01-main/21-bitmap-settings.spec.ts | 13 ++++--- e2e/specs/01-main/26-server-layout.spec.ts | 33 +++++++++-------- .../31-split-view-client-state.spec.ts | 13 ++++--- e2e/specs/01-main/32-undo-redo.spec.ts | 13 ++++--- .../02-standalone/01-persistence.spec.ts | 14 +++++--- e2e/specs/02-standalone/02-macos-dock.spec.ts | 10 +++--- .../03-split-view-persistence.spec.ts | 26 ++++++++------ .../03-presentation/01-feature-tour.spec.ts | 13 ++++--- .../01-privileged-port-modal.spec.ts | 14 +++++--- .../99-hardware/02-iem3000-reconnect.spec.ts | 14 +++++--- 18 files changed, 201 insertions(+), 111 deletions(-) diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index d9ad10d2..e498a15a 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,5 +1,5 @@ import { test as base, type ElectronApplication, type Page } from '@playwright/test' -import { launchElectron } from './launch' +import { launchElectron, evaluateMain } from './launch' import { createWriteStream, mkdirSync } from 'fs' import { join } from 'path' @@ -46,8 +46,10 @@ export const test = base.extend<{}, ElectronFixtures>({ const app = await launchElectron() keepOutput(app) - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) await use(app) @@ -65,8 +67,10 @@ export const test = base.extend<{}, ElectronFixtures>({ searchCount++ // Check BrowserWindow title (HTML was removed to not override it) - const found = await electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const found = await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) if (found && electronApp.windows().length === 1) { diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index e904b7be..1919ed23 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -142,3 +142,27 @@ export async function launchElectron(userDataDir?: string): Promise<ElectronAppl await Promise.all(app.windows().map(reduceMotion)) return app } + +/** + * Runs an `app.evaluate` call again when main's inspector drops its answer. + * + * Node's inspector can collect the promise Playwright awaits before it settles + * and answers `Promise was collected`, which Playwright reports as "Execution + * context was destroyed, most likely because of a navigation" although main is + * running (microsoft/playwright#33737). The function may already have run, + * so every call passed here has to be safe to run twice. The drops come in + * runs, so a retry waits before it asks again. + */ +export async function evaluateMain<R>(evaluate: () => Promise<R>): Promise<R> { + const deadline = Date.now() + 5000 + for (;;) { + try { + return await evaluate() + } catch (error) { + const dropped = + error instanceof Error && error.message.includes('Execution context was destroyed') + if (!dropped || Date.now() > deadline) throw error + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } +} diff --git a/e2e/fixtures/presentation-app.ts b/e2e/fixtures/presentation-app.ts index d8634a0f..63aaee7a 100644 --- a/e2e/fixtures/presentation-app.ts +++ b/e2e/fixtures/presentation-app.ts @@ -4,7 +4,7 @@ import { type ElectronApplication, type Page } from '@playwright/test' -import { launchOptions } from './launch' +import { launchOptions, evaluateMain } from './launch' export type PresentationFixtures = { electronApp: ElectronApplication @@ -18,8 +18,10 @@ export const test = base.extend<{}, PresentationFixtures>({ async ({}, use): Promise<void> => { const app = await electron.launch(launchOptions()) - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) await use(app) @@ -36,8 +38,10 @@ export const test = base.extend<{}, PresentationFixtures>({ while (searchCount < 10) { searchCount++ - const found = await electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const found = await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) if (found && electronApp.windows().length === 1) { diff --git a/e2e/specs/01-main/01-home-navigation.spec.ts b/e2e/specs/01-main/01-home-navigation.spec.ts index cc0e0ee2..ea349531 100644 --- a/e2e/specs/01-main/01-home-navigation.spec.ts +++ b/e2e/specs/01-main/01-home-navigation.spec.ts @@ -1,11 +1,14 @@ import { test, expect } from '../../fixtures/electron-app' +import { evaluateMain } from '../../fixtures/launch' test.describe.serial('Home screen and navigation', () => { test('app launches with correct title', async ({ electronApp }) => { - const title = await electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows() - .find((w) => w.getTitle() === 'Modbux') - ?.getTitle() + const title = await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find((w) => w.getTitle() === 'Modbux') + ?.getTitle() + ) ) expect(title).toBe('Modbux') }) diff --git a/e2e/specs/01-main/05-file-io.spec.ts b/e2e/specs/01-main/05-file-io.spec.ts index f11fcf90..5a177a23 100644 --- a/e2e/specs/01-main/05-file-io.spec.ts +++ b/e2e/specs/01-main/05-file-io.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from '../../fixtures/electron-app' import { addBool, navigateToServer, navigateToClient, selectUnitId } from '../../fixtures/helpers' import { resolve } from 'path' import { tmpdir } from 'os' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const CONFIG_FILES = { @@ -154,11 +155,13 @@ test.describe.serial('File I/O — open, save, clear server and client configs', }) => { const savePath = resolve(tmpdir(), `modbux-test-save-${Date.now()}.json`) - await electronApp.evaluate(({ session }, path) => { - session.defaultSession.on('will-download', (_event, item) => { - item.setSavePath(path) - }) - }, savePath) + await evaluateMain(() => + electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + ) await mainPage.getByTestId('server-save-btn').click() await mainPage.waitForTimeout(1000) @@ -202,11 +205,13 @@ test.describe.serial('File I/O — open, save, clear server and client configs', await expect(mainPage.getByTestId('section-coils')).toContainText('(2)') const savePath = resolve(tmpdir(), `modbux-test-coils-off-${Date.now()}.json`) - await electronApp.evaluate(({ session }, path) => { - session.defaultSession.on('will-download', (_event, item) => { - item.setSavePath(path) - }) - }, savePath) + await evaluateMain(() => + electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + ) await mainPage.getByTestId('server-save-btn').click() await mainPage.waitForTimeout(1000) diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index 92932486..dfd8a73b 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../fixtures/electron-app' import { navigateToHome, navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { type Page } from '@playwright/test' import net from 'net' +import { evaluateMain } from '../../fixtures/launch' let serverPage: Page let serverPort: number @@ -11,11 +12,13 @@ const masterEvents: string[] = [] test.describe.serial('Split View — Server in separate window', () => { test.afterAll(async ({ electronApp }) => { master?.destroy() - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() === 'Server') + .forEach((w) => w.close()) + }) + ) }) test('read the server port, then navigate to home', async ({ mainPage }) => { @@ -39,10 +42,12 @@ test.describe.serial('Split View — Server in separate window', () => { test('open split view from Home', async ({ electronApp, mainPage }) => { serverPage = await splitOutServerWindow(electronApp, mainPage) await serverPage.waitForTimeout(500) - const title = await electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows() - .find((w) => w.getTitle() === 'Server') - ?.getTitle() + const title = await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .find((w) => w.getTitle() === 'Server') + ?.getTitle() + ) ) expect(title).toBe('Server') }) @@ -93,11 +98,13 @@ test.describe.serial('Split View — Server in separate window', () => { electronApp, mainPage }) => { - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .find((w) => w.getTitle() === 'Server') - ?.close() - }) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .find((w) => w.getTitle() === 'Server') + ?.close() + }) + ) await expect(mainPage.getByTestId('home-btn')).toBeVisible() }) }) diff --git a/e2e/specs/01-main/14-client-config-io.spec.ts b/e2e/specs/01-main/14-client-config-io.spec.ts index fc1aea2b..2ec92f85 100644 --- a/e2e/specs/01-main/14-client-config-io.spec.ts +++ b/e2e/specs/01-main/14-client-config-io.spec.ts @@ -11,6 +11,7 @@ import { } from '../../fixtures/helpers' import { resolve } from 'path' import { tmpdir } from 'os' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const CONFIG_FILES = { @@ -82,11 +83,13 @@ test.describe.serial('Client config I/O — view, save, clear, load', () => { test('save client config — verify download content', async ({ electronApp, mainPage }) => { const savePath = resolve(tmpdir(), `modbux-client-test-save-${Date.now()}.json`) - await electronApp.evaluate(({ session }, path) => { - session.defaultSession.on('will-download', (_event, item) => { - item.setSavePath(path) - }) - }, savePath) + await evaluateMain(() => + electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + ) await mainPage.getByTestId('save-config-btn').click() await mainPage.waitForTimeout(1000) diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index 1cd99fb0..0e1d81d3 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -21,6 +21,7 @@ import { import { resolve } from 'path' import { readFileSync } from 'fs' import { tmpdir } from 'os' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-huawei-smartlogger.json') @@ -339,11 +340,13 @@ test.describe.serial('Huawei Smart Logger — JSON server + manual client config test('save manual config and verify JSON output', async ({ electronApp, mainPage }) => { const savePath = resolve(tmpdir(), `modbux-huawei-manual-${Date.now()}.json`) - await electronApp.evaluate(({ session }, path) => { - session.defaultSession.on('will-download', (_event, item) => { - item.setSavePath(path) - }) - }, savePath) + await evaluateMain(() => + electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + ) await mainPage.getByTestId('save-config-btn').click() await mainPage.waitForTimeout(1000) diff --git a/e2e/specs/01-main/21-bitmap-settings.spec.ts b/e2e/specs/01-main/21-bitmap-settings.spec.ts index b61eb1f1..c48bdc98 100644 --- a/e2e/specs/01-main/21-bitmap-settings.spec.ts +++ b/e2e/specs/01-main/21-bitmap-settings.spec.ts @@ -11,6 +11,7 @@ import { } from '../../fixtures/helpers' import { resolve } from 'path' import { tmpdir } from 'os' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const BITMAP_CONFIG = resolve(CONFIG_DIR, 'client-bitmap.json') @@ -125,11 +126,13 @@ test.describe.serial('Bitmap settings — color, invert & config persistence', ( test('save config — bitMap settings round-trip', async ({ electronApp, mainPage }) => { const savePath = resolve(tmpdir(), `modbux-bitmap-test-${Date.now()}.json`) - await electronApp.evaluate(({ session }, path) => { - session.defaultSession.on('will-download', (_event, item) => { - item.setSavePath(path) - }) - }, savePath) + await evaluateMain(() => + electronApp.evaluate(({ session }, path) => { + session.defaultSession.on('will-download', (_event, item) => { + item.setSavePath(path) + }) + }, savePath) + ) await mainPage.getByTestId('save-config-btn').click() await mainPage.waitForTimeout(1000) diff --git a/e2e/specs/01-main/26-server-layout.spec.ts b/e2e/specs/01-main/26-server-layout.spec.ts index 8ca5c2fb..a7de4054 100644 --- a/e2e/specs/01-main/26-server-layout.spec.ts +++ b/e2e/specs/01-main/26-server-layout.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../fixtures/electron-app' import { navigateToServer, addBool } from '../../fixtures/helpers' +import { evaluateMain } from '../../fixtures/launch' /** * The server view lays its four panels out in a wrapping flex row. Nothing @@ -29,14 +30,16 @@ test.describe.serial('Server layout — panels stay inside the view', () => { [820, 800] ] as [number, number][]) { test(`no overflow at ${width}x${height}`, async ({ mainPage, electronApp }) => { - await electronApp.evaluate( - ({ BrowserWindow }, size) => { - const [window] = BrowserWindow.getAllWindows() - if (!window) throw new Error('no window to resize') - const [w, h] = size - window.setSize(w, h) - }, - [width, height] as [number, number] + await evaluateMain(() => + electronApp.evaluate( + ({ BrowserWindow }, size) => { + const [window] = BrowserWindow.getAllWindows() + if (!window) throw new Error('no window to resize') + const [w, h] = size + window.setSize(w, h) + }, + [width, height] as [number, number] + ) ) await mainPage.waitForTimeout(500) @@ -49,11 +52,13 @@ test.describe.serial('Server layout — panels stay inside the view', () => { } test('restore the window', async ({ electronApp }) => { - await electronApp.evaluate(({ BrowserWindow }, size) => { - const [window] = BrowserWindow.getAllWindows() - if (!window) throw new Error('no window to restore') - const [w, h] = size - window.setSize(w, h) - }, DEFAULT_SIZE) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }, size) => { + const [window] = BrowserWindow.getAllWindows() + if (!window) throw new Error('no window to restore') + const [w, h] = size + window.setSize(w, h) + }, DEFAULT_SIZE) + ) }) }) diff --git a/e2e/specs/01-main/31-split-view-client-state.spec.ts b/e2e/specs/01-main/31-split-view-client-state.spec.ts index 60f9026e..fd6282cd 100644 --- a/e2e/specs/01-main/31-split-view-client-state.spec.ts +++ b/e2e/specs/01-main/31-split-view-client-state.spec.ts @@ -17,6 +17,7 @@ import { } from '../../fixtures/helpers' import { resolve } from 'path' import { type Page } from '@playwright/test' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') @@ -33,11 +34,13 @@ let serverPage: Page // see it. test.describe.serial('Read configuration survives the split out server window', () => { test.afterAll(async ({ electronApp }) => { - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() === 'Server') + .forEach((w) => w.close()) + }) + ) }) test('clean server state', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index 375483e2..4a11d05b 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -17,6 +17,7 @@ import { selectRegisterType, splitOutServerWindow } from '../../fixtures/helpers' +import { evaluateMain } from '../../fixtures/launch' /** What the client store last persisted, read the way the next launch reads it. */ const savedConnection = async (p: Page): Promise<{ host: string; unitId: number }> => @@ -178,11 +179,13 @@ test.describe.serial('The server steps travel with the server view', () => { await expect(serverPage.getByTestId('server-bool-row-coils-5')).toBeVisible() await leaveTheField(serverPage) - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .find((window) => window.getTitle() === 'Server') - ?.close() - }) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .find((window) => window.getTitle() === 'Server') + ?.close() + }) + ) await expect(mainPage.getByTestId('home-btn')).toBeVisible() await navigateToServer(mainPage) const mainRow = mainPage.getByTestId('server-bool-row-coils-5') diff --git a/e2e/specs/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index f511ec54..5ab8a000 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type ElectronApplication, type Page } from '@playwright/test' import { resolve } from 'path' import { loadServerConfig, selectUnitId } from '../../fixtures/helpers' -import { launchElectron } from '../../fixtures/launch' +import { launchElectron, evaluateMain } from '../../fixtures/launch' import { keepOutput } from '../../fixtures/electron-app' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') @@ -18,16 +18,20 @@ async function launchApp(clearStorage = true): Promise<void> { // instance lock, which is where the log has to be able to speak. keepOutput(app) if (clearStorage) { - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) } // Wait for main window (check BrowserWindow title, not HTML title) let searchCount = 0 while (searchCount < 10) { searchCount++ - const found = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const found = await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) const [firstWindow] = app.windows() if (found && firstWindow && app.windows().length === 1) { diff --git a/e2e/specs/02-standalone/02-macos-dock.spec.ts b/e2e/specs/02-standalone/02-macos-dock.spec.ts index 6fb39126..e727f53d 100644 --- a/e2e/specs/02-standalone/02-macos-dock.spec.ts +++ b/e2e/specs/02-standalone/02-macos-dock.spec.ts @@ -7,7 +7,7 @@ import { } from '@playwright/test' import net from 'net' import { keepOutput } from '../../fixtures/electron-app' -import { launchElectron, launchOptions, ownProfileDir } from '../../fixtures/launch' +import { launchElectron, launchOptions, ownProfileDir, evaluateMain } from '../../fixtures/launch' import { connectClient, navigateToClient, navigateToServer } from '../../fixtures/helpers' /** @@ -74,7 +74,9 @@ function startDevice(): Promise<void> { } async function windowCount(): Promise<number> { - return app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length) + return evaluateMain(() => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length) + ) } /** @@ -133,8 +135,8 @@ test.describe.serial('macOS dock — the app outlives its windows', () => { }) test('closing every window leaves the app running', async () => { - await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().forEach((w) => w.close()) + await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().forEach((w) => w.close())) ) await expect.poll(windowCount, { timeout: 10000 }).toBe(0) }) diff --git a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts index 50afa095..902dead6 100644 --- a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -5,7 +5,7 @@ import { navigateToHome, splitOutServerWindow } from '../../fixtures/helpers' -import { launchElectron } from '../../fixtures/launch' +import { launchElectron, evaluateMain } from '../../fixtures/launch' import { keepOutput } from '../../fixtures/electron-app' let app: ElectronApplication @@ -18,15 +18,19 @@ async function launchApp(clearStorage: boolean): Promise<void> { app = await launchElectron() keepOutput(app) if (clearStorage) { - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) } let searchCount = 0 while (searchCount < 10) { searchCount++ - const found = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const found = await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) const [firstWindow] = app.windows() if (found && firstWindow && app.windows().length === 1) { @@ -86,11 +90,13 @@ test.describe.serial('A register added in the split out server window', () => { }) test('the split window closes and the main window keeps it', async () => { - await app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) + await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() === 'Server') + .forEach((w) => w.close()) + }) + ) await page.waitForTimeout(3000) }) diff --git a/e2e/specs/03-presentation/01-feature-tour.spec.ts b/e2e/specs/03-presentation/01-feature-tour.spec.ts index 39a72c9e..79b776d0 100644 --- a/e2e/specs/03-presentation/01-feature-tour.spec.ts +++ b/e2e/specs/03-presentation/01-feature-tour.spec.ts @@ -42,6 +42,7 @@ import { readFileSync, writeFileSync } from 'fs' import { Locator, type Page } from '@playwright/test' import GIFEncoder from 'gif-encoder-2' import { PNG } from 'pngjs' +import { evaluateMain } from '../../fixtures/launch' // ─── Paths ────────────────────────────────────────────────────────────────── @@ -953,11 +954,13 @@ test.describe.serial('Act V — Side by Side', () => { test('scene 33 — cleanup', async ({ electronApp, mainPage }) => { await disconnectClient(mainPage) - await electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) + await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() === 'Server') + .forEach((w) => w.close()) + }) + ) await beat(mainPage, 500) await expect(mainPage.getByTestId('home-btn')).toBeVisible() diff --git a/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts b/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts index 144ad57a..7dd7bf7f 100644 --- a/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts +++ b/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts @@ -14,7 +14,7 @@ * one go. It raises a PolicyKit password prompt. */ import { test, expect, type ElectronApplication, type Page } from '@playwright/test' -import { launchElectron } from '../../fixtures/launch' +import { launchElectron, evaluateMain } from '../../fixtures/launch' import { navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { readFileSync } from 'fs' @@ -32,16 +32,20 @@ function kernelFloor(): number { async function launchApp(clearStorage = true): Promise<void> { app = await launchElectron() if (clearStorage) { - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) } // firstWindow() can hand back a window that is already on its way out, so // wait for the real main window the way the hardware specs do. let found: Page | undefined for (let attempt = 0; attempt < 10 && !found; attempt++) { - const ready = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const ready = await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) if (ready && app.windows().length === 1) found = app.windows()[0] else await new Promise((r) => setTimeout(r, 1000)) diff --git a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts index c4aa88e1..f4bb0685 100644 --- a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts +++ b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts @@ -20,7 +20,7 @@ import { scrollCell, expectCellContains } from '../../fixtures/helpers' -import { launchElectron } from '../../fixtures/launch' +import { launchElectron, evaluateMain } from '../../fixtures/launch' import { findArduinoPort, selectComPort } from '../../fixtures/arduino-port' @@ -33,15 +33,19 @@ let arduinoPort = '' async function launchApp(clearStorage = true): Promise<void> { app = await launchElectron() if (clearStorage) { - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) } let searchCount = 0 while (searchCount < 10) { searchCount++ - const found = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + const found = await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) ) const [firstWindow] = app.windows() if (found && firstWindow && app.windows().length === 1) { From 7e2f36ac92d0831be357d27d02592f0aad2f5837 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 12:13:18 +0200 Subject: [PATCH 112/137] docs: the changelog says what a released version did Every Unreleased entry was measured against the tree and against v2.3.0. Four described a defect no release carried, because it arose and was fixed on this branch; two of those are dropped and two fold into the entry they belong to. One claimed a state only mocks reach and is cut. The rest say what 2.3.0 did rather than what an intermediate commit did, and drop the figures and clauses nothing measured. --- CHANGELOG.md | 111 +++++++++++++++++++++------------------------------ 1 file changed, 46 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c65c65f..b12b9520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,27 +18,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and the server's steps go with the server view into its own window and back. A connection setting waits until you disconnect, and Modbux says so, as it does when there is nothing left to undo. In a text field the keys undo your - typing, as before. Each view keeps its last 100 steps until Modbux closes. + typing. Each view keeps its last 100 steps until Modbux closes. ### Fixed - **A scan no longer stalls the window on a large configuration.** Every step a scan reported, every transaction it logged and every change of connection state wrote your whole saved client configuration back to disk, register - mapping and all. With two thousand mapped registers that is close to a - millisecond of frozen window each time, and a unit ID scan over the full range - with all four register types reports 988 steps. None of it is worth keeping + mapping and all. With a large mapping that is close to a millisecond of + frozen window each time, and a unit ID scan reports a step for every unit ID + and register type it asks. None of it is worth keeping across a launch, and none of it is written any more. -- **A register a saved session cannot read no longer empties the other - servers.** Every server's registers were read back as one, so a single - register Modbux refused emptied the map of every server you had rather than - the one holding it. It costs that one server's registers now, and the servers - beside it keep theirs. +- **A register a saved session cannot read no longer resets every server.** One + register Modbux refused reset the whole server setup to its defaults: every + server, with its name, port and registers. It now costs the registers of the + one server holding it, and every other server comes back as it was. - **A blank COM port is no longer sent to the serial connection.** Clearing the COM field wrote an empty port name into the connection, so a connect attempt - failed on nothing to open. The field keeps what you type and marks it, and the - connection keeps the last port you picked for the rest of the session, which - is what the host field already did. + failed on nothing to open. The field keeps what you type and marks it, and + Connect stays greyed until it names a port again, as it does for a blank + host. - **Changing the unit ID now empties the grid the way the address does.** The rows the previous unit answered stayed on screen under a unit ID that had not read them, so you were looking at one device's values under another's ID. The @@ -60,15 +59,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the client's bitmap panel put two requests on one connection, and the second carried the word from before the first. A write now holds the connection until the register has been read back, the bits stop taking a click for that moment, - and the Read button and the Read Configuration toggle grey out with them. + and the Read and Poll buttons and the Read Configuration toggle grey out with + them. - **A coil read can now ask for the 2000 bits the protocol allows.** The Length field stopped at 125 whatever the type selected, which is the ceiling for holding and input registers, so reading a thousand coils took eight reads rather than one. It now stops at what the selected type answers, and at what is left from the address: a read starting at 65500 offers 36. Writing those - coils back stops at 1968, which is what one request carries. A read no longer - runs off the end of the range either, and neither does a scan: a length left - over from a wider read asked a device for addresses that do not exist. + coils back stops at 1968, which is what one request carries. A read of the + toolbar's range no longer runs off the end of the range either, and a read or + a scan no longer asks for more than one response carries: a length or chunk + size left over from a coil read asked a device for 2000 registers. - **A scan that reaches past the last unit ID or register now stops there.** A Start of 200 with a Count of 100 asked for unit ID 299, and an address of 60000 with a Length of 10000 asked for register 69999. Neither exists, so the @@ -78,8 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 scanned nothing in the same silence, and now goes back to 1 when you leave the field. - **The transaction log now shows the address of a coil read and of a write.** - The Addr column was blank for every coil read, every discrete input read and - every write Modbux sends, so a log of coil traffic said nothing about which + The Addr column was blank for every coil read, every discrete input read, + every coil write and every multi-register write, so a log of coil traffic said nothing about which address each request went to. - **The unit ID scan no longer asks for more than a device can answer.** Its Length field took anything up to 65535 and sent it as the quantity, which is @@ -109,7 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A file that is not a configuration is now refused instead of opened.** Picking a JSON file holding a number, a string or a list emptied the server and reported that the configuration had been updated from an older format. A - file holding nothing at all emptied it too and then failed with a message + file holding only `null` emptied it too and then failed with a message about a property of null. A file claiming a version that is not a number got one of those or the newer-version warning, depending on the value. - **Opening the wrong file no longer empties the server.** Picking a client @@ -121,47 +122,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on that launch and on every launch after it, with no message and nothing on screen to clear the configuration with. A refusal now costs the one server it belonged to. -- **A server added in the split out window can be changed when that window - closes.** Its port, unit id and byte order each refused every change with no - message until Modbux was restarted. - **A server register the encoder cannot serve is now refused by name.** A config file could put a fixed value outside the range of its own data type, a - string wider than the register map, a register running past the last address, - a generator drawing between two numbers its data type cannot hold, or a - generator whose interval was so long it fired every millisecond instead. - Opening it left the server view blank for that launch and every launch after - it, with no message and nothing on screen to clear it with. The file is now - refused with the register and the field named, and one already saved is - dropped on the next launch with the rest of the setup kept. + string wider than the register map, or a register running past the last + address, and opening it left the server view blank for that launch and every + launch after it, with no message and nothing on screen to clear it with. A + generator drawing between two numbers its data type cannot hold served 0 + instead, and one whose interval was too long fired every millisecond. The + file is now refused with the register and the field named, and one already + saved is dropped on the next launch, with the addresses it stood on freed and + the rest of the setup kept. - **Splitting the server into its own window no longer turns Read Configuration off.** The toggle stayed on while the reading went back to the - address and length in the toolbar, so every configured register above the - tenth stopped being read and its value left the grid. The split out window now + address and length in the toolbar, so every configured register outside them + stopped being read and its value left the grid. The split out window now leaves the client alone. - **A DATETIME write now carries the date you typed.** The write dialog took a value field whose range was stated in seconds while the register is encoded from milliseconds, so every value it accepted fell before the year 2000 and went out as 2000/01/01 00:00:00. The field now takes milliseconds over the - range the format carries, which is 2000 through 2127. A date past 2127 used to - be written with the year wrapped, so a register set to 2200 read back as 2072; - it now stops at the end of 2127. -- **An address freed on load is offered again.** A saved setup comes back - without a register Modbux cannot serve, and the addresses that register stood - on stayed marked In use, so the Add dialog refused an address the grid showed - nothing at. + range the format carries, which is 2000 through 2127. - **A write no longer logs another request as its own.** The transaction log took the last request that went out, listed it as the write and took its entry - away, so a read still waiting for an answer timed out. That happened after a - write Modbux refuses, such as a UTF-8 register through FC16, and after any - write with a poll or a read running alongside it. A write with nothing - connected now says so rather than answering "Port Not Open", and an empty coil - list is refused for FC15 the way it already was for FC5. + away, so a read still waiting for an answer timed out. That happened to a + write with a poll or a read running alongside it. - **The transaction log now shows a unit id scan.** The scan sends a request per unit id and per register type, and the log listed none of them. -- **A write or a scan refused for a lost connection now reports it.** Both said - they could not reach the device and left the app showing it as connected. A - read already corrected the state, so which one you pressed decided whether the - app noticed. - **Two bit toggles in a row now both land.** A bit in the server's bitmap panel is set by writing the whole word back, and the panel read that word from before the toggle before it, so a second toggle in quick succession cleared @@ -170,7 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Each window kept its own copy of the server setup and both wrote it to the same place, so the window you were not looking at wrote its copy over what you had just added. One generator was enough to keep that happening, and closing - the split window left the stale copy as the only one. + the split window left the stale copy as the only one. A server made there can + be changed once the window closes. - **The RTU status light is right in a window that just opened.** It is set from a message the server sends when it starts or stops, so a window opened after that showed the server as stopped while it was running. The window now asks. @@ -210,9 +197,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 server's DATETIME row decoded the four registers itself, without the format's invalid flag and without checking the fields it read, so whatever a client had written into them came back as a plausible date. A day its month does not - have, such as the 31st of February, came out as the words `Invalid DateTime`, - in the client grid as well. Both now show nothing, and a UNIX register holding - 0 shows 1970/01/01 00:00:00 where it used to show a dash. + have, such as the 31st of February, came out in the client grid as the words + `Invalid DateTime`. It now shows nothing there and a dash in the server row, + and a UNIX register holding 0 shows 1970/01/01 00:00:00 where it used to show + a dash. - **The server no longer answers for units it does not have.** On a shared RS-485 line it replied to every address on the bus, including the ones belonging to the real devices on it, so its frame went out at the same moment @@ -224,7 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on the network could turn an unused unit ID into one the server answers for, and nothing in the view said it had happened. - **Opening the server in its own window no longer disconnects your clients.** - The second window restarted every running server, and anything connected was + The second window restarted every running TCP server, and anything connected was dropped without a word. Clearing a server's registers did the same. Both now leave the connection where it is. - **Writing one coil no longer switches off the ones beside it.** The write @@ -325,7 +313,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Starting the poll again right after stopping it no longer runs two read loops at once.** Pressing Stop and then Poll while a read was still on its way left the old loop running beside the new one, so the device was asked twice as - often and two requests could sit on one connection. Only one loop runs now, + often from then on. Only one loop runs now, however fast the buttons are pressed. - **Read configuration is only offered where there is something to read.** The button went live as soon as an address carried anything at all, a comment @@ -377,8 +365,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 window opens in the browser. - **A Linear Interpolation endpoint no longer loses its decimals.** Its four fields stopped at two, so an endpoint of 0.0625 went in as 0.06 and every row - scaled through it read 4% low. They take seven now, which is what the value, - min and max fields on the server side have always taken. + scaled through it was off by 4%. They take seven now, which is what the + value, min and max fields on the server side take. - **A read no longer fails on a device whose answer carries half a register.** The reply declares how many bytes it holds, and a device declaring an odd number leaves a byte no register is made of. Modbux read past the end of it @@ -412,22 +400,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 already like this. - **The Value column no longer shows the word "undefined".** Rows read as coils or discrete inputs carry no register value, and they stay on screen when you - switch the register type while a read loop is running. Every mapped address - then drew `undefined` in the Value column, or `undefine` on a UTF-8 mapping. + switch the register type while a read loop is running. Every address + mapped as a date or a Unix time then drew `undefined` in the Value column, and + a UTF-8 mapping drew `undefine`. Those cells are empty now. - **The address on the Add bar follows the unit you are looking at.** It was read once, from whichever unit was selected then, so after switching to a unit holding fewer coils it sat above every address that unit had. Adding an address with nothing free above it now says so as well, instead of leaving the button doing nothing. -- **A server whose port was lost comes back on one.** When the stored ports - could not be read, Modbux said so and reset them, and every server but the - first then came up with no port at all: a blank button in the row at the top, - answering nothing, with its registers still there behind it. Each one now - opens on the first free port instead. -- **Cmd+Z or Ctrl+Z in the host field now undoes what you typed.** It did - nothing there, where the unit ID field beside it undoes a typed run in one - step. ### Changed From 2a529554dab5f83c761f90d62369938f23fb568f Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 12:13:18 +0200 Subject: [PATCH 113/137] docs: the unit id scan runs to 255 in two comments Both counted a scan of 1 through 247, a range the unit id fields have not had since they were widened to 255. The widest scan is 0 through 255 over four types, 1024 events. The test comment names scanUnitIds rather than a line number. --- src/renderer/src/context/__tests__/volatileState.test.ts | 6 +++--- src/renderer/src/context/data.zustand.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/context/__tests__/volatileState.test.ts b/src/renderer/src/context/__tests__/volatileState.test.ts index df3d05ec..465f7e76 100644 --- a/src/renderer/src/context/__tests__/volatileState.test.ts +++ b/src/renderer/src/context/__tests__/volatileState.test.ts @@ -4,9 +4,9 @@ // persist middleware. In `client.zustand` each of them serialized the whole // partialized state, register mapping included, because zustand's persist // wraps `setState` and calls `setItem` on every call with no debounce. -// `modbusClient.ts:990` sets `_totalScans` to the address count times the -// register type count, so a unit id scan of 1 through 247 over four types -// sends 988 `scan_progress` events alone. +// `scanUnitIds` in `modbusClient.ts` sets `_totalScans` to the unit id count +// times the register type count, so a unit id scan of 0 through 255 over four +// types sends 1024 `scan_progress` events alone. import { beforeEach, describe, expect, it, vi } from 'vitest' import { CLIENT_ZUSTAND_STORAGE_KEY, defaultClientState } from '@shared' import type { ScanUnitIDResult, Transaction } from '@shared' diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 53d85093..bd2bcecf 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -30,7 +30,7 @@ import { ClientState, RegisterData, defaultClientState, dummyWords } from '@shar * `localStorage.setItem` alone, so the store Chromium keeps off the main thread * does not take that second part off it either. `modbusClient`'s `scanUnitIds` * sets `_totalScans` to the unit id count times the register type count, so a - * scan of 1 through 247 over four types sends 988 `scan_progress` events. + * scan of 0 through 255 over four types sends 1024 `scan_progress` events. */ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]>( mutative((set) => ({ From 4de271cebe246482026cee3a828b09d2f015138e Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 12:17:55 +0200 Subject: [PATCH 114/137] test: the serial specs leave the client on TCP and find socat on arm 27-serial-line-settings switched the server back to TCP and left the client on RTU, and only 28-connect-cancel's cleanup put it back. 25 and 28 kept their own list of socat paths without /opt/homebrew/bin, so on the Apple Silicon runner 28 skipped and 29-read-on-read-configuration found no host field. Run 35843977908 failed there; 27 followed by 29 reproduces it locally with 27's old cleanup. Both specs use e2e/fixtures/socat.ts now, and 27 switches the client back itself. On Linux 28 read "Connected over Modbus RTU" in the snackbar container before its cancel ran, next to the pty errors 27 left, so it failed on a message it did not raise. It closes every snackbar before the cancel now, which also keeps preventDuplicate from dropping a new one. --- .../01-main/25-disconnect-messages.spec.ts | 6 +----- .../01-main/27-serial-line-settings.spec.ts | 4 +++- e2e/specs/01-main/28-connect-cancel.spec.ts | 19 ++++++++++++++++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/e2e/specs/01-main/25-disconnect-messages.spec.ts b/e2e/specs/01-main/25-disconnect-messages.spec.ts index f8161d0c..3dd01bab 100644 --- a/e2e/specs/01-main/25-disconnect-messages.spec.ts +++ b/e2e/specs/01-main/25-disconnect-messages.spec.ts @@ -24,12 +24,8 @@ import { } from '../../fixtures/helpers' import { spawn, type ChildProcess } from 'child_process' import { existsSync, unlinkSync } from 'fs' +import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' -const SOCAT_PATHS = ['/usr/local/bin/socat', '/usr/bin/socat'] -// The bare name when neither path is there, so `hasSocat` is what decides -// whether the suite runs rather than a path that happens to exist. -const SOCAT_PATH = SOCAT_PATHS.find((p) => existsSync(p)) ?? 'socat' -const hasSocat = existsSync(SOCAT_PATH) const PTY_0 = '/tmp/ttyVDISC0' const PTY_1 = '/tmp/ttyVDISC1' diff --git a/e2e/specs/01-main/27-serial-line-settings.spec.ts b/e2e/specs/01-main/27-serial-line-settings.spec.ts index a11e551b..6a6e4483 100644 --- a/e2e/specs/01-main/27-serial-line-settings.spec.ts +++ b/e2e/specs/01-main/27-serial-line-settings.spec.ts @@ -217,8 +217,10 @@ test.describe.serial('Serial line settings — what reaches the port', () => { // ─── Cleanup ────────────────────────────────────────────────────────────── - test('disconnect the client', async ({ mainPage }) => { + test('disconnect the client and switch it back to TCP', async ({ mainPage }) => { await disconnectClient(mainPage) + await mainPage.getByTestId('protocol-tcp-btn').click() + await expect(mainPage.getByTestId('tcp-host-input')).toBeVisible() }) test('restore the server defaults and switch back to TCP', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/28-connect-cancel.spec.ts b/e2e/specs/01-main/28-connect-cancel.spec.ts index 288cf0e2..874e43cb 100644 --- a/e2e/specs/01-main/28-connect-cancel.spec.ts +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -20,10 +20,8 @@ import { } from '../../fixtures/helpers' import { spawn, type ChildProcess } from 'child_process' import { existsSync, unlinkSync } from 'fs' +import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' -const SOCAT_PATHS = ['/usr/local/bin/socat', '/usr/bin/socat'] -const SOCAT_PATH = SOCAT_PATHS.find((p) => existsSync(p)) ?? 'socat' -const hasSocat = existsSync(SOCAT_PATH) const PTY_0 = '/tmp/ttyVCANCEL0' const PTY_1 = '/tmp/ttyVCANCEL1' @@ -44,6 +42,20 @@ async function snackbarsOver(p: Page, milliseconds: number): Promise<string> { return [...new Set(seen)].join(' | ') } +/** + * Closes every snackbar on screen. `preventDuplicate` drops a message whose + * text is still showing, so a "Connected over" left by an earlier spec both + * reads as raised here and hides one that is. + */ +async function closeSnackbars(p: Page): Promise<void> { + const close = p.getByTestId('snackbar-close-btn') + await expect(async () => { + const count = await close.count() + if (count > 0) await close.first().click() + expect(count).toBe(0) + }).toPass({ timeout: 10_000 }) +} + test.describe.serial('Cancelling a connect', () => { test.skip(!hasSocat, 'socat not available') @@ -96,6 +108,7 @@ test.describe.serial('Cancelling a connect', () => { await navigateToClient(mainPage) await connectClientRTU(mainPage, '0', '9600', 'none', '8', '1') await mainPage.getByTestId('rtu-com-input').locator('input').fill(PTY_1) + await closeSnackbars(mainPage) await mainPage.evaluate(() => { window.api.connect() From 37351d88e400ececc2140b1ad2408e63a64b647c Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 12:59:23 +0200 Subject: [PATCH 115/137] test: 28 presses only the snackbar close buttons that are visible On the macOS runners a closed snackbar stayed in the DOM hidden, and closeSnackbars waited 30 s to click it. A probe on run 35850109803 showed the window focused and three snackbars going to one, the last one with a close button Playwright called not visible. Only visible buttons are pressed now, and afterwards no container may still hold "Connected over", which is what preventDuplicate would drop a new one against. --- e2e/specs/01-main/28-connect-cancel.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/specs/01-main/28-connect-cancel.spec.ts b/e2e/specs/01-main/28-connect-cancel.spec.ts index 874e43cb..694bed78 100644 --- a/e2e/specs/01-main/28-connect-cancel.spec.ts +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -45,15 +45,18 @@ async function snackbarsOver(p: Page, milliseconds: number): Promise<string> { /** * Closes every snackbar on screen. `preventDuplicate` drops a message whose * text is still showing, so a "Connected over" left by an earlier spec both - * reads as raised here and hides one that is. + * reads as raised here and hides one that is. On a CI runner a closed + * snackbar can stay in the DOM hidden, so only a visible close button is + * pressed, and afterwards no container may hold the text, hidden or not. */ async function closeSnackbars(p: Page): Promise<void> { - const close = p.getByTestId('snackbar-close-btn') + const close = p.getByTestId('snackbar-close-btn').filter({ visible: true }) await expect(async () => { const count = await close.count() if (count > 0) await close.first().click() expect(count).toBe(0) }).toPass({ timeout: 10_000 }) + await expect(p.locator(SNACKBARS, { hasText: 'Connected over' })).toHaveCount(0) } test.describe.serial('Cancelling a connect', () => { From 351e73a74a2ca3ae6a44c67478484a2d7705f412 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 13:26:57 +0200 Subject: [PATCH 116/137] test: 28 reads the Connect button, not the snackbars The cancel test read the snackbar container for "Connected over", and every way of clearing it first failed on some runner: a snackbar from an earlier spec, a closed one left in the DOM hidden, and on the Intel runner two visible ones that were still there after ten seconds of clicking close. The defect the spec guards left the button on Disconnect, so it samples the button for two seconds instead. With disconnect's _connectGeneration bump removed, it reads ["", "Disconnect"] and fails; restored, 25 to 29 pass. --- e2e/specs/01-main/28-connect-cancel.spec.ts | 41 +++++---------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/e2e/specs/01-main/28-connect-cancel.spec.ts b/e2e/specs/01-main/28-connect-cancel.spec.ts index 694bed78..2fb0c067 100644 --- a/e2e/specs/01-main/28-connect-cancel.spec.ts +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -4,7 +4,9 @@ * The Connect button is a Cancel for as long as the state is 'connecting', and * it sends `disconnect`. On RTU a cancelled connect that finishes its open * anyway reports "Connected over Modbus RTU" over the cancel, leaving the - * button on Disconnect and the port held. + * button on Disconnect and the port held. The button is what this reads: a + * snackbar can outlive the spec that raised it, and `preventDuplicate` drops a + * new one whose text is still showing. * * Both calls go through `window.api` in one tick, which is the window the * button opens while a port is opening. @@ -25,38 +27,15 @@ import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' const PTY_0 = '/tmp/ttyVCANCEL0' const PTY_1 = '/tmp/ttyVCANCEL1' -const SNACKBARS = '.notistack-SnackbarContainer' - -/** Every snackbar raised over the next two seconds, read as they come. */ -async function snackbarsOver(p: Page, milliseconds: number): Promise<string> { - const seen: string[] = [] +/** Every label the Connect button shows over the next two seconds. */ +async function connectLabelsOver(p: Page, milliseconds: number): Promise<string[]> { + const seen = new Set<string>() const deadline = Date.now() + milliseconds while (Date.now() < deadline) { - const text = await p - .locator(SNACKBARS) - .textContent({ timeout: 100 }) - .catch(() => null) - if (text) seen.push(text) + seen.add((await p.getByTestId('connect-btn').textContent()) ?? '') await p.waitForTimeout(50) } - return [...new Set(seen)].join(' | ') -} - -/** - * Closes every snackbar on screen. `preventDuplicate` drops a message whose - * text is still showing, so a "Connected over" left by an earlier spec both - * reads as raised here and hides one that is. On a CI runner a closed - * snackbar can stay in the DOM hidden, so only a visible close button is - * pressed, and afterwards no container may hold the text, hidden or not. - */ -async function closeSnackbars(p: Page): Promise<void> { - const close = p.getByTestId('snackbar-close-btn').filter({ visible: true }) - await expect(async () => { - const count = await close.count() - if (count > 0) await close.first().click() - expect(count).toBe(0) - }).toPass({ timeout: 10_000 }) - await expect(p.locator(SNACKBARS, { hasText: 'Connected over' })).toHaveCount(0) + return [...seen] } test.describe.serial('Cancelling a connect', () => { @@ -111,15 +90,13 @@ test.describe.serial('Cancelling a connect', () => { await navigateToClient(mainPage) await connectClientRTU(mainPage, '0', '9600', 'none', '8', '1') await mainPage.getByTestId('rtu-com-input').locator('input').fill(PTY_1) - await closeSnackbars(mainPage) await mainPage.evaluate(() => { window.api.connect() window.api.disconnect() }) - const raised = await snackbarsOver(mainPage, 2000) - expect(raised).not.toContain('Connected over') + expect(await connectLabelsOver(mainPage, 2000)).not.toContain('Disconnect') await expect(mainPage.getByTestId('connect-btn')).toContainText('Connect') }) From 817fe96b72200b34c48af7f174e35f973583aa69 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 13:55:40 +0200 Subject: [PATCH 117/137] chore: upload the out/ run's test results before dist/ empties them The only upload ran at the end of the job, after the dist/ run had emptied test-results/, so a failure in the out/ run reached the artifact as the passing dist/ run's logs. Run 35854594226 lost the Windows main-process log that way. The out/ run now uploads its own on failure, as playwright-out-<runner>. --- .github/workflows/e2e.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f92095d6..ec87f9fa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -154,6 +154,17 @@ jobs: shell: bash run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --trace retain-on-failure + # The dist/ run empties test-results/ before it starts, so a failure here + # is uploaded now or not at all. + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-out-${{ matrix.runner }} + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + # not cancelled(), rather than success(): a failure in the dev run says # nothing about the packaged app, and the packaged app is what ships. The # job still ends red, and now it ends red with both answers in it. From 3d4ac27dc7df14ca8e33bc74e96e614d84edbb92 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 14:45:23 +0200 Subject: [PATCH 118/137] test: every launch keeps the app's output, and a failed test keeps a trace keepOutput moves into launch.ts and runs inside launchElectron, so the privileged port and hardware specs log main as well; the presentation fixture calls it itself. Playwright's trace option records only the contexts it creates, so a failed test's trace held its assertions and no DOM. The fixture traces the app's context with DOM snapshots, one chunk per test, and writes the chunk, with a screenshot of every open window, only for a test that failed. A probe that failed on purpose left electron-trace.zip, window-0.png and the main log. 01-main 01 to 28 took 6.6 min without it and 7.7 min with it, one run each; with the screencast as well it took 8.5 min, so that stays off. --- e2e/fixtures/electron-app.ts | 69 +++++++++---------- e2e/fixtures/launch.ts | 34 ++++++++- e2e/fixtures/presentation-app.ts | 3 +- .../02-standalone/01-persistence.spec.ts | 5 -- e2e/specs/02-standalone/02-macos-dock.spec.ts | 2 - .../03-split-view-persistence.spec.ts | 2 - 6 files changed, 69 insertions(+), 46 deletions(-) diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index e498a15a..3fe0354b 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,50 +1,49 @@ import { test as base, type ElectronApplication, type Page } from '@playwright/test' import { launchElectron, evaluateMain } from './launch' -import { createWriteStream, mkdirSync } from 'fs' -import { join } from 'path' - -/** - * Everything the app writes, kept next to the traces. - * - * Playwright owns the child process and nothing reads its stdio, so a run that - * ends with "Target page, context or browser has been closed" says only that - * the app is gone. The exit line below is the point of this: a code means the - * app came down on its own, a signal means something else took it. - * - * The workflow uploads test-results/ when a run fails, so the file travels with - * the trace it belongs to. - */ -export function keepOutput(app: ElectronApplication): void { - const dir = join(process.cwd(), 'test-results') - mkdirSync(dir, { recursive: true }) - - const worker = process.env.TEST_WORKER_INDEX ?? '0' - const log = createWriteStream(join(dir, `electron-main-${worker}.log`), { flags: 'a' }) - const stamp = (): string => new Date().toISOString() - - // The persistence spec launches its own app beside the worker's, and both - // write here, so every line says which process it came from. - const proc = app.process() - const pid = proc.pid - proc.stdout?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} out ${c.toString()}`)) - proc.stderr?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} err ${c.toString()}`)) - proc.on('exit', (code, signal) => { - log.write(`[${stamp()}] ${pid} exit code=${code} signal=${signal}\n`) - }) -} export type ElectronFixtures = { electronApp: ElectronApplication mainPage: Page } -// eslint-disable-next-line @typescript-eslint/ban-types -export const test = base.extend<{}, ElectronFixtures>({ +/** + * A trace of the app for every test that fails. + * + * Playwright's own `trace` option records the contexts it creates itself, and + * the app's is not one of them, so a failed test's trace held its assertions + * and no DOM. Tracing runs on the app's context for the whole worker, one + * chunk per test, and a chunk is written only when its test failed, beside a + * screenshot of every window still open. An app that is already gone has no + * chunk to stop, and its log says why. + */ +type ElectronTestFixtures = { electronTrace: void } + +export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ + electronTrace: [ + async ({ electronApp }, use, testInfo): Promise<void> => { + const tracing = electronApp.context().tracing + await tracing.startChunk({ title: testInfo.title }) + await use() + if (testInfo.status === testInfo.expectedStatus) { + await tracing.stopChunk().catch(() => undefined) + return + } + for (const [i, window] of electronApp.windows().entries()) { + await window + .screenshot({ path: testInfo.outputPath(`window-${i}.png`) }) + .catch(() => undefined) + } + await tracing + .stopChunk({ path: testInfo.outputPath('electron-trace.zip') }) + .catch(() => undefined) + }, + { auto: true } + ], electronApp: [ // eslint-disable-next-line no-empty-pattern async ({}, use): Promise<void> => { const app = await launchElectron() - keepOutput(app) + await app.context().tracing.start({ snapshots: true }) await evaluateMain(() => app.evaluate((ctx) => diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index 1919ed23..fc1c00ae 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -1,5 +1,5 @@ import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test' -import { existsSync, mkdtempSync } from 'fs' +import { createWriteStream, existsSync, mkdirSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' @@ -126,6 +126,37 @@ export function launchOptions(userDataDir?: string): LaunchOptions { return { executablePath: packagedBinary(), args, env } } +/** + * Everything the app writes, kept next to the traces. + * + * Playwright owns the child process and nothing reads its stdio, so a run that + * ends with "Target page, context or browser has been closed" says only that + * the app is gone. The exit line below is the point of this: a code means the + * app came down on its own, a signal means something else took it. + * + * `launchElectron` calls it for every app it starts, and the workflow uploads + * test-results/ when a run fails, so the file travels with the trace it + * belongs to. + */ +export function keepOutput(app: ElectronApplication): void { + const dir = join(process.cwd(), 'test-results') + mkdirSync(dir, { recursive: true }) + + const worker = process.env.TEST_WORKER_INDEX ?? '0' + const log = createWriteStream(join(dir, `electron-main-${worker}.log`), { flags: 'a' }) + const stamp = (): string => new Date().toISOString() + + // The persistence spec launches its own app beside the worker's, and both + // write here, so every line says which process it came from. + const proc = app.process() + const pid = proc.pid + proc.stdout?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} out ${c.toString()}`)) + proc.stderr?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} err ${c.toString()}`)) + proc.on('exit', (code, signal) => { + log.write(`[${stamp()}] ${pid} exit code=${code} signal=${signal}\n`) + }) +} + /** * Launches the app with reduced motion emulated in every window it has and * every window it opens. The theme follows that setting, so MUI's transitions @@ -134,6 +165,7 @@ export function launchOptions(userDataDir?: string): LaunchOptions { */ export async function launchElectron(userDataDir?: string): Promise<ElectronApplication> { const app = await electron.launch(launchOptions(userDataDir)) + keepOutput(app) const reduceMotion = (page: Page): Promise<void> => page.emulateMedia({ reducedMotion: 'reduce' }) app.on('window', (page) => { // A window closed before the emulation reached it has no motion left. diff --git a/e2e/fixtures/presentation-app.ts b/e2e/fixtures/presentation-app.ts index 63aaee7a..7364cc95 100644 --- a/e2e/fixtures/presentation-app.ts +++ b/e2e/fixtures/presentation-app.ts @@ -4,7 +4,7 @@ import { type ElectronApplication, type Page } from '@playwright/test' -import { launchOptions, evaluateMain } from './launch' +import { launchOptions, evaluateMain, keepOutput } from './launch' export type PresentationFixtures = { electronApp: ElectronApplication @@ -17,6 +17,7 @@ export const test = base.extend<{}, PresentationFixtures>({ // eslint-disable-next-line no-empty-pattern async ({}, use): Promise<void> => { const app = await electron.launch(launchOptions()) + keepOutput(app) await evaluateMain(() => app.evaluate((ctx) => diff --git a/e2e/specs/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index 5ab8a000..ee60ae25 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -2,7 +2,6 @@ import { test, expect, type ElectronApplication, type Page } from '@playwright/t import { resolve } from 'path' import { loadServerConfig, selectUnitId } from '../../fixtures/helpers' import { launchElectron, evaluateMain } from '../../fixtures/launch' -import { keepOutput } from '../../fixtures/electron-app' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') @@ -13,10 +12,6 @@ let page: Page async function launchApp(clearStorage = true): Promise<void> { app = await launchElectron() - // This spec launches its own app, so it needs the same output kept as the - // shared fixture keeps. Its launches race the worker's app for the single - // instance lock, which is where the log has to be able to speak. - keepOutput(app) if (clearStorage) { await evaluateMain(() => app.evaluate((ctx) => diff --git a/e2e/specs/02-standalone/02-macos-dock.spec.ts b/e2e/specs/02-standalone/02-macos-dock.spec.ts index e727f53d..1755bf87 100644 --- a/e2e/specs/02-standalone/02-macos-dock.spec.ts +++ b/e2e/specs/02-standalone/02-macos-dock.spec.ts @@ -6,7 +6,6 @@ import { type Page } from '@playwright/test' import net from 'net' -import { keepOutput } from '../../fixtures/electron-app' import { launchElectron, launchOptions, ownProfileDir, evaluateMain } from '../../fixtures/launch' import { connectClient, navigateToClient, navigateToServer } from '../../fixtures/helpers' @@ -101,7 +100,6 @@ test.describe.serial('macOS dock — the app outlives its windows', () => { await startDevice() profile = ownProfileDir() app = await launchElectron(profile) - keepOutput(app) page = await waitForWindow() await page.waitForTimeout(500) }) diff --git a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts index 902dead6..335fbbcd 100644 --- a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -6,7 +6,6 @@ import { splitOutServerWindow } from '../../fixtures/helpers' import { launchElectron, evaluateMain } from '../../fixtures/launch' -import { keepOutput } from '../../fixtures/electron-app' let app: ElectronApplication let page: Page @@ -16,7 +15,6 @@ async function launchApp(clearStorage: boolean): Promise<void> { // rather than passing on the one the previous launch left here. page = undefined as unknown as Page app = await launchElectron() - keepOutput(app) if (clearStorage) { await evaluateMain(() => app.evaluate((ctx) => From 38e58cc1f57790cdfdcfc6d81bdce023573f0c40 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 16:19:30 +0200 Subject: [PATCH 119/137] test: every spec file runs against an app launched for it One app per worker carried each spec's leftovers into the next. 27 left the client on RTU and only 28's cleanup put it back, and run alone, 12, 15 and 18 failed while passing in the suite. The fixture now launches the app when a file's first test asks for it and closes it when the next file's does. The three that leaned on an earlier spec set up for themselves: 12 loads server-integration.json as 11 does, 15 loads client-basic.json as 14 left it, and 18 no longer clicks Clear on a grid that connecting a fresh app has already emptied. The afterAll hooks in 10 and 31 closed a server window for the next file and are gone; a beforeAll or afterAll cannot take the app now. yarn test:e2e: 647 passed in 11.3 min, against 646 in 7.5 min on 574e4b6 before this and the trace commit. --- e2e/fixtures/electron-app.ts | 179 +++++++++++------- e2e/specs/01-main/10-split-view.spec.ts | 9 +- e2e/specs/01-main/12-scan-unitids.spec.ts | 14 +- e2e/specs/01-main/15-client-toolbar.spec.ts | 15 +- .../01-main/18-huawei-manual-client.spec.ts | 5 +- .../31-split-view-client-state.spec.ts | 11 -- 6 files changed, 136 insertions(+), 97 deletions(-) diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 3fe0354b..2c3dd914 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,24 +1,123 @@ import { test as base, type ElectronApplication, type Page } from '@playwright/test' import { launchElectron, evaluateMain } from './launch' +type RunningApp = { file: string; app: ElectronApplication; page: Page } + +/** + * The app a spec file runs against, launched for that file alone. + * + * One app per worker carried every spec's leftovers into the next, such as + * the client 27 left on RTU, and 12, 15 and 18 passed in the suite and failed + * on their own. So the app is launched when a file's first test asks for it + * and closed when the next file's does. + * + * The worker holds the running app so the last one is closed at teardown. + * `electronApp` and `mainPage` are test fixtures on top of it, which is why a + * `beforeAll` or `afterAll` cannot take them. + */ +type ElectronWorkerFixtures = { runningApp: { current: RunningApp | undefined } } + export type ElectronFixtures = { + appForFile: RunningApp electronApp: ElectronApplication mainPage: Page + electronTrace: void } -/** - * A trace of the app for every test that fails. - * - * Playwright's own `trace` option records the contexts it creates itself, and - * the app's is not one of them, so a failed test's trace held its assertions - * and no DOM. Tracing runs on the app's context for the whole worker, one - * chunk per test, and a chunk is written only when its test failed, beside a - * screenshot of every window still open. An app that is already gone has no - * chunk to stop, and its log says why. - */ -type ElectronTestFixtures = { electronTrace: void } +async function launchForFile(file: string): Promise<RunningApp> { + const app = await launchElectron() + await app.context().tracing.start({ snapshots: true }) + + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) + ) + + let page: Page | undefined + let searchCount = 0 + + while (searchCount < 10) { + searchCount++ + + // Check BrowserWindow title (HTML <title> was removed to not override it) + const found = await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) + ) + + if (found && app.windows().length === 1) { + page = app.windows()[0] + break + } + + await new Promise((r) => setTimeout(r, 1000)) + } + + if (!page) throw new Error('Modbux main window not found!') + + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(500) + + // Disable CSS animations/transitions globally for faster test execution + await page.addStyleTag({ + content: + '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' + }) + + return { file, app, page } +} + +export const test = base.extend<ElectronFixtures, ElectronWorkerFixtures>({ + runningApp: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use): Promise<void> => { + const holder: { current: RunningApp | undefined } = { current: undefined } + await use(holder) + await holder.current?.app.close() + }, + { scope: 'worker' } + ], + + appForFile: [ + async ({ runningApp }, use, testInfo): Promise<void> => { + let running = runningApp.current + if (running?.file !== testInfo.file) { + await running?.app.close() + runningApp.current = undefined + running = await launchForFile(testInfo.file) + runningApp.current = running + } + await use(running) + }, + { scope: 'test' } + ], + + electronApp: [ + async ({ appForFile }, use): Promise<void> => { + await use(appForFile.app) + }, + { scope: 'test' } + ], -export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ + mainPage: [ + async ({ appForFile }, use): Promise<void> => { + await use(appForFile.page) + }, + { scope: 'test' } + ], + + /** + * A trace of the app for every test that fails. + * + * Playwright's own `trace` option records the contexts it creates itself, + * and the app's is not one of them, so a failed test's trace held its + * assertions and no DOM. Tracing runs on the app's context, one chunk per + * test, and a chunk is written only when its test failed, beside a + * screenshot of every window still open. An app that is already gone has no + * chunk to stop, and its log says why. + */ electronTrace: [ async ({ electronApp }, use, testInfo): Promise<void> => { const tracing = electronApp.context().tracing @@ -38,62 +137,6 @@ export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ .catch(() => undefined) }, { auto: true } - ], - electronApp: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use): Promise<void> => { - const app = await launchElectron() - await app.context().tracing.start({ snapshots: true }) - - await evaluateMain(() => - app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) - ) - ) - - await use(app) - await app.close() - }, - { scope: 'worker' } - ], - - mainPage: [ - async ({ electronApp }, use): Promise<void> => { - let page: Page | undefined - let searchCount = 0 - - while (searchCount < 10) { - searchCount++ - - // Check BrowserWindow title (HTML <title> was removed to not override it) - const found = await evaluateMain(() => - electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') - ) - ) - - if (found && electronApp.windows().length === 1) { - page = electronApp.windows()[0] - break - } - - await new Promise((r) => setTimeout(r, 1000)) - } - - if (!page) throw new Error('Modbux main window not found!') - - await page.waitForLoadState('domcontentloaded') - await page.waitForTimeout(500) - - // Disable CSS animations/transitions globally for faster test execution - await page.addStyleTag({ - content: - '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' - }) - - await use(page) - }, - { scope: 'worker' } ] }) diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index dfd8a73b..0a61476e 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -10,15 +10,8 @@ let master: net.Socket const masterEvents: string[] = [] test.describe.serial('Split View — Server in separate window', () => { - test.afterAll(async ({ electronApp }) => { + test.afterAll(() => { master?.destroy() - await evaluateMain(() => - electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) - ) }) test('read the server port, then navigate to home', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index c0923d97..c7028425 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -4,11 +4,21 @@ import { connectClient, disconnectClient, enableAdvancedMode, - selectRegisterType + selectRegisterType, + cleanServerState, + loadServerConfig } from '../../fixtures/helpers' +import { resolve } from 'path' + +const SERVER_CONFIG = resolve(__dirname, '../../fixtures/config-files/server-integration.json') test.describe.serial('Scan Unit IDs', () => { - // ─── Setup (server already configured from spec 11) ───────────────── + // ─── Setup ────────────────────────────────────────────────────────── + + test('clean server state and load server config', async ({ mainPage }) => { + await cleanServerState(mainPage) + await loadServerConfig(mainPage, SERVER_CONFIG) + }) test('navigate to client and connect', async ({ mainPage }) => { await navigateToClient(mainPage) diff --git a/e2e/specs/01-main/15-client-toolbar.spec.ts b/e2e/specs/01-main/15-client-toolbar.spec.ts index 3d7f079e..8bac8e02 100644 --- a/e2e/specs/01-main/15-client-toolbar.spec.ts +++ b/e2e/specs/01-main/15-client-toolbar.spec.ts @@ -14,12 +14,14 @@ import { expectCell, expectColumn, openColumnMenu, - clearClientConfig + clearClientConfig, + loadClientConfig } from '../../fixtures/helpers' import { resolve } from 'path' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') +const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-basic.json') test.describe.serial('Client toolbar — display options and utilities', () => { // ─── Setup ────────────────────────────────────────────────────────── @@ -29,8 +31,9 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await loadServerConfig(mainPage, SERVER_CONFIG) }) - test('navigate to client and connect', async ({ mainPage }) => { + test('navigate to client, load a mapping and connect', async ({ mainPage }) => { await navigateToClient(mainPage) + await loadClientConfig(mainPage, CLIENT_CONFIG) await connectClient(mainPage, '127.0.0.1', '502', '0') }) @@ -42,7 +45,7 @@ test.describe.serial('Client toolbar — display options and utilities', () => { // ─── Advanced mode & 64-bit toggle ──────────────────────────────── test('disable advanced mode — no value columns visible', async ({ mainPage }) => { - // Ensure advanced mode is off (earlier specs may have enabled it) + // Ensure advanced mode is off await mainPage.getByTestId('menu-btn').click() await mainPage .getByTestId('advanced-mode-checkbox') @@ -74,7 +77,7 @@ test.describe.serial('Client toolbar — display options and utilities', () => { await mainPage.getByTestId('advanced-mode-checkbox').click() await mainPage.waitForTimeout(200) - // Ensure 64-bit is off (earlier specs may have left it on) + // Ensure 64-bit is off const show64Input = mainPage .getByTestId('show-64bit-checkbox') .locator('input[type="checkbox"]') @@ -285,8 +288,8 @@ test.describe.serial('Client toolbar — display options and utilities', () => { test(`[${regType}] read config: clear config → button disabled`, async ({ mainPage }) => { await selectRegisterType(mainPage, regType) - // The first pass of this loop runs against what `14-client-config-io` - // left mapped, and the second against what the first cleared. Keep it + // The first pass of this loop runs against the mapping the setup + // loaded, and the second against what the first cleared. Keep it // leaves that mapping where it was, which the dialog coming up a second // time is what says. const asks = regType === 'Holding Registers' diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index 0e1d81d3..7eb4ceaa 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -14,7 +14,6 @@ import { disableReadConfiguration, cleanServerState, loadServerConfig, - clearData, loadDummyData, expectCell } from '../../fixtures/helpers' @@ -231,7 +230,9 @@ test.describe.serial('Huawei Smart Logger — JSON server + manual client config }) test('readConfiguration reads all manually configured registers', async ({ mainPage }) => { - await clearData(mainPage) + // Connecting a fresh app empties the dummy rows, so the rows below come + // from readConfiguration. + await expect(mainPage.getByTestId('clear-data-btn')).toBeDisabled() await enableReadConfiguration(mainPage) diff --git a/e2e/specs/01-main/31-split-view-client-state.spec.ts b/e2e/specs/01-main/31-split-view-client-state.spec.ts index fd6282cd..499ede42 100644 --- a/e2e/specs/01-main/31-split-view-client-state.spec.ts +++ b/e2e/specs/01-main/31-split-view-client-state.spec.ts @@ -17,7 +17,6 @@ import { } from '../../fixtures/helpers' import { resolve } from 'path' import { type Page } from '@playwright/test' -import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') @@ -33,16 +32,6 @@ let serverPage: Page // configured register above address 9 stopped being read. Only two windows can // see it. test.describe.serial('Read configuration survives the split out server window', () => { - test.afterAll(async ({ electronApp }) => { - await evaluateMain(() => - electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) - ) - }) - test('clean server state', async ({ mainPage }) => { await cleanServerState(mainPage) }) From b3bbdef3a2161d91d63fc948e55588c10e37a884 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 17:00:01 +0200 Subject: [PATCH 120/137] test: 18 counts the grid's rows, not the ones the window draws The read configuration test counted the rows in the DOM and asked for 20. The grid virtualises, so that count is the window's height: 29 locally and 19 on the macOS arm and Windows runners of run 35873899922, where a fresh app per file no longer inherits a window an earlier spec had drawn. It checks aria-rowcount instead, which is every row and the header, 81 for the 80 registers configured. --- e2e/specs/01-main/18-huawei-manual-client.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index 7eb4ceaa..ae906232 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -239,9 +239,13 @@ test.describe.serial('Huawei Smart Logger — JSON server + manual client config await mainPage.getByTestId('read-btn').click() await mainPage.waitForTimeout(5000) - // DataGrid virtualizes rows — only ~28-35 are in the DOM at once - const rowCount = await mainPage.locator('.MuiDataGrid-row').count() - expect(rowCount).toBeGreaterThanOrEqual(20) + // The grid virtualises, so the rows in the DOM are what the window is tall + // enough for: 29 here, 19 on the macOS and Windows runners. aria-rowcount + // counts every row, and the header. + await expect(mainPage.locator('.MuiDataGrid-root [role="grid"]').first()).toHaveAttribute( + 'aria-rowcount', + String(TOTAL_REGISTERS + 1) + ) }) test('readConfig validates comments survived', async ({ mainPage }) => { From ec641d513f976dcec2e744bf01179f259cdaa88f Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 17:23:29 +0200 Subject: [PATCH 121/137] test: one app per worker again, reset in every spec file's beforeAll A fresh app per file made the suite 20 to 35 percent slower on the CI runners, with the trace commit counted in, so the worker keeps one app and resetApp brings it back to a fresh launch instead: it closes every window but the main one, disconnects the client, deletes every server but 502 and clears it, puts it back on TCP, then clears the storage and reloads, which hands main the default client configuration through the store's init. Every spec file under the fixture calls it in a beforeAll, and a test in a file that did not is refused, so a new spec cannot leave it out. The afterAll hooks in 10 and 31 stay gone; the next file's reset closes the server window. yarn test:e2e: 647 passed. --- e2e/fixtures/electron-app.ts | 208 +++++++++--------- e2e/specs/01-main/01-home-navigation.spec.ts | 6 +- e2e/specs/01-main/02-input-validation.spec.ts | 6 +- e2e/specs/01-main/03-server-config.spec.ts | 6 +- .../01-main/04-add-register-modal.spec.ts | 6 +- e2e/specs/01-main/05-file-io.spec.ts | 6 +- e2e/specs/01-main/06-client-server.spec.ts | 6 +- e2e/specs/01-main/07-endianness.spec.ts | 6 +- .../01-main/08-polling-generators.spec.ts | 6 +- e2e/specs/01-main/09-write-operations.spec.ts | 6 +- e2e/specs/01-main/10-split-view.spec.ts | 6 +- e2e/specs/01-main/11-scan-registers.spec.ts | 6 +- e2e/specs/01-main/12-scan-unitids.spec.ts | 6 +- e2e/specs/01-main/13-cleanup.spec.ts | 6 +- e2e/specs/01-main/14-client-config-io.spec.ts | 6 +- e2e/specs/01-main/15-client-toolbar.spec.ts | 6 +- e2e/specs/01-main/16-client-rtu.spec.ts | 6 +- .../01-main/17-huawei-smartlogger.spec.ts | 6 +- .../01-main/18-huawei-manual-client.spec.ts | 6 +- e2e/specs/01-main/19-large-config.spec.ts | 6 +- .../01-main/20-read-after-config-load.spec.ts | 6 +- e2e/specs/01-main/21-bitmap-settings.spec.ts | 6 +- .../01-main/22-large-config-perf.spec.ts | 6 +- e2e/specs/01-main/23-server-rtu.spec.ts | 6 +- .../01-main/24-client-rtu-over-tcp.spec.ts | 6 +- .../01-main/25-disconnect-messages.spec.ts | 6 +- e2e/specs/01-main/26-server-layout.spec.ts | 6 +- .../01-main/27-serial-line-settings.spec.ts | 6 +- e2e/specs/01-main/28-connect-cancel.spec.ts | 6 +- .../29-read-on-read-configuration.spec.ts | 6 +- .../01-main/30-server-bit-toggle.spec.ts | 6 +- .../31-split-view-client-state.spec.ts | 6 +- e2e/specs/01-main/32-undo-redo.spec.ts | 6 +- .../97-scan-perf/01-grid-during-scan.spec.ts | 6 +- e2e/specs/99-hardware/01-iem3000-rtu.spec.ts | 6 +- 35 files changed, 275 insertions(+), 137 deletions(-) diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 2c3dd914..8bf562c8 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,125 +1,74 @@ -import { test as base, type ElectronApplication, type Page } from '@playwright/test' +import { test as base, expect, type ElectronApplication, type Page } from '@playwright/test' import { launchElectron, evaluateMain } from './launch' - -type RunningApp = { file: string; app: ElectronApplication; page: Page } - -/** - * The app a spec file runs against, launched for that file alone. - * - * One app per worker carried every spec's leftovers into the next, such as - * the client 27 left on RTU, and 12, 15 and 18 passed in the suite and failed - * on their own. So the app is launched when a file's first test asks for it - * and closed when the next file's does. - * - * The worker holds the running app so the last one is closed at teardown. - * `electronApp` and `mainPage` are test fixtures on top of it, which is why a - * `beforeAll` or `afterAll` cannot take them. - */ -type ElectronWorkerFixtures = { runningApp: { current: RunningApp | undefined } } +import { cleanServerState, navigateToHome } from './helpers' export type ElectronFixtures = { - appForFile: RunningApp electronApp: ElectronApplication mainPage: Page - electronTrace: void } -async function launchForFile(file: string): Promise<RunningApp> { - const app = await launchElectron() - await app.context().tracing.start({ snapshots: true }) +/** The spec files whose `beforeAll` ran `resetApp` in this worker. */ +const resetFiles = new Set<string>() +const NO_MOTION = + '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' + +/** + * Brings the worker's app back to what a fresh launch shows, for the spec + * file whose `beforeAll` calls it. + * + * The app is shared by every file a worker runs, so each file inherited what + * the one before it left: 27 left the client on RTU, and 12, 15 and 18 passed + * in the suite and failed on their own. Every file under this fixture calls it + * first, and a test in a file that did not is refused. + * + * Main holds what the renderer's storage does not: the split out server + * window, the client's connection, and every server with its listener. Those + * go first, through the app, and then the storage is cleared and the window + * reloaded, which hands main the default client configuration through the + * store's `init`. + */ +export async function resetApp(app: ElectronApplication, page: Page): Promise<void> { await evaluateMain(() => - app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) - ) + app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() !== 'Modbux') + .forEach((w) => w.close()) + }) ) + await page.evaluate(() => window.api.disconnect()) - let page: Page | undefined - let searchCount = 0 - - while (searchCount < 10) { - searchCount++ - - // Check BrowserWindow title (HTML <title> was removed to not override it) - const found = await evaluateMain(() => - app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') - ) - ) - - if (found && app.windows().length === 1) { - page = app.windows()[0] - break - } - - await new Promise((r) => setTimeout(r, 1000)) - } - - if (!page) throw new Error('Modbux main window not found!') + await cleanServerState(page) + await page.getByTestId('server-mode-tcp-btn').click() + await page.evaluate(() => localStorage.clear()) + await page.reload() await page.waitForLoadState('domcontentloaded') - await page.waitForTimeout(500) + await page.addStyleTag({ content: NO_MOTION }) + await navigateToHome(page) + await expect(page.getByTestId('home-client-btn')).toBeVisible() - // Disable CSS animations/transitions globally for faster test execution - await page.addStyleTag({ - content: - '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' - }) - - return { file, app, page } + resetFiles.add(base.info().file) } -export const test = base.extend<ElectronFixtures, ElectronWorkerFixtures>({ - runningApp: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use): Promise<void> => { - const holder: { current: RunningApp | undefined } = { current: undefined } - await use(holder) - await holder.current?.app.close() - }, - { scope: 'worker' } - ], - - appForFile: [ - async ({ runningApp }, use, testInfo): Promise<void> => { - let running = runningApp.current - if (running?.file !== testInfo.file) { - await running?.app.close() - runningApp.current = undefined - running = await launchForFile(testInfo.file) - runningApp.current = running - } - await use(running) - }, - { scope: 'test' } - ], - - electronApp: [ - async ({ appForFile }, use): Promise<void> => { - await use(appForFile.app) - }, - { scope: 'test' } - ], - - mainPage: [ - async ({ appForFile }, use): Promise<void> => { - await use(appForFile.page) - }, - { scope: 'test' } - ], +/** + * A trace of the app for every test that fails. + * + * Playwright's own `trace` option records the contexts it creates itself, and + * the app's is not one of them, so a failed test's trace held its assertions + * and no DOM. Tracing runs on the app's context for the whole worker, one + * chunk per test, and a chunk is written only when its test failed, beside a + * screenshot of every window still open. An app that is already gone has no + * chunk to stop, and its log says why. + */ +type ElectronTestFixtures = { electronTrace: void } - /** - * A trace of the app for every test that fails. - * - * Playwright's own `trace` option records the contexts it creates itself, - * and the app's is not one of them, so a failed test's trace held its - * assertions and no DOM. Tracing runs on the app's context, one chunk per - * test, and a chunk is written only when its test failed, beside a - * screenshot of every window still open. An app that is already gone has no - * chunk to stop, and its log says why. - */ +export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ electronTrace: [ async ({ electronApp }, use, testInfo): Promise<void> => { + if (!resetFiles.has(testInfo.file)) { + throw new Error(`${testInfo.file} has no beforeAll that calls resetApp`) + } const tracing = electronApp.context().tracing await tracing.startChunk({ title: testInfo.title }) await use() @@ -137,6 +86,59 @@ export const test = base.extend<ElectronFixtures, ElectronWorkerFixtures>({ .catch(() => undefined) }, { auto: true } + ], + electronApp: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use): Promise<void> => { + const app = await launchElectron() + await app.context().tracing.start({ snapshots: true }) + + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) + ) + + await use(app) + await app.close() + }, + { scope: 'worker' } + ], + + mainPage: [ + async ({ electronApp }, use): Promise<void> => { + let page: Page | undefined + let searchCount = 0 + + while (searchCount < 10) { + searchCount++ + + // Check BrowserWindow title (HTML <title> was removed to not override it) + const found = await evaluateMain(() => + electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') + ) + ) + + if (found && electronApp.windows().length === 1) { + page = electronApp.windows()[0] + break + } + + await new Promise((r) => setTimeout(r, 1000)) + } + + if (!page) throw new Error('Modbux main window not found!') + + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(500) + + // Disable CSS animations/transitions globally for faster test execution + await page.addStyleTag({ content: NO_MOTION }) + + await use(page) + }, + { scope: 'worker' } ] }) diff --git a/e2e/specs/01-main/01-home-navigation.spec.ts b/e2e/specs/01-main/01-home-navigation.spec.ts index ea349531..23b4c4c7 100644 --- a/e2e/specs/01-main/01-home-navigation.spec.ts +++ b/e2e/specs/01-main/01-home-navigation.spec.ts @@ -1,6 +1,10 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { evaluateMain } from '../../fixtures/launch' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Home screen and navigation', () => { test('app launches with correct title', async ({ electronApp }) => { const title = await evaluateMain(() => diff --git a/e2e/specs/01-main/02-input-validation.spec.ts b/e2e/specs/01-main/02-input-validation.spec.ts index 4aeb2069..be2556d5 100644 --- a/e2e/specs/01-main/02-input-validation.spec.ts +++ b/e2e/specs/01-main/02-input-validation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, navigateToClient, @@ -7,6 +7,10 @@ import { } from '../../fixtures/helpers' import type { RegisterDef } from '../../fixtures/types' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Input validation — AddRegister modal and client inputs', () => { test('navigate to server view', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/03-server-config.spec.ts b/e2e/specs/01-main/03-server-config.spec.ts index 2b1a11de..b7bea25f 100644 --- a/e2e/specs/01-main/03-server-config.spec.ts +++ b/e2e/specs/01-main/03-server-config.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, navigateToClient, @@ -10,6 +10,10 @@ import { } from '../../fixtures/helpers' import { SERVER_1_UNIT_0, SERVER_1_UNIT_1, SERVER_2_UNIT_0 } from '../../fixtures/test-data' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Server configuration', () => { /** What `add-server-btn` picked, read once the second server is selected. */ let secondServerPort = '' diff --git a/e2e/specs/01-main/04-add-register-modal.spec.ts b/e2e/specs/01-main/04-add-register-modal.spec.ts index 277e3489..364e8882 100644 --- a/e2e/specs/01-main/04-add-register-modal.spec.ts +++ b/e2e/specs/01-main/04-add-register-modal.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, selectDataType, @@ -8,6 +8,10 @@ import { } from '../../fixtures/helpers' import type { RegisterDef } from '../../fixtures/types' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('AddRegister modal — state management and validation', () => { test('navigate to server, select server 502, unit 0', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/05-file-io.spec.ts b/e2e/specs/01-main/05-file-io.spec.ts index 5a177a23..9d953852 100644 --- a/e2e/specs/01-main/05-file-io.spec.ts +++ b/e2e/specs/01-main/05-file-io.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { addBool, navigateToServer, navigateToClient, selectUnitId } from '../../fixtures/helpers' import { resolve } from 'path' import { tmpdir } from 'os' @@ -14,6 +14,10 @@ const CONFIG_FILES = { clientBasic: resolve(CONFIG_DIR, 'client-basic.json') } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('File I/O — open, save, clear server and client configs', () => { test('navigate to server view', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/06-client-server.spec.ts b/e2e/specs/01-main/06-client-server.spec.ts index 21f9a3de..a6a96aef 100644 --- a/e2e/specs/01-main/06-client-server.spec.ts +++ b/e2e/specs/01-main/06-client-server.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { selectRegisterType, cell, @@ -22,6 +22,10 @@ const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') const SERVER_2_CONFIG = resolve(CONFIG_DIR, 'server-2.json') const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-server1-unit0.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Client-Server Integration', () => { let server2Port: string diff --git a/e2e/specs/01-main/07-endianness.spec.ts b/e2e/specs/01-main/07-endianness.spec.ts index 02bafc0e..753e6d10 100644 --- a/e2e/specs/01-main/07-endianness.spec.ts +++ b/e2e/specs/01-main/07-endianness.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { selectUnitId, cell, @@ -22,6 +22,10 @@ import { resolve } from 'path' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Endianness — Little Endian round-trip', () => { // ─── Setup: clean state and configure server 1 ───────────────────── diff --git a/e2e/specs/01-main/08-polling-generators.spec.ts b/e2e/specs/01-main/08-polling-generators.spec.ts index 0b75ae34..32df4629 100644 --- a/e2e/specs/01-main/08-polling-generators.spec.ts +++ b/e2e/specs/01-main/08-polling-generators.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { selectRegisterType, cell, @@ -18,6 +18,10 @@ const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') const SERVER_2_CONFIG = resolve(CONFIG_DIR, 'server-2.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Polling and Generator verification', () => { let server2Port: string diff --git a/e2e/specs/01-main/09-write-operations.spec.ts b/e2e/specs/01-main/09-write-operations.spec.ts index 3a061640..042bd6fa 100644 --- a/e2e/specs/01-main/09-write-operations.spec.ts +++ b/e2e/specs/01-main/09-write-operations.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { selectRegisterType, addRegister, @@ -17,6 +17,10 @@ import { expectCellContains } from '../../fixtures/helpers' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Write Operations', () => { // ─── Setup: clean state and configure writable registers ─────────── diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index 0a61476e..0b79a6ea 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToHome, navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { type Page } from '@playwright/test' import net from 'net' @@ -9,6 +9,10 @@ let serverPort: number let master: net.Socket const masterEvents: string[] = [] +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Split View — Server in separate window', () => { test.afterAll(() => { master?.destroy() diff --git a/e2e/specs/01-main/11-scan-registers.spec.ts b/e2e/specs/01-main/11-scan-registers.spec.ts index bcade22b..fe344268 100644 --- a/e2e/specs/01-main/11-scan-registers.spec.ts +++ b/e2e/specs/01-main/11-scan-registers.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { loadServerConfig, navigateToClient, @@ -14,6 +14,10 @@ import { resolve } from 'path' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Scan Registers', () => { // ─── Setup ────────────────────────────────────────────────────────── diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index c7028425..90f2a5b2 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -12,6 +12,10 @@ import { resolve } from 'path' const SERVER_CONFIG = resolve(__dirname, '../../fixtures/config-files/server-integration.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Scan Unit IDs', () => { // ─── Setup ────────────────────────────────────────────────────────── diff --git a/e2e/specs/01-main/13-cleanup.spec.ts b/e2e/specs/01-main/13-cleanup.spec.ts index 3732e8d0..3394ede2 100644 --- a/e2e/specs/01-main/13-cleanup.spec.ts +++ b/e2e/specs/01-main/13-cleanup.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { loadServerConfig, selectUnitId, @@ -11,6 +11,10 @@ const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') const SERVER_2_CONFIG = resolve(CONFIG_DIR, 'server-2.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Cleanup Operations', () => { // Setup: clean state first, then create full state to clean up test('clean server state', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/14-client-config-io.spec.ts b/e2e/specs/01-main/14-client-config-io.spec.ts index 2ec92f85..07713c91 100644 --- a/e2e/specs/01-main/14-client-config-io.spec.ts +++ b/e2e/specs/01-main/14-client-config-io.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, selectRegisterType, @@ -20,6 +20,10 @@ const CONFIG_FILES = { clientComprehensive: resolve(CONFIG_DIR, 'client-server1-unit0.json') } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Client config I/O — view, save, clear, load', () => { test('navigate to client view', async ({ mainPage }) => { await navigateToClient(mainPage) diff --git a/e2e/specs/01-main/15-client-toolbar.spec.ts b/e2e/specs/01-main/15-client-toolbar.spec.ts index 8bac8e02..0227e784 100644 --- a/e2e/specs/01-main/15-client-toolbar.spec.ts +++ b/e2e/specs/01-main/15-client-toolbar.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -23,6 +23,10 @@ const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-basic.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Client toolbar — display options and utilities', () => { // ─── Setup ────────────────────────────────────────────────────────── diff --git a/e2e/specs/01-main/16-client-rtu.spec.ts b/e2e/specs/01-main/16-client-rtu.spec.ts index 82124cc1..8cd09e03 100644 --- a/e2e/specs/01-main/16-client-rtu.spec.ts +++ b/e2e/specs/01-main/16-client-rtu.spec.ts @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient } from '../../fixtures/helpers' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Client RTU — serial protocol configuration', () => { test('navigate to client view', async ({ mainPage }) => { await navigateToClient(mainPage) diff --git a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts index e9c93c41..528adeef 100644 --- a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts +++ b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -28,6 +28,10 @@ import { resolve } from 'path' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-huawei-smartlogger.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Huawei Smart Logger — comprehensive integration test', () => { test('clean server state', async ({ mainPage }) => { await cleanServerState(mainPage) diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index ae906232..1a2d78c0 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -179,6 +179,10 @@ async function configureRegister(p: any, rowId: number, reg: ClientRegister): Pr // ─── Tests ──────────────────────────────────────────────────────── +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Huawei Smart Logger — JSON server + manual client config', () => { // ─── Server setup (JSON load) ────────────────────────────────── diff --git a/e2e/specs/01-main/19-large-config.spec.ts b/e2e/specs/01-main/19-large-config.spec.ts index fc39b4b1..d84b4d27 100644 --- a/e2e/specs/01-main/19-large-config.spec.ts +++ b/e2e/specs/01-main/19-large-config.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -19,6 +19,10 @@ const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-large-config.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Large Config (>100 registers) with readConfiguration', () => { // ─── Setup ────────────────────────────────────────────────────── diff --git a/e2e/specs/01-main/20-read-after-config-load.spec.ts b/e2e/specs/01-main/20-read-after-config-load.spec.ts index 13e00b9e..effb127e 100644 --- a/e2e/specs/01-main/20-read-after-config-load.spec.ts +++ b/e2e/specs/01-main/20-read-after-config-load.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -13,6 +13,10 @@ import { resolve } from 'path' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Read after config load — init readConfiguration sync', () => { test('clean server state', async ({ mainPage }) => { await cleanServerState(mainPage) diff --git a/e2e/specs/01-main/21-bitmap-settings.spec.ts b/e2e/specs/01-main/21-bitmap-settings.spec.ts index c48bdc98..d28258fc 100644 --- a/e2e/specs/01-main/21-bitmap-settings.spec.ts +++ b/e2e/specs/01-main/21-bitmap-settings.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, cleanServerState, @@ -16,6 +16,10 @@ import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const BITMAP_CONFIG = resolve(CONFIG_DIR, 'client-bitmap.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Bitmap settings — color, invert & config persistence', () => { test('navigate to client and load bitmap config', async ({ mainPage }) => { await navigateToClient(mainPage) diff --git a/e2e/specs/01-main/22-large-config-perf.spec.ts b/e2e/specs/01-main/22-large-config-perf.spec.ts index 00823a40..8244a4a4 100644 --- a/e2e/specs/01-main/22-large-config-perf.spec.ts +++ b/e2e/specs/01-main/22-large-config-perf.spec.ts @@ -3,12 +3,16 @@ * Measures how long the sequential for...of loop takes to load all unit configs. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, loadServerConfig, selectUnitId } from '../../fixtures/helpers' import { resolve } from 'path' const LARGE_CONFIG = resolve(__dirname, '../../fixtures/config-files/server-large-perf.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Large config performance (20 units, 2600 entries)', () => { test('navigate to server', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/23-server-rtu.spec.ts b/e2e/specs/01-main/23-server-rtu.spec.ts index dfaa21f2..17cd3b0d 100644 --- a/e2e/specs/01-main/23-server-rtu.spec.ts +++ b/e2e/specs/01-main/23-server-rtu.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' import { navigateToServer, @@ -38,6 +38,10 @@ const PTY_1 = '/tmp/ttyV1' // ─── Block 1: Server RTU UI Elements ───────────────────────────────────────── +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Server RTU — UI elements', () => { test('navigate to server view', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts b/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts index 4203b219..02f65765 100644 --- a/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts +++ b/e2e/specs/01-main/24-client-rtu-over-tcp.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, navigateToClient, @@ -34,6 +34,10 @@ const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-basic-unit1.json') const PTY = '/tmp/ttyVRTU' const TCP_PORT = '15020' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Client RTU over TCP — round-trip via socat gateway', () => { test.skip(!hasSocat, 'socat not available') diff --git a/e2e/specs/01-main/25-disconnect-messages.spec.ts b/e2e/specs/01-main/25-disconnect-messages.spec.ts index 3dd01bab..0b5df1d2 100644 --- a/e2e/specs/01-main/25-disconnect-messages.spec.ts +++ b/e2e/specs/01-main/25-disconnect-messages.spec.ts @@ -12,7 +12,7 @@ * unexpectedly" beside "Disconnected from server". So what this spec watches is * the library: it goes red if a bump puts that relay back. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' import { cleanServerState, @@ -41,6 +41,10 @@ async function expectCleanDisconnect(p: Page): Promise<void> { await expect(snackbars).not.toContainText('unexpectedly') } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Disconnect messaging — TCP', () => { test('clean server state', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/26-server-layout.spec.ts b/e2e/specs/01-main/26-server-layout.spec.ts index a7de4054..e576f9d0 100644 --- a/e2e/specs/01-main/26-server-layout.spec.ts +++ b/e2e/specs/01-main/26-server-layout.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToServer, addBool } from '../../fixtures/helpers' import { evaluateMain } from '../../fixtures/launch' @@ -14,6 +14,10 @@ import { evaluateMain } from '../../fixtures/launch' const DEFAULT_SIZE: [number, number] = [1480, 1000] const BITS = 14 +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Server layout — panels stay inside the view', () => { test('fill both bit lists', async ({ mainPage }) => { await navigateToServer(mainPage) diff --git a/e2e/specs/01-main/27-serial-line-settings.spec.ts b/e2e/specs/01-main/27-serial-line-settings.spec.ts index 6a6e4483..dc808051 100644 --- a/e2e/specs/01-main/27-serial-line-settings.spec.ts +++ b/e2e/specs/01-main/27-serial-line-settings.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' import { navigateToServer, @@ -134,6 +134,10 @@ const CASES: { name: string; ui: SerialUi; speed: number; flags: string[] }[] = } ] +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Serial line settings — what reaches the port', () => { test.skip(!hasSocat, 'socat not available') diff --git a/e2e/specs/01-main/28-connect-cancel.spec.ts b/e2e/specs/01-main/28-connect-cancel.spec.ts index 2fb0c067..f41ad0a7 100644 --- a/e2e/specs/01-main/28-connect-cancel.spec.ts +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -11,7 +11,7 @@ * Both calls go through `window.api` in one tick, which is the window the * button opens while a port is opening. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' import { cleanServerState, @@ -38,6 +38,10 @@ async function connectLabelsOver(p: Page, milliseconds: number): Promise<string[ return [...seen] } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Cancelling a connect', () => { test.skip(!hasSocat, 'socat not available') diff --git a/e2e/specs/01-main/29-read-on-read-configuration.spec.ts b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts index dc141f94..ffd86d9c 100644 --- a/e2e/specs/01-main/29-read-on-read-configuration.spec.ts +++ b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts @@ -1,4 +1,4 @@ -import { test } from '../../fixtures/electron-app' +import { test, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -18,6 +18,10 @@ const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-large-config.json') const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-large-config.json') +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + // Turning read configuration on fills the grid from the mapping, and every word // in it is `dummyWords`, so address 0 reads 0 where the server holds 1. The // store asks main for a read, and only while main can answer one: nothing diff --git a/e2e/specs/01-main/30-server-bit-toggle.spec.ts b/e2e/specs/01-main/30-server-bit-toggle.spec.ts index d2a17ccf..edf57e9b 100644 --- a/e2e/specs/01-main/30-server-bit-toggle.spec.ts +++ b/e2e/specs/01-main/30-server-bit-toggle.spec.ts @@ -1,6 +1,10 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { addRegister, cleanServerState } from '../../fixtures/helpers' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + // A toggle writes the whole word back, so it has to read the word the toggle // before it wrote. The store gets that word one IPC round trip late and hands // it on after 50 ms of quiet, and clicks arrive faster than either. diff --git a/e2e/specs/01-main/31-split-view-client-state.spec.ts b/e2e/specs/01-main/31-split-view-client-state.spec.ts index 499ede42..cca76be7 100644 --- a/e2e/specs/01-main/31-split-view-client-state.spec.ts +++ b/e2e/specs/01-main/31-split-view-client-state.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, navigateToHome, @@ -24,6 +24,10 @@ const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-large-config.json') let serverPage: Page +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + // Both windows load the one renderer bundle, so both evaluate // `client.zustand`'s module scope, and `init` there called // `setReadConfiguration(false)` with no addressee. The main window's toggle diff --git a/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts index 4a11d05b..47fa6440 100644 --- a/e2e/specs/01-main/32-undo-redo.spec.ts +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -6,7 +6,7 @@ * saved configuration follows. The key presses here go to the page, not through * Electron's Edit menu, so this covers the field's half and not the menu's. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Locator, Page } from '@playwright/test' import { addBool, @@ -37,6 +37,10 @@ const typeOver = async (p: Page, input: Locator, text: string): Promise<void> => await p.keyboard.type(text, { delay: 30 }) } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Undo and redo in a focused field', () => { test('open the client view over TCP', async ({ mainPage }) => { await navigateToClient(mainPage) diff --git a/e2e/specs/97-scan-perf/01-grid-during-scan.spec.ts b/e2e/specs/97-scan-perf/01-grid-during-scan.spec.ts index c4c6b703..68958336 100644 --- a/e2e/specs/97-scan-perf/01-grid-during-scan.spec.ts +++ b/e2e/specs/97-scan-perf/01-grid-during-scan.spec.ts @@ -9,7 +9,7 @@ * Not part of the suite. Run it with `yarn test:e2e --grep "Scan grid cost"`. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { loadServerConfig, navigateToClient, @@ -27,6 +27,10 @@ const CONFIG = resolve(__dirname, '../../fixtures/config-files/server-scan-perf. const SCAN_LENGTH = process.env.SCAN_LENGTH ?? '2000' const CHUNK_SIZE = process.env.CHUNK_SIZE ?? '1' +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Scan grid cost', () => { test('load a server with 2000 registers', async ({ mainPage }) => { await cleanServerState(mainPage) diff --git a/e2e/specs/99-hardware/01-iem3000-rtu.spec.ts b/e2e/specs/99-hardware/01-iem3000-rtu.spec.ts index 74679fb3..a910a763 100644 --- a/e2e/specs/99-hardware/01-iem3000-rtu.spec.ts +++ b/e2e/specs/99-hardware/01-iem3000-rtu.spec.ts @@ -9,7 +9,7 @@ * * With no Arduino attached the whole suite skips. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClientRTU, @@ -32,6 +32,10 @@ const CLIENT_CONFIG_ERROR = resolve(CONFIG_DIR, 'client-iem3000-error.json') /** Gap between enabling read-configuration and the read it must serve. */ const READ_SETTLE_MS = 250 +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Hardware — iEM3000 RTU (Arduino emulator)', () => { let arduinoPort = '' From bfbbf877522b961cbe1da0f14ac631d20d64e952 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 18:43:24 +0200 Subject: [PATCH 122/137] chore: log before-quit with its stack, and the exit code at quit The Windows e2e flake of run 35881205447 left a main log that ends in window created, "Debugger ending", window closed and exit code 0, with none of the lines the two app.quit() callers write. So the quit came from somewhere this file does not log. before-quit now logs the stack that asked for it and quit the exit code; a quit Playwright asks for shows playwright-core's electron loader and an evaluate in that stack. --- src/main/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 16f2e9a6..2a447d3c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -251,5 +251,11 @@ app.on('window-all-closed', () => { } }) +// A quit neither caller above logged still passes here. The stack names the +// JavaScript that asked for it, and a quit from outside the app's own code +// has none of its frames in it. +app.on('before-quit', () => lifecycle(`before-quit\n${new Error().stack}`)) +app.on('quit', (_event, exitCode) => lifecycle(`quit exitCode=${exitCode}`)) + // In this file you can include the rest of your app"s specific main process // code. You can also put them in separate files and require them here. From 48d1dcdddf941611b1d77d7b4933a50d4b2336fc Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 18:43:25 +0200 Subject: [PATCH 123/137] test: a failing e2e spec no longer stops the run Every spec file resets the app in its beforeAll now, so a failure in one file says nothing about the next, and the run can report every failing file rather than the first. maxFailures goes from the base config. A probe that failed on purpose, followed by 01-home-navigation, gave 1 failed and 7 passed. The presentation tour keeps maxFailures 1, because each scene builds on the one before it. A correction to a3ea1ea: its message says 18 passed on the macOS arm and Windows runners before because a fresh app per file no longer inherited a window an earlier spec had drawn. That cause was not measured; why 18 passed there before is not known. --- CONTRIBUTING.md | 2 +- playwright.config.ts | 3 ++- playwright.presentation.config.ts | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea29fe0a..74e96d60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -294,7 +294,7 @@ generic driver claims the device. Every `test:all:*` ends with this round, and - **New features need tests.** Unit tests for logic, e2e tests for UI behavior. - **Bug fixes need a regression test.** Prove it was broken, prove it's fixed. - **Tests must be deterministic.** No flaky tests. No "works on my machine". Use `waitForTimeout()` for UI settling and animations, but use `toPass()` when asserting on data that needs time to arrive. -- **e2e tests run serially** with `maxFailures: 1`. One failure stops the entire suite. This is intentional. +- **e2e tests run serially**, and every spec file starts with a `beforeAll` that calls `resetApp`, which the fixture refuses to run a test without. A failure skips the rest of a `describe.serial` and touches no other file, so one run reports every failing file. ### Writing e2e tests diff --git a/playwright.config.ts b/playwright.config.ts index 643bb83a..549c5629 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -35,7 +35,8 @@ export default defineConfig({ timeout: 60000, retries: 0, workers: 1, - maxFailures: 1, + // Every spec file resets the app in its beforeAll, so a failure in one file + // says nothing about the next, and the run reports every failure it has. use: { trace: 'on-first-retry' } diff --git a/playwright.presentation.config.ts b/playwright.presentation.config.ts index 0fc22e71..3e405ad8 100644 --- a/playwright.presentation.config.ts +++ b/playwright.presentation.config.ts @@ -2,13 +2,15 @@ import { defineConfig } from '@playwright/test' import base from './playwright.config' // The way back into the presentation tour that playwright.config.ts leaves out. -// Spreads the base config so workers/retries/maxFailures stay in one place, and +// Spreads the base config so workers and retries stay in one place, and // overrides only what this suite genuinely needs: a longer timeout for the -// deliberate pauses between scenes, and its own output directory. +// deliberate pauses between scenes, its own output directory, and a stop at +// the first failure, because every scene builds on the one before it. export default defineConfig({ ...base, testDir: './e2e/specs/03-presentation', testIgnore: [], timeout: 120000, - outputDir: './e2e/presentation-output/test-results' + outputDir: './e2e/presentation-output/test-results', + maxFailures: 1 }) From 9cded5999b11f0a271749c2b2002ef73c417a06a Mon Sep 17 00:00:00 2001 From: Jens De Ketelaere <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 18:57:40 +0200 Subject: [PATCH 124/137] test: the unit id scan specs wait for the scan they started Modbux's server refuses a unit it does not host at once, so the scan of all 256 could end before the five disabled fields were read. It did in 3 of 7 runs of yarn test:e2e on Linux. That test now scans a device that never answers, so every read waits out its 500ms timeout. The two scans before it waited for Start Scanning, which the button reads before main takes the request too. A scan still running there was stopped by the next test's click. Both now wait for their last row. Removing the Stop button's call turns the stop test red at Stop Scanning, and a Length field left enabled turns it red at that field. --- e2e/specs/01-main/12-scan-unitids.spec.ts | 99 +++++++++++++++-------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index 90f2a5b2..db45a375 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -9,6 +9,7 @@ import { loadServerConfig } from '../../fixtures/helpers' import { resolve } from 'path' +import net from 'net' const SERVER_CONFIG = resolve(__dirname, '../../fixtures/config-files/server-integration.json') @@ -217,8 +218,12 @@ test.describe.serial('Scan Unit IDs', () => { const timeout = mainPage.getByTestId('scan-unitid-timeout-input').locator('input') await timeout.fill('500') - // Start scanning — button text changes back when done + // The last row before the button: the button reads Start Scanning before + // main takes the request too, and a scan still running when the next test + // presses the button is stopped by it. await mainPage.getByTestId('scan-unitid-start-stop-btn').click() + const modal = mainPage.locator('.MuiModal-root') + await expect(modal.locator('.MuiDataGrid-row[data-id="5"]')).toBeVisible({ timeout: 30000 }) await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( 'Start Scanning', { timeout: 30000 } @@ -291,8 +296,9 @@ test.describe.serial('Scan Unit IDs', () => { const timeout = mainPage.getByTestId('scan-unitid-timeout-input').locator('input') await timeout.fill('500') - // Start scan + // The last row before the button, as in the first scan. await mainPage.getByTestId('scan-unitid-start-stop-btn').click() + await expect(modal.locator('.MuiDataGrid-row[data-id="3"]')).toBeVisible({ timeout: 60000 }) await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( 'Start Scanning', { timeout: 60000 } @@ -333,39 +339,6 @@ test.describe.serial('Scan Unit IDs', () => { expect(count).toBeGreaterThanOrEqual(3) }) - // ─── Fields disabled during scanning + stop mid-scan ───────────── - - test('fields are disabled while scanning and stop works', async ({ mainPage }) => { - // Configure max range so scanning takes time - const startUnit = mainPage.getByTestId('scan-start-unitid-input').locator('input') - await startUnit.fill('0') - const count = mainPage.getByTestId('scan-unitid-count-input').locator('input') - await count.fill('256') - const timeout = mainPage.getByTestId('scan-unitid-timeout-input').locator('input') - await timeout.fill('500') - - await mainPage.getByTestId('scan-unitid-start-stop-btn').click() - await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText('Stop Scanning') - - // While scanning, all config fields should be disabled - await expect(mainPage.getByTestId('scan-start-unitid-input').locator('input')).toBeDisabled() - await expect(mainPage.getByTestId('scan-unitid-count-input').locator('input')).toBeDisabled() - await expect(mainPage.getByTestId('scan-unitid-address-input').locator('input')).toBeDisabled() - await expect(mainPage.getByTestId('scan-unitid-length-input').locator('input')).toBeDisabled() - await expect(mainPage.getByTestId('scan-unitid-timeout-input').locator('input')).toBeDisabled() - - // Stop scan mid-operation - await mainPage.getByTestId('scan-unitid-start-stop-btn').click() - await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( - 'Start Scanning', - { timeout: 10000 } - ) - - // Fields should be re-enabled after stopping - await expect(mainPage.getByTestId('scan-start-unitid-input').locator('input')).toBeEnabled() - await expect(mainPage.getByTestId('scan-unitid-count-input').locator('input')).toBeEnabled() - }) - // ─── A range wider than the unit ids there are ──────────────────── // Start caps at 255 and Count at 256, so the pair named unit id 299 and @@ -427,6 +400,62 @@ test.describe.serial('Scan Unit IDs', () => { await disconnectClient(mainPage) }) + // ─── Fields disabled during scanning + stop mid-scan ───────────── + + // Modbux's own server refuses a unit it does not host at once, so a scan of + // all 256 against it could end before the five fields were read: it did in + // 3 of 7 runs of yarn test:e2e on Linux, 23 Sep 2026. A device that never + // answers makes every read wait out the 500ms timeout, so the scan is still + // running when Stop is pressed. + test('fields are disabled while scanning and stop works', async ({ mainPage }) => { + const sockets: net.Socket[] = [] + const silentDevice = net.createServer((socket) => sockets.push(socket)) + const port = await new Promise<number>((resolve) => { + silentDevice.listen(0, '127.0.0.1', () => { + const address = silentDevice.address() + if (address === null || typeof address === 'string') throw new Error('device has no port') + resolve(address.port) + }) + }) + + await connectClient(mainPage, '127.0.0.1', String(port), '0') + await mainPage.getByTestId('menu-btn').click() + await mainPage.getByTestId('scan-unitids-btn').click() + + const startUnit = mainPage.getByTestId('scan-start-unitid-input').locator('input') + await startUnit.fill('0') + const count = mainPage.getByTestId('scan-unitid-count-input').locator('input') + await count.fill('256') + const timeout = mainPage.getByTestId('scan-unitid-timeout-input').locator('input') + await timeout.fill('500') + + await mainPage.getByTestId('scan-unitid-start-stop-btn').click() + await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText('Stop Scanning') + + await expect(mainPage.getByTestId('scan-start-unitid-input').locator('input')).toBeDisabled() + await expect(mainPage.getByTestId('scan-unitid-count-input').locator('input')).toBeDisabled() + await expect(mainPage.getByTestId('scan-unitid-address-input').locator('input')).toBeDisabled() + await expect(mainPage.getByTestId('scan-unitid-length-input').locator('input')).toBeDisabled() + await expect(mainPage.getByTestId('scan-unitid-timeout-input').locator('input')).toBeDisabled() + + // Unstopped, the scan runs for 256 x 500ms, so reading Start Scanning + // within 10s is the stop's doing. + await mainPage.getByTestId('scan-unitid-start-stop-btn').click() + await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( + 'Start Scanning', + { timeout: 10000 } + ) + + await expect(mainPage.getByTestId('scan-start-unitid-input').locator('input')).toBeEnabled() + await expect(mainPage.getByTestId('scan-unitid-count-input').locator('input')).toBeEnabled() + + await mainPage.keyboard.press('Escape') + await mainPage.keyboard.press('Escape') + await disconnectClient(mainPage) + for (const socket of sockets) socket.destroy() + silentDevice.close() + }) + test('scan unit IDs button is disabled when disconnected', async ({ mainPage }) => { await mainPage.getByTestId('menu-btn').click() await expect(mainPage.getByTestId('scan-unitids-btn')).toBeDisabled() From d38db5591ea4d6fdbc032e2742578c1e1ea4bdcc Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 18:58:42 +0200 Subject: [PATCH 125/137] test: trace the app on CI only, and screenshot a failure everywhere Recording the app's trace made 06, 11 and 21 together take 1.2 and 1.3 min against 1.0 min without, alternating runs on one machine, which accounts for most of what the suite gained on CI today. The fixture traces only when E2E_TRACE=1, which e2e.yml sets on both runs; a local run leaves it off, and a spec that fails is run again on its own with it on. A failed test gets a screenshot of every open window either way. A probe that failed on purpose left one png without E2E_TRACE, and a png and electron-trace.zip with it. --- .github/workflows/e2e.yml | 9 +++++++-- e2e/fixtures/electron-app.ts | 26 ++++++++++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ec87f9fa..6ce9d156 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -146,11 +146,14 @@ jobs: - name: Build run: yarn electron-vite build - # --trace retain-on-failure, because the repository config sets - # on-first-retry against retries: 0 and so records nothing. A failure here + # E2E_TRACE=1 makes the fixture trace the app's own context and keep the + # trace of a failed test; --trace retain-on-failure records only the + # contexts Playwright creates, which holds the assertions. A failure here # cannot be reproduced on the machine you are reading this from. - name: E2E against out/ if: inputs.mode != 'packaged' + env: + E2E_TRACE: '1' shell: bash run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --trace retain-on-failure @@ -177,6 +180,8 @@ jobs: - name: E2E against dist/ if: "!cancelled() && inputs.mode != 'dev'" + env: + E2E_TRACE: '1' shell: bash run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --config playwright.packaged.config.ts --trace retain-on-failure diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 8bf562c8..0aa2a839 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -52,15 +52,21 @@ export async function resetApp(app: ElectronApplication, page: Page): Promise<vo } /** - * A trace of the app for every test that fails. + * A screenshot of every open window for every test that fails, and a trace of + * the app beside it when `E2E_TRACE=1`. * * Playwright's own `trace` option records the contexts it creates itself, and * the app's is not one of them, so a failed test's trace held its assertions * and no DOM. Tracing runs on the app's context for the whole worker, one - * chunk per test, and a chunk is written only when its test failed, beside a - * screenshot of every window still open. An app that is already gone has no - * chunk to stop, and its log says why. + * chunk per test, and a chunk is written only when its test failed. An app + * that is already gone has no chunk to stop, and its log says why. + * + * Recording cost 06, 11 and 21 together 1.2 and 1.3 min against 1.0 min + * without, so the workflow turns it on and a local run leaves it off: a spec + * that fails locally is run again on its own with `E2E_TRACE=1`. */ +const tracing = process.env.E2E_TRACE === '1' + type ElectronTestFixtures = { electronTrace: void } export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ @@ -69,11 +75,11 @@ export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ if (!resetFiles.has(testInfo.file)) { throw new Error(`${testInfo.file} has no beforeAll that calls resetApp`) } - const tracing = electronApp.context().tracing - await tracing.startChunk({ title: testInfo.title }) + const context = tracing ? electronApp.context().tracing : undefined + await context?.startChunk({ title: testInfo.title }) await use() if (testInfo.status === testInfo.expectedStatus) { - await tracing.stopChunk().catch(() => undefined) + await context?.stopChunk().catch(() => undefined) return } for (const [i, window] of electronApp.windows().entries()) { @@ -81,8 +87,8 @@ export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ .screenshot({ path: testInfo.outputPath(`window-${i}.png`) }) .catch(() => undefined) } - await tracing - .stopChunk({ path: testInfo.outputPath('electron-trace.zip') }) + await context + ?.stopChunk({ path: testInfo.outputPath('electron-trace.zip') }) .catch(() => undefined) }, { auto: true } @@ -91,7 +97,7 @@ export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({ // eslint-disable-next-line no-empty-pattern async ({}, use): Promise<void> => { const app = await launchElectron() - await app.context().tracing.start({ snapshots: true }) + if (tracing) await app.context().tracing.start({ snapshots: true }) await evaluateMain(() => app.evaluate((ctx) => From 74a97368a2a8104ab18cc3ad5b39e935ab6dcae0 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 20:13:41 +0200 Subject: [PATCH 126/137] fix: a client keeps reconnecting while its device is gone A failed reconnect ended the burst. The open's catch reported the error and set the client disconnected, and a failed open fires no `close` to schedule the next attempt. Measured on the Arduino on 23 Sep 2026 with the USB cable pulled while polling: one attempt failing on "No such file or directory", then nothing, and still disconnected 30 s after the cable was back. A failed reconnect now reports its reason as an error, then a "Reconnecting (n/5)..." warning, and schedules the next attempt until the limit. Giving up clears the reconnect flag, so a manual connect after it counts from zero and says Connected rather than Reconnected. The first hardware run with the fix showed a second defect: the 10 s stability timer of the connection that dropped fired mid-burst and reset the count, 2/5 followed by 1/5. The drop clears it now. Verified on the Arduino: cable out, attempts failing on the missing path, cable back, "Reconnected over Modbus RTU" 0.6 s later and polling 1 s after that. That run predates the timer clear and the split message, which the five new unit tests cover; each rule was mutated and seen red. Lint, typecheck, yarn test (1793 passed) and e2e 25 and 28 (13 passed) green. --- CHANGELOG.md | 5 + .../modules/__tests__/modbusClient.test.ts | 91 +++++++++++++++++++ src/main/modules/modbusClient.ts | 24 ++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b12b9520..70a76289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A client keeps reconnecting while its device is gone.** Pulling the USB + cable of a serial device got one reconnect attempt, and when that failed the + client stopped trying, so plugging the cable back in did nothing. It now + keeps trying, up to five attempts, and picks the connection and the poll back + up once the device answers. - **A scan no longer stalls the window on a large configuration.** Every step a scan reported, every transaction it logged and every change of connection state wrote your whole saved client configuration back to disk, register diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index eb9c98d2..00383a0d 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -865,6 +865,97 @@ describe('ModbusClient', () => { expect(getLastClientState().connectState).toBe('disconnected') }) + /** + * A reconnect whose open fails. + * + * A pulled USB cable fails every open until it is plugged back in, and a + * failed open fires no `close` to schedule the next attempt. + */ + describe('a reconnect whose open fails', () => { + const GONE = 'No such file or directory, cannot open /dev/tty.usbmodem144301' + const messageTexts = (): string[] => + getWindowCalls('backend_message').map((m) => String(m[1].message)) + + const dropWithDeviceGone = async (): Promise<void> => { + await connectClient() + await vi.advanceTimersByTimeAsync(11000) + mockModbusRTU.isOpen = false + mockModbusRTU.connectTCP.mockRejectedValue(new Error(GONE)) + fireClientEvent('close') + await vi.advanceTimersByTimeAsync(3500) + } + + it('tries again, and stays connecting', async () => { + await dropWithDeviceGone() + const attempts = mockModbusRTU.connectTCP.mock.calls.length + + await vi.advanceTimersByTimeAsync(3000) + + expect(mockModbusRTU.connectTCP.mock.calls.length).toBe(attempts + 1) + expect(getLastClientState().connectState).toBe('connecting') + const messages = getWindowCalls('backend_message').map((m) => m[1]) + expect(messages).toContainEqual( + expect.objectContaining({ message: GONE, variant: 'error' }) + ) + expect(messages).toContainEqual( + expect.objectContaining({ message: 'Reconnecting (2/5)...', variant: 'warning' }) + ) + }) + + it('connects when the device is back', async () => { + await dropWithDeviceGone() + mockModbusRTU.connectTCP.mockImplementation(async () => { + mockModbusRTU.isOpen = true + }) + + await vi.advanceTimersByTimeAsync(3000) + + expect(getLastClientState().connectState).toBe('connected') + expect(messageTexts()).toContain('Reconnected over Modbus TCP') + }) + + it('gives up at the limit, and a manual connect after it starts afresh', async () => { + await dropWithDeviceGone() + await vi.advanceTimersByTimeAsync(3000 * 5) + + expect(messageTexts()).toContain('Too many consecutive reconnect attempts, giving up') + expect(getLastClientState().connectState).toBe('disconnected') + const attempts = mockModbusRTU.connectTCP.mock.calls.length + await vi.advanceTimersByTimeAsync(10000) + expect(mockModbusRTU.connectTCP.mock.calls.length).toBe(attempts) + + await connectClient() + expect(getLastClientState().connectState).toBe('connected') + expect(messageTexts().at(-1)).toBe('Connected over Modbus TCP') + }) + + it('counts on through the second the last connect was vouched for', async () => { + await connectClient() + mockModbusRTU.isOpen = false + mockModbusRTU.connectTCP.mockRejectedValue(new Error(GONE)) + // The drop lands inside the ten seconds after the connect, so the + // stability timer is still pending when the burst starts. + await vi.advanceTimersByTimeAsync(5000) + fireClientEvent('close') + await vi.advanceTimersByTimeAsync(3000 * 6) + + const retries = messageTexts().filter((text) => text.startsWith('Reconnecting (')) + expect(retries).toEqual([2, 3, 4, 5].map((n) => `Reconnecting (${n}/5)...`)) + expect(messageTexts()).toContain('Too many consecutive reconnect attempts, giving up') + }) + + it('leaves a connect the user pressed failing once, with no retry', async () => { + mockModbusRTU.connectTCP.mockRejectedValue(new Error(GONE)) + + await client.connect() + await vi.advanceTimersByTimeAsync(10000) + + expect(mockModbusRTU.connectTCP.mock.calls.length).toBe(1) + expect(getLastClientState().connectState).toBe('disconnected') + expect(messageTexts()).toContain(GONE) + }) + }) + it('does not reconnect after deliberate disconnect', async () => { await connectClient() await client.disconnect() diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 50fcae2c..903cc51b 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -143,6 +143,10 @@ export class ModbusClient { if (this._shouldAutoReconnect) { // Remember polling state before trying to reconnect this._reconnectWasPolling = this._clientState.polling + // The connection this timer vouched for is gone. Left running, it + // would zero the count in the middle of the burst that follows, and + // the burst would run past its limit. + clearTimeout(this._reconnectResetTimeout) // Only emit reconnecting message if not already in connecting state if (!this._reconnectTimeout) { @@ -303,6 +307,7 @@ export class ModbusClient { error: null }) this._reconnectWasPolling = false + this._reconnectTriggered = false clearTimeout(this._reconnectResumePollingTimeout) this._setDisconnected() return @@ -450,11 +455,20 @@ export class ModbusClient { return } const port = protocol === 'ModbusRtu' ? com : undefined - this._emitMessage({ - message: humanizeSerialError(error as Error, port), - variant: 'error', - error - }) + const reason = humanizeSerialError(error as Error, port) + // A failed reconnect is one attempt of the burst, not the end of it: a + // pulled USB cable fails every open until it is plugged back in, and no + // `close` follows a failed open to schedule the next one. + this._emitMessage({ message: reason, variant: 'error', error }) + if (this._reconnectTriggered) { + this._emitMessage({ + message: `Reconnecting (${this._consecutiveReconnects + 1}/${this._maxConsecutiveReconnects})...`, + variant: 'warning', + error: null + }) + this._scheduleReconnect() + return + } this._setDisconnected() } finally { this._connectInFlight = false From a10e4db595cdb4eef52396f20a32350ac5cb8c13 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 20:18:03 +0200 Subject: [PATCH 127/137] docs: name the library versions the RTU server's comments describe Five comments in rtu.ts describe what `@serialport/stream` and modbus-serial's `serverserial.js` do, and none said which version they read. They now name 13.0.0 and 8.0.25, the versions in node_modules, so a bump that changes that behaviour leaves a comment that says what it was true of. Comments only. --- src/main/modules/modbusServer/rtu.ts | 42 +++++++++++++++------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/main/modules/modbusServer/rtu.ts b/src/main/modules/modbusServer/rtu.ts index 84875418..b4a09b16 100644 --- a/src/main/modules/modbusServer/rtu.ts +++ b/src/main/modules/modbusServer/rtu.ts @@ -8,9 +8,9 @@ import { BROADCAST_UNIT_ID, createVector } from './vector' /** * The serial port under a `ServerSerial`, with the events this file listens for. * - * `serverserial.js` opens a `SerialPort` into `_serverPath` and returns it from - * `getPort()`. `ServerSerial.d.ts` declares neither, so reaching the port needs - * a type written here. + * `serverserial.js` (modbus-serial 8.0.25) opens a `SerialPort` into + * `_serverPath` and returns it from `getPort()`. `ServerSerial.d.ts` declares + * neither, so reaching the port needs a type written here. */ interface RtuSerialPort { on(event: 'error' | 'close', listener: (err?: Error) => void): void @@ -23,11 +23,11 @@ interface ServerSerialWithPort extends ServerSerial { /** * `ServerSerial`'s third constructor argument, which its typings leave out. * - * `serverserial.js` builds its own option object out of `path`, `baudRate`, - * `parity`, `debug`, `unitID` and `binding`, and assigns it over this one. So - * these two reach `SerialPort` by no other route, and the options object the - * typings do declare accepts them and drops them. Without them the binding - * opens at its own defaults, `dataBits: 8` and `stopBits: 1`. + * `serverserial.js` (8.0.25) builds its own option object out of `path`, + * `baudRate`, `parity`, `debug`, `unitID` and `binding`, and assigns it over + * this one. So these two reach `SerialPort` by no other route, and the + * options object the typings do declare accepts them and drops them. Without + * them the binding opens at its own defaults, `dataBits: 8` and `stopBits: 1`. */ interface ServerSerialPortOptions { dataBits: DataBits @@ -101,8 +101,9 @@ export class RtuServer { * A generation that is not the current one belongs to a server `start` has * already replaced, and that server says nothing. An open still in flight * is what gets here: `stop` cannot close a port that never opened, - * because `SerialPortStream.close` takes its `!isOpen` branch and answers - * "Port is not open", so the open outlives the server it was started for. + * because `SerialPortStream.close` (13.0.0) takes its `!isOpen` branch and + * answers "Port is not open", so the open outlives the server it was started + * for. */ private _reportDown(generation: number, message: string, error?: Error): void { if (generation !== this._generation) return @@ -131,9 +132,10 @@ export class RtuServer { path: serialConfig.com, baudRate: Number(serialConfig.options.baudRate), parity: serialConfig.options.parity ?? 'none', - // `@serialport/stream`'s `_error` hands a failed open to this callback - // when one is passed and emits `error` on the port when none is. The - // same callback carries the success, with null in place of an error. + // `@serialport/stream` 13.0.0's `_error` hands a failed open to this + // callback when one is passed and emits `error` on the port when none + // is. The same callback carries the success, with null in place of an + // error. openCallback: (err): void => { if (err) this._reportDown(generation, `RTU server error: ${err.message}`) } @@ -154,9 +156,9 @@ export class RtuServer { this._reportDown(generation, `RTU server error: ${err?.message ?? err}`) }) - // `close` is the disconnect event. `@serialport/stream` documents it as - // "in the case of a disconnect it will be called with a Disconnect Error - // object", and its `_disconnected` answers a failed read with + // `close` is the disconnect event. `@serialport/stream` 13.0.0 documents + // it as "in the case of a disconnect it will be called with a Disconnect + // Error object", and its `_disconnected` answers a failed read with // `close(undefined, new DisconnectedError(...))` while pushing nothing // into the stream. So an adapter pulled between requests arrives here // and nowhere else, and without this the view keeps showing a server @@ -182,11 +184,11 @@ export class RtuServer { this.warnBroadcastUnit(uuid) }) - // `socketError`, not `error`. `serverserial.js` emits `error` only from - // `sockWriter`'s `if (err)`, and the only caller of `sockWriter` is + // `socketError`, not `error`. `serverserial.js` 8.0.25 emits `error` only + // from `sockWriter`'s `if (err)`, and the only caller of `sockWriter` is // `_callbackFactory`, which passes null on both of its branches: it has - // turned the error into an exception frame by then. `socketError` is what - // a failure of the pipe under the server emits. + // turned the error into an exception frame by then. `socketError` is + // what a failure of the pipe under the server emits. this._server.on('socketError', (err) => { this._reportDown(generation, `RTU server error: ${err?.message ?? err}`) }) From 1f5436980ad0153e8252f7d555ec219d97f1a3ab Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 20:32:08 +0200 Subject: [PATCH 128/137] fix: the server lists no longer draw themselves on every value `ServerBoolList` and `ServerRegisterRows` selected the whole map of their register type and derived the sorted addresses from it. Mutative gives that map a new identity on every value written into it, so the coil list drew itself again on every toggle and the register list on every generator tick, sorting its keys each time and comparing every row's props. Both now select a string of their keys, which compares equal until a key comes or goes, and each register row selects its own entry by key, as the bool rows already did. A type-checker scan of the renderer's 269 store selectors found 20 answering an object or array; these two were the ones that used only the keys of what they selected. Measured with a render counter in vitest: one extra list render per value written, for both lists, before the change; none after. Reverting either file to HEAD turns its test red. Lint, typecheck, yarn test (1797 passed) and e2e 03, 04, 08, 21 and 30 green. --- .../server/ServerGrid/ServerBooleans.tsx | 24 +-- .../ServerRegisters/ServerRegisters.tsx | 48 ++++-- .../ServerGrid/__tests__/listRenders.test.tsx | 138 ++++++++++++++++++ 3 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/server/ServerGrid/__tests__/listRenders.test.tsx diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx index d1cccea5..234651d9 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx @@ -104,25 +104,25 @@ const ServerBoolRow = meme(({ address, type }: ServerBoolRowProps) => { // ─── Bool list ──────────────────────────────────────────────────────────────── const ServerBoolList = meme(({ type }: Omit<ServerBooleanProps, 'name'>) => { - // Zustand runs a selector on every store change to compare, not only during - // render, so a ref caching the sorted keys here would be written outside - // React's render phase. The map is selected and the order derived in a - // `useMemo`, which runs when React says so, the way `ServerRegisterRows` - // next door does. Mutative gives the map a new identity on every value - // written into it, so this list re-renders per toggle. The register list - // already pays that against generators writing on an interval, and each row - // is `meme`'d on props that do not move. - const boolMap = useServerZustand((z) => { + // The addresses, not the map: Mutative gives the map a new identity on every + // value written into it, and a string compares equal until an address comes + // or goes. Each row selects its own entry, so a toggle draws that row. Every + // address ends in a comma, so the split's last piece is always the empty one. + const joinedAddresses = useServerZustand((z) => { const uuid = z.selectedUuid const unitId = z.getUnitId(uuid) - return z.servers[uuid]?.registers[unitId]?.[type] + return Object.keys(z.servers[uuid]?.registers[unitId]?.[type] ?? {}) + .map((address) => `${address},`) + .join('') }) const addresses = useMemo( () => - Object.keys(boolMap ?? {}) + joinedAddresses + .split(',') + .slice(0, -1) .map(Number) .sort((a, b) => a - b), - [boolMap] + [joinedAddresses] ) return addresses.map((address) => ( diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index f2a423b1..d2be878d 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -73,9 +73,20 @@ const ServerRegisterValue = meme(({ register }: RowProps): JSX.Element => { ) }) -const ServerRegisterRow = meme(({ register }: RowProps) => { - const isBitmap = register.params.dataType === 'bitmap' +interface ServerRegisterRowProps { + type: NumberRegisters + registerKey: string +} + +const ServerRegisterRow = meme(({ type, registerKey }: ServerRegisterRowProps) => { + const register = useServerZustand((z) => { + const uuid = z.selectedUuid + const unitId = z.getUnitId(uuid) + return z.servers[uuid]?.registers[unitId]?.[type]?.[registerKey] + }) const [expanded, setExpanded] = useState(false) + if (!register) return null + const isBitmap = register.params.dataType === 'bitmap' return ( <Box> @@ -136,23 +147,42 @@ const ServerRegisterRow = meme(({ register }: RowProps) => { }) const ServerRegisterRows = meme(({ type }: { type: NumberRegisters }) => { - const registerMap = useServerZustand((z) => { + // Each map key with the address its row draws, joined: a string that compares + // equal until a register comes, goes or moves, where the map itself takes a + // new identity on every value a generator writes into it. Each row selects + // its own entry, so a value draws that row alone. Every pair ends in a comma, + // so the split's last piece is always the empty one. + const joinedKeys = useServerZustand((z) => { const uuid = z.selectedUuid const unitId = z.getUnitId(uuid) - return z.servers[uuid]?.registers[unitId]?.[type] + return Object.entries(z.servers[uuid]?.registers[unitId]?.[type] ?? {}) + .map(([key, entry]) => `${key}:${entry.params.address},`) + .join('') }) // Sorted by the address the row draws, the way `ServerBoolList` sorts its // keys, and keyed by the map key rather than that address. `Object.entries` // is ascending only over integer-index keys, and `RegisterAddressKeySchema` // accepts '007', which is not one and which names the same address as '7'. - const registers = useMemo( + const registerKeys = useMemo( () => - Object.entries(registerMap ?? {}).sort(([, a], [, b]) => a.params.address - b.params.address), - [registerMap] + joinedKeys + .split(',') + .slice(0, -1) + .map((pair) => { + const colon = pair.indexOf(':') + return { key: pair.slice(0, colon), address: Number(pair.slice(colon + 1)) } + }) + .sort((a, b) => a.address - b.address) + .map(({ key }) => key), + [joinedKeys] ) - return registers.map(([address, register]) => ( - <ServerRegisterRow key={`server_register_${type}_${address}`} register={register} /> + return registerKeys.map((registerKey) => ( + <ServerRegisterRow + key={`server_register_${type}_${registerKey}`} + type={type} + registerKey={registerKey} + /> )) }) diff --git a/src/renderer/src/components/server/ServerGrid/__tests__/listRenders.test.tsx b/src/renderer/src/components/server/ServerGrid/__tests__/listRenders.test.tsx new file mode 100644 index 00000000..0926977e --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/__tests__/listRenders.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// The two server lists select the addresses they draw, not the map holding +// them. Mutative gives a map a new identity on every value written into it, so +// a list that selected the map drew itself again on every toggle and every +// generator tick, sorting its keys each time. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +vi.hoisted(() => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { on: () => () => {}, send: () => {}, invoke: async () => undefined } + } + w.api = new Proxy({}, { get: () => () => Promise.resolve(undefined) }) +}) + +vi.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: vi.fn() }) })) + +// Counts the renders of every `meme` component whose props are `{ type }` and +// nothing else. In these two files that is the list and only the list: the +// panel takes a name as well, and a row takes an address or a register. +const { listRenders } = vi.hoisted(() => ({ listRenders: { count: 0 } })) +vi.mock('@renderer/components/shared/inputs/meme', async (importOriginal) => { + const original = await importOriginal<typeof import('@renderer/components/shared/inputs/meme')>() + const counted = (component: (props: Record<string, unknown>) => unknown) => { + const wrapped = (props: Record<string, unknown>) => { + if (Object.keys(props).join() === 'type') listRenders.count++ + return component(props) + } + return wrapped + } + return { + // A forwardRef component is an object, and none of those is a list. + meme: (component: Parameters<typeof original.meme>[0]) => + original.meme( + typeof component === 'function' + ? (counted(component as Parameters<typeof counted>[0]) as typeof component) + : component + ) + } +}) + +import { act, render, screen } from '@testing-library/react' +import { useServerZustand } from '@renderer/context/server.zustand' +import { getDefaultServer } from '@renderer/context/server.zustand.helpers' +import { ServerRegisterEntry } from '@shared' +import ServerBooleans from '../ServerBooleans' +import ServerRegisters from '../ServerRegisters/ServerRegisters' + +const UUID = 'u' + +const holdingAt = (address: number, value: number): ServerRegisterEntry => ({ + value, + params: { + address, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value, + min: undefined, + max: undefined, + interval: undefined + } +}) + +beforeEach(() => { + listRenders.count = 0 + useServerZustand.setState({ + selectedUuid: UUID, + ready: { [UUID]: true }, + servers: { + [UUID]: { + ...getDefaultServer(), + unitId: '0', + registers: { + '0': { + coils: { 0: { value: false }, 1: { value: false }, 2: { value: false } }, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { 0: holdingAt(0, 1), 1: holdingAt(1, 2) } + } + } + } + } + } as never) +}) + +describe('the coil list', () => { + it('does not draw itself again when a coil changes value', () => { + render(<ServerBooleans name="Coils" type="coils" />) + const before = listRenders.count + + act(() => { + useServerZustand.getState().setBool({ registerType: 'coils', address: 1, boolState: true }) + }) + + expect(listRenders.count).toBe(before) + }) + + it('draws the coil a new address adds', () => { + render(<ServerBooleans name="Coils" type="coils" />) + + act(() => { + useServerZustand.getState().setBool({ registerType: 'coils', address: 7, boolState: false }) + }) + + expect(screen.getByTestId('server-bool-coils-circle-7')).toBeTruthy() + }) +}) + +describe('the holding register list', () => { + it('does not draw itself again when a register changes value', () => { + render(<ServerRegisters name="Holding Registers" type="holding_registers" />) + const before = listRenders.count + + act(() => { + useServerZustand + .getState() + .setRegisterValue({ registerType: 'holding_registers', address: 1, value: 42 }) + }) + + expect(listRenders.count).toBe(before) + }) + + it('shows the value a register changes to', () => { + render(<ServerRegisters name="Holding Registers" type="holding_registers" />) + + act(() => { + useServerZustand + .getState() + .setRegisterValue({ registerType: 'holding_registers', address: 1, value: 42 }) + }) + + expect(screen.getByTestId('server-reg-value-holding_registers-1').textContent).toContain('42') + }) +}) From 33926f958197347b23e73479643acb64507efe0c Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 20:46:11 +0200 Subject: [PATCH 129/137] fix: a scan no longer pauses between requests Both scans awaited `_sendScanProgress`, which slept 5 ms after sending, and added a pause of their own: 10 ms per unit id before its result row, and 5 ms per register scan chunk. `8aed54a` brought all three in with the scans and recorded no reason. A scan spent them rather than reported them, so a device that answers quickly was asked at a fraction of its pace. The pauses are gone. Progress goes out at most every 100 ms, and always on the last step, without waiting. The unit id result goes out as soon as the id is done. Measured on localhost against Modbux's own server, a register scan of 10000 addresses: chunk 100, 2.0 s before and 0.54 s after; chunk 10, 13.1 s before and 1.9 to 2.6 s after. On the Arduino a scan of 100 registers at chunk 2 took 26.1 s before and after: at 9600 baud the device takes about 520 ms a request, and no request failed either way. Three stop tests relied on the pauses to keep a scan running long enough to stop; their mock device now takes 1 ms per answer, as a real one takes time. Three new tests cover the pauseless scan, the throttled progress and the result per unit id; reverting to HEAD turns all three red, and removing the throttle or its last-step clause turns the progress test red. Lint, typecheck, yarn test (1800 passed) and e2e 11 and 12 (66 passed) green. --- CHANGELOG.md | 8 ++ .../modules/__tests__/modbusClient.test.ts | 75 +++++++++++++++++-- src/main/modules/modbusClient.ts | 38 ++++------ .../context/__tests__/volatileState.test.ts | 6 +- src/renderer/src/context/data.zustand.ts | 6 +- 5 files changed, 96 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a76289..5f28a940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 does when there is nothing left to undo. In a text field the keys undo your typing. Each view keeps its last 100 steps until Modbux closes. +### Changed + +- **Scans no longer pause between requests.** A register scan and a unit ID + scan waited 10 ms after every request, so a device that answers quickly was + asked at a fraction of its pace. A register scan of 10,000 addresses in + chunks of 10 took 13 seconds against Modbux's own server and now takes about + 2. A slow device scans as fast as before, and the progress bar still moves. + ### Fixed - **A client keeps reconnecting while its device is gone.** Pulling the USB diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 00383a0d..58d89515 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -3106,9 +3106,10 @@ describe('ModbusClient', () => { it('stops mid-scan when stopScanningUnitIds is called', async () => { await connectClient() - mockModbusRTU.readHoldingRegisters.mockResolvedValue({ - data: [0], - buffer: Buffer.alloc(2) + // A device takes time to answer, and a stop arrives in between. + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return { data: [0], buffer: Buffer.alloc(2) } }) const scanPromise = client.scanUnitIds({ @@ -3136,9 +3137,10 @@ describe('ModbusClient', () => { // so the last id handed to `setID` is the last iteration that ran. it('leaves the remaining unit ids unvisited after a cancel', async () => { await connectClient() - mockModbusRTU.readHoldingRegisters.mockResolvedValue({ - data: [0], - buffer: Buffer.alloc(2) + // A device takes time to answer, and a stop arrives in between. + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return { data: [0], buffer: Buffer.alloc(2) } }) mockModbusRTU.setID.mockClear() @@ -3150,7 +3152,8 @@ describe('ModbusClient', () => { timeout: 1000 }) - await vi.advanceTimersByTimeAsync(50) + // About ten ids in, at a millisecond an answer. + await vi.advanceTimersByTimeAsync(10) client.stopScanningUnitIds() await vi.advanceTimersByTimeAsync(5000) await scanPromise @@ -3508,9 +3511,65 @@ describe('ModbusClient', () => { }) }) + describe('how fast a scan runs, and what it reports', () => { + it('waits for nothing but the device', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + + await client.scanRegisters({ addressRange: [0, 999], length: 10, timeout: 1000 }) + + expect(mockModbusRTU.readHoldingRegisters.mock.calls.length).toBe(100) + expect(client.state.scanningRegisters).toBe(false) + }) + + it('says how far it has got every 100 ms, and 100 at the end', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return { data: new Array(10).fill(100), buffer: Buffer.alloc(20) } + }) + + const scanPromise = client.scanRegisters({ + addressRange: [0, 9999], + length: 10, + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(2000) + await scanPromise + + const progress = getWindowCalls('scan_progress').map((call) => call[1]) + // A thousand answers a millisecond apart: a report per 100 ms of them, + // not one per answer. + expect(progress.length).toBeGreaterThan(5) + expect(progress.length).toBeLessThan(20) + expect(progress.at(-1)).toBe(100) + }) + + it('sends the result of each unit id without waiting', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ data: [0], buffer: Buffer.alloc(2) }) + + await client.scanUnitIds({ + range: [1, 5], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + + expect(getWindowCalls('scan_unit_id_result').map((call) => call[1].id)).toEqual([ + 1, 2, 3, 4, 5 + ]) + }) + }) + it('stops mid-scan when stopScanningRegisters is called', async () => { await connectClient() - setupHoldingRegisterReadMock([100]) + // A device takes time to answer, and a stop arrives in between. + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return { data: new Array(10).fill(100), buffer: Buffer.alloc(20) } + }) const scanPromise = client.scanRegisters({ addressRange: [0, 1000], diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 903cc51b..d9ee02a6 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -206,23 +206,23 @@ export class ModbusClient { } /** - * Sends how far a scan has got, and pauses 5 ms. + * Counts one step of a scan, and says how far it has got at most every + * 100 ms, and on the last step. * - * Both scans await this between two device requests, so the pause is what a - * scan spends rather than what it reports. The widest unit id scan the fields - * accept is 256 ids over four register types, which is 1024 calls and 5.12 s; - * a register scan of 65536 addresses at a chunk of 100 is 656 calls and - * 3.28 s. - * - * `8aed54a` brought the three pauses in with the scans and recorded no reason - * for any of them. Shortening one is a change to how fast Modbux talks to a - * device, so it is measured against a device rather than settled here. + * A step is one device request, and a scan can take thousands. Sending each + * one costs an IPC message and a store write in the renderer, and the bar + * cannot show a step of a thousandth anyway. Nothing here waits, so a scan + * runs as fast as the device answers. */ - private _sendScanProgress = async (): Promise<void> => { + private _scanProgressSentAt = 0 + private _countScanStep = (): void => { this._scansDone++ + const now = Date.now() + const last = this._scansDone >= this._totalScans + if (!last && now - this._scanProgressSentAt < 100) return + this._scanProgressSentAt = now const progress = round((this._scansDone / this._totalScans) * 100, 2) this._windows.send('scan_progress', progress, 'main') - await new Promise((resolve) => setTimeout(resolve, 5)) } // @@ -1003,6 +1003,7 @@ export class ModbusClient { this._totalScans = (range[1] - range[0] + 1) * params.registerTypes.length this._scansDone = 0 + this._scanProgressSentAt = 0 for (let id = range[0]; id <= range[1]; id++) { await this._scanUnitIds({ id, ...params }) @@ -1054,7 +1055,7 @@ export class ModbusClient { } this._transactionLog.log(transactionIdKey, errorMessage) - await this._sendScanProgress() + this._countScanStep() } if (!this._clientState.scanningUnitIds) { @@ -1062,10 +1063,6 @@ export class ModbusClient { return } - // One pause per unit id, between the last register type's progress and this - // id's result row: 2.56 s over the widest scan the fields accept. - // `_sendScanProgress` says what these figures are. - await new Promise((resolve) => setTimeout(resolve, 10)) this._sendUnitIdResult(result) } @@ -1094,19 +1091,16 @@ export class ModbusClient { this._totalScans = Math.ceil((addressRange[1] - addressRange[0] + 1) / length) this._scansDone = 0 + this._scanProgressSentAt = 0 this._clientState.scanningRegisters = true this._sendClientState() for (let address = addressRange[0]; address <= addressRange[1]; address += length) { await this._scanRegister(address, length) - await this._sendScanProgress() + this._countScanStep() if (!this._clientState.scanningRegisters) break if (this._clientState.connectState !== 'connected') break - // The second pause per chunk, on top of `_sendScanProgress`'s, so a - // register scan waits 10 ms between chunks: 6.56 s over 65536 addresses - // at a chunk of 100. - await new Promise((resolve) => setTimeout(resolve, 5)) } this._clientState.scanningRegisters = false diff --git a/src/renderer/src/context/__tests__/volatileState.test.ts b/src/renderer/src/context/__tests__/volatileState.test.ts index 465f7e76..d43fed65 100644 --- a/src/renderer/src/context/__tests__/volatileState.test.ts +++ b/src/renderer/src/context/__tests__/volatileState.test.ts @@ -3,10 +3,8 @@ // Five fields written at event rate live in `data.zustand`, which has no // persist middleware. In `client.zustand` each of them serialized the whole // partialized state, register mapping included, because zustand's persist -// wraps `setState` and calls `setItem` on every call with no debounce. -// `scanUnitIds` in `modbusClient.ts` sets `_totalScans` to the unit id count -// times the register type count, so a unit id scan of 0 through 255 over four -// types sends 1024 `scan_progress` events alone. +// wraps `setState` and calls `setItem` on every call with no debounce. A unit +// id scan of 0 through 255 over four types logs 1024 transactions. import { beforeEach, describe, expect, it, vi } from 'vitest' import { CLIENT_ZUSTAND_STORAGE_KEY, defaultClientState } from '@shared' import type { ScanUnitIDResult, Transaction } from '@shared' diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index bd2bcecf..3c362f03 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -28,9 +28,9 @@ import { ClientState, RegisterData, defaultClientState, dummyWords } from '@shar * renderer on a 219955 byte blob, 1000 of those writes cost 887 ms of the main * thread, 432 ms of it the `JSON.stringify` alone and 249 ms the * `localStorage.setItem` alone, so the store Chromium keeps off the main thread - * does not take that second part off it either. `modbusClient`'s `scanUnitIds` - * sets `_totalScans` to the unit id count times the register type count, so a - * scan of 0 through 255 over four types sends 1024 `scan_progress` events. + * does not take that second part off it either. A scan sends `scan_progress` + * at most every 100 ms, and a unit id scan sends a result per id and a + * transaction per request. */ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]>( mutative((set) => ({ From 2a11e9bd3beba0ef18eac4e548afd1ab4a4971ea Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 21:42:06 +0200 Subject: [PATCH 130/137] fix: a config file opens on the side it belongs to, or says which Found by opening a client config from May 2025 on the server: the server cleared itself, loaded nothing and reported "Configuration updated from older format". That file has no version and no `registerMapping`, so `migrateServerConfig` read it as a v1 server config and built a server with no units. The same on main (2fab763) and v2.3.0, measured. The other mix-ups were refused on main with a Zod message naming a missing field. On this branch one of them was worse: a server config is v3 now and the client's own version is 2, so a server file opened in the client took the newer-version path and loaded an empty mapping with a warning. Both migrations now call `refuseOtherSidesConfig` first. A server config is the file holding `serverRegistersPerUnit`, or `serverRegisters`, which a server wrote before it had units (before 14893ff). The server refuses a file with neither; the client refuses one with either. A client config from before versioned files is a bare mapping, so the client keeps taking it. `serverRegisters` also opens now, as unit 0, where it loaded as an empty server before. The test "still takes a file with no version at all" fed `{ name, coils }` in as a server file, a shape no server ever wrote; it now carries `serverRegistersPerUnit`. configKind.test.ts covers each direction and format; each refusal and the unit 0 fallback was mutated and seen red. Lint, typecheck, yarn test (1811 passed), e2e 05 and 14 (36 passed) green. --- CHANGELOG.md | 9 ++ src/shared/__tests__/migrations.test.ts | 4 +- .../migrations/__tests__/configKind.test.ts | 107 ++++++++++++++++++ src/shared/migrations/client/config.ts | 2 + src/shared/migrations/server/config.ts | 7 +- src/shared/migrations/server/v1.ts | 2 + src/shared/migrations/shared.ts | 26 +++++ 7 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/shared/migrations/__tests__/configKind.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f28a940..3d83f4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Opening the other side's configuration says so.** A client configuration + from before files carried a version, opened on the server, cleared the + server and loaded nothing, while it reported "Configuration updated from + older format". The other mix-ups were refused with a message naming a + missing field. Each now says the file belongs to the other side, and leaves + what is on screen alone. +- **A server configuration from before units opens again.** A file saved + before a server had units, before June 2025, loaded as an empty server. Its + registers now open as unit 0. - **A client keeps reconnecting while its device is gone.** Pulling the USB cable of a serial device got one reconnect attempt, and when that failed the client stopped trying, so plugging the cable back in did nothing. It now diff --git a/src/shared/__tests__/migrations.test.ts b/src/shared/__tests__/migrations.test.ts index ad663a3b..0812df55 100644 --- a/src/shared/__tests__/migrations.test.ts +++ b/src/shared/__tests__/migrations.test.ts @@ -436,7 +436,9 @@ describe('configMigration', () => { ) it('still takes a file with no version at all', () => { - const result = migrateServerConfig(JSON.stringify({ name: 'v1', coils: {} })) + const result = migrateServerConfig( + JSON.stringify({ name: 'v1', serverRegistersPerUnit: {} }) + ) expect(result.fromVersion).toBe(1) expect(result.migrated).toBe(true) }) diff --git a/src/shared/migrations/__tests__/configKind.test.ts b/src/shared/migrations/__tests__/configKind.test.ts new file mode 100644 index 00000000..98511940 --- /dev/null +++ b/src/shared/migrations/__tests__/configKind.test.ts @@ -0,0 +1,107 @@ +// A client config opened on the server, or a server config in the client, is +// refused by name. A client config from before versioned files migrated on the +// server to a server with no registers, reported as "Configuration updated from +// older format", and the server view clears itself before it loads. +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { migrateClientConfig, migrateServerConfig } from '@shared' + +const fixture = (name: string): string => + readFileSync(resolve(__dirname, '../../../../e2e/fixtures/config-files', name), 'utf8') + +/** A client config as Modbux wrote it before files carried a version. */ +const clientV1 = JSON.stringify({ + Coils: {}, + DiscreteInputs: {}, + HoldingRegisters: { 30000: { dataType: 'utf8', comment: 'Model' } }, + InputRegisters: {} +}) + +/** + * A server config as Modbux wrote it before a server had units, before + * `14893ff`: one set of registers, which is unit 0, with the byte order on each + * register and a bool as a bare boolean. + */ +const serverBeforeUnits = JSON.stringify({ + name: 'Before units', + serverRegisters: { + coils: { 3: true }, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 10: { + value: 42, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'int16', + littleEndian: false, + comment: 'setpoint', + value: 42 + } + } + } + } +}) + +const clientV2 = fixture('client-basic.json') +const serverV1 = fixture('server-v1-legacy.json') +const serverV2 = fixture('server-basic.json') + +describe('the server', () => { + it('refuses a client config from before versioned files', () => { + expect(() => migrateServerConfig(clientV1)).toThrow('This is not a server configuration') + }) + + it('refuses a versioned client config', () => { + expect(() => migrateServerConfig(clientV2)).toThrow('This is not a server configuration') + }) + + it('opens a server config from before versioned files', () => { + expect(Object.keys(migrateServerConfig(serverV1).config.serverRegistersPerUnit)).toEqual(['0']) + }) + + it('opens a server config from before units, as unit 0', () => { + const { config } = migrateServerConfig(serverBeforeUnits) + expect(config.serverRegistersPerUnit['0']?.holding_registers['10']?.value).toBe(42) + expect(config.serverRegistersPerUnit['0']?.coils['3']).toEqual({ value: true }) + }) + + it('opens a versioned server config', () => { + expect(Object.keys(migrateServerConfig(serverV2).config.serverRegistersPerUnit)).toEqual(['0']) + }) +}) + +describe('the client', () => { + it('refuses a server config from before versioned files', () => { + expect(() => migrateClientConfig(serverV1)).toThrow('This is not a client configuration') + }) + + it('refuses a versioned server config', () => { + expect(() => migrateClientConfig(serverV2)).toThrow('This is not a client configuration') + }) + + // A server config this build saves is v3, and the client's own version is + // 2, so without the refusal it took the newer-version path and loaded an + // empty mapping. + it('refuses a server config this build saves', () => { + const serverV3 = JSON.stringify({ ...JSON.parse(serverV2), version: 3 }) + expect(() => migrateClientConfig(serverV3)).toThrow('This is not a client configuration') + }) + + it('refuses a server config from before units', () => { + expect(() => migrateClientConfig(serverBeforeUnits)).toThrow( + 'This is not a client configuration' + ) + }) + + it('opens a client config from before versioned files', () => { + const { config } = migrateClientConfig(clientV1) + expect(Object.keys(config.registerMapping.holding_registers ?? {})).toEqual(['30000']) + }) + + it('opens a versioned client config', () => { + expect(migrateClientConfig(clientV2).migrated).toBe(false) + }) +}) diff --git a/src/shared/migrations/client/config.ts b/src/shared/migrations/client/config.ts index 5ddfd133..674f4589 100644 --- a/src/shared/migrations/client/config.ts +++ b/src/shared/migrations/client/config.ts @@ -6,6 +6,7 @@ import { dropUnreadableConfigMapping, formatZodError, parseConfigFile, + refuseOtherSidesConfig, renameLegacyRegisterTypeKeys } from '../shared' @@ -50,6 +51,7 @@ function migrateClientV1toV2(v1Config: unknown): RegisterMapConfig { */ export function migrateClientConfig(raw: string): MigrationResult<RegisterMapConfig> { const { parsed, detectedVersion } = parseConfigFile(raw) + refuseOtherSidesConfig(parsed, 'client') // Current version - no migration needed if (detectedVersion === CURRENT_CLIENT_CONFIG_VERSION) { diff --git a/src/shared/migrations/server/config.ts b/src/shared/migrations/server/config.ts index d973010e..3405ac57 100644 --- a/src/shared/migrations/server/config.ts +++ b/src/shared/migrations/server/config.ts @@ -13,6 +13,7 @@ import { dropUnservableConfigRegisters, isRecord, parseConfigFile, + refuseOtherSidesConfig, renameLegacyRegisterTypeKeys, stringifyExact64BitValues } from '../shared' @@ -44,7 +45,10 @@ const SERVER_CONFIG_MIGRATIONS: Record<number, Migration<ServerConfig>> = { function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndianness?: boolean } { renameLegacyRegisterTypeKeys(v1Config) const config = v1Config as V1ServerConfig - const v1Registers = config.serverRegistersPerUnit ?? {} + // A server before `14893ff` had no units, and its one set of registers is + // what unit 0 holds now. + const v1Registers = + config.serverRegistersPerUnit ?? (config.serverRegisters ? { 0: config.serverRegisters } : {}) // `V1ServerRegisters` declares the bool records as the entries this leaves, // because the loop below reads them after it. The helper runs here rather // than a third copy of the step: a copy that builds a new record per bool @@ -156,6 +160,7 @@ function migrateBoolShapeInConfig(serverRegistersPerUnit: unknown): void { */ export function migrateServerConfig(raw: string): MigrationResult<ServerConfig> { const { parsed, detectedVersion } = parseConfigFile(raw) + refuseOtherSidesConfig(parsed, 'server') // Current version - migrate bool shape if needed, then validate if (detectedVersion === CURRENT_SERVER_CONFIG_VERSION) { diff --git a/src/shared/migrations/server/v1.ts b/src/shared/migrations/server/v1.ts index 1fcaa0de..4c888324 100644 --- a/src/shared/migrations/server/v1.ts +++ b/src/shared/migrations/server/v1.ts @@ -32,6 +32,8 @@ export type V1ServerRegistersPerUnit = Record<string, V1ServerRegisters | undefi export interface V1ServerConfig { name?: string serverRegistersPerUnit?: V1ServerRegistersPerUnit + /** What a server wrote before it had units: the registers of unit 0. */ + serverRegisters?: V1ServerRegisters } /** diff --git a/src/shared/migrations/shared.ts b/src/shared/migrations/shared.ts index bfc3159b..5f0a3550 100644 --- a/src/shared/migrations/shared.ts +++ b/src/shared/migrations/shared.ts @@ -86,6 +86,32 @@ export function parseConfigFile(raw: string): { return { parsed, detectedVersion: version ?? 1 } } +/** + * Refuses a file saved by the other side. + * + * A server config is the one that holds `serverRegistersPerUnit`, in every + * version, or `serverRegisters`, which a server wrote before it had units. A + * client config from before versioned files is a bare register mapping with no + * key of its own to test for, so the client side asks the opposite question: + * whether the file holds a server's key. + */ +export function refuseOtherSidesConfig( + parsed: Record<string, unknown>, + side: 'client' | 'server' +): void { + const isServerConfig = 'serverRegistersPerUnit' in parsed || 'serverRegisters' in parsed + if (side === 'server' && !isServerConfig) { + throw new Error( + 'This is not a server configuration. A client configuration opens in the client.' + ) + } + if (side === 'client' && isServerConfig) { + throw new Error( + 'This is not a client configuration. A server configuration opens on the server.' + ) + } +} + /** The object entries of `value`, and nothing at all when it is not an object. */ export const recordEntries = (value: unknown): [string, Record<string, unknown>][] => isRecord(value) From ddb00a3a55061325c1657dce8723254a8da1d77d Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 22:03:18 +0200 Subject: [PATCH 131/137] fix: a unit id scan and a write stop at the last address Address 65535 with a length of 2 in the unit id scan asked every unit for 65535 and 65536. The toolbar's Length field is bounded by the addresses left (`registersFrom(address)`), and the scan's was bounded by `maxReadQuantity` alone, so did `ScanUnitIDParametersSchema`; main passes the length through. A sweep for the same pair found one more: an int32 written at 65535 went out as FC16 for two registers, and `WriteParametersSchema` checked the address alone. The scan's Length field now masks to the smaller of the two bounds, and the mask rewrites a stored length when the address moves. Both schemas refuse a read or write that runs past 65535 and name the field. The register scan, the toolbar read and the server's add-register dialog were bounded already. A clamp in the scan request as well turned out unreachable: with the field mounted the store already holds the bounded length, so removing it left every test green. It is not in. Tests: the scan and write schemas at the edge in both directions, and the dialog sending 65535 with a length of 1. Each rule was mutated and seen red. Lint, typecheck and yarn test (1822 passed) green. The e2e specs 09, 11 and 12 did not run: a dev app held port 502. --- CHANGELOG.md | 4 ++ .../client/ScanUnitIds/ScanUnitIds.tsx | 8 ++- .../__tests__/ScanUnitIds.test.tsx | 13 ++++ src/shared/__tests__/schemas.test.ts | 59 ++++++++++++++++++- src/shared/types/client.ts | 17 +++++- src/shared/types/scan.ts | 36 +++++++---- 6 files changed, 121 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d83f4b2..da11cbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A unit ID scan stops at the last address.** Address 65535 with a length of + 2 asked every unit for an address that does not exist. The Length field now + stops at the addresses left, as the read's Length field does. A write that + would run past the last address is refused too. - **Opening the other side's configuration says so.** A client configuration from before files carried a version, opened on the server, cleared the server and loaded nothing, while it reported "Configuration updated from diff --git a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx index 3eefbc0f..65e2d273 100644 --- a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -11,7 +11,7 @@ import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInpu import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useDataZustand } from '@renderer/context/data.zustand' -import { MAX_UNIT_ID, maxReadQuantity, RegisterType } from '@shared' +import { MAX_UNIT_ID, maxReadQuantity, RegisterType, registersFrom } from '@shared' import { ElementType, useCallback } from 'react' import useScanUnitIdColumns from './columns' import { useScanUnitIdZustand } from './scanUnitIds.zustand' @@ -118,7 +118,11 @@ const LengthField = meme((): JSX.Element => { // every one of them. The floor is on the blur, the way Count's is: // `ScanUnitIDParametersSchema` takes a positive length. const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) - const max = maxReadQuantity(registerTypes) + const address = useScanUnitIdZustand((z) => z.address) + // The read also ends on an address there is, the way the toolbar's Length + // field is bounded. The mask rewrites the stored length when the address + // moves, so a length typed first still fits. + const max = Math.min(maxReadQuantity(registerTypes), registersFrom(address)) const setLength = useScanUnitIdZustand.getState().setLength diff --git a/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx index a9d57f9a..6c8df922 100644 --- a/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx @@ -72,6 +72,19 @@ describe('ScanUnitIds asks for a range the boundary takes', () => { expect(ScanUnitIDParametersSchema.safeParse(payload()).success).toBe(true) }) + // The Length field's mask is bounded by the address as well, and rewrites + // the stored length when it mounts: 65535 with a length of 2 asked for 65536. + it('ends the read on the last address', () => { + useScanUnitIdZustand.setState({ address: 65535, length: 2 }) + + render(<ScanUnitIds />) + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ address: 65535, length: 1 }) + expect(ScanUnitIDParametersSchema.safeParse(payload()).success).toBe(true) + }) + it('leaves a range that fits where it is', () => { useScanUnitIdZustand.setState({ startUnitId: 1, count: 6 }) diff --git a/src/shared/__tests__/schemas.test.ts b/src/shared/__tests__/schemas.test.ts index 69e25348..6c56ee27 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest' import { ConnectionConfigSchema, RegisterConfigSchema, - RegisterMapObjectSchema + RegisterMapObjectSchema, + WriteParametersSchema } from '../types/client' import { defaultConnectionConfig, defaultRegisterConfig } from '../default' import { BitColorSchema, BitMapEntrySchema, BitMapConfigSchema } from '../types/bitmap' @@ -558,6 +559,25 @@ describe('ScanUnitIDParametersSchema — the quantity that reaches the wire', () expect(takes({ registerTypes: ['coils', 'holding_registers'], length: 125 })).toBe(true) }) + // The quantity also has to end at the last address there is: 65535 with a + // length of 2 asked every unit for 65535 and 65536. + it('refuses a read that runs past the last address', () => { + expect(takes({ address: MAX_REGISTER_ADDRESS, length: 2 })).toBe(false) + expect(takes({ address: 65500, length: 37 })).toBe(false) + }) + + it('takes a read that ends on the last address', () => { + expect(takes({ address: MAX_REGISTER_ADDRESS, length: 1 })).toBe(true) + expect(takes({ address: 65500, length: 36 })).toBe(true) + }) + + it('names the length when the read runs past the last address', () => { + const result = ScanUnitIDParametersSchema.safeParse( + parameters({ address: MAX_REGISTER_ADDRESS, length: 2 }) + ) + expect(result.error?.issues[0]?.path).toEqual(['length']) + }) + it('names the length', () => { const result = ScanUnitIDParametersSchema.safeParse(parameters({ length: 65535 })) expect(result.success).toBe(false) @@ -565,6 +585,43 @@ describe('ScanUnitIDParametersSchema — the quantity that reaches the wire', () }) }) +describe('WriteParametersSchema — the registers a write reaches', () => { + const holding = (address: number, dataType: string): unknown => ({ + address, + single: false, + type: 'holding_registers', + value: 1, + dataType + }) + const coils = (address: number, count: number): unknown => ({ + address, + single: false, + type: 'coils', + value: Array<boolean>(count).fill(true), + dataType: undefined + }) + const takes = (parameters: unknown): boolean => + WriteParametersSchema.safeParse(parameters).success + + it('refuses a value that runs past the last address', () => { + expect(takes(holding(MAX_REGISTER_ADDRESS, 'int32'))).toBe(false) + expect(takes(holding(65533, 'double'))).toBe(false) + }) + + it('takes a value that ends on the last address', () => { + expect(takes(holding(65534, 'int32'))).toBe(true) + expect(takes(holding(MAX_REGISTER_ADDRESS, 'uint16'))).toBe(true) + }) + + it('refuses coils that run past the last address', () => { + expect(takes(coils(65530, 7))).toBe(false) + }) + + it('takes coils that end on the last address', () => { + expect(takes(coils(65530, 6))).toBe(true) + }) +}) + describe('maxReadQuantity', () => { // MODBUS Application Protocol Specification V1.1b3 section 6: FC01 and FC02 // answer at most 2000 bits, FC03 and FC04 at most 125 registers. diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index 31eb880f..4f222a58 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -1,12 +1,14 @@ import z from 'zod' import { BaseDataTypeSchema, DataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' +import { registerWidth } from '../encoding' import { MAX_WRITE_BITS, PortSchema, RegisterAddressKeySchema, RegisterAddressSchema, - UnitIdSchema + UnitIdSchema, + registersFrom } from './ranges' import { RegisterType, RegisterTypeSchema } from './register' import { SerialPortOptionsSchema } from './serial' @@ -176,6 +178,19 @@ export const WriteParametersSchema = z }) ]) ) + // What the write reaches has to end on an address there is: an int32 at + // 65535 went out as FC16 for 65535 and 65536. + .superRefine((parameters, ctx) => { + const width = + parameters.type === 'coils' ? parameters.value.length : registerWidth(parameters.dataType) + const available = registersFrom(parameters.address) + if (width <= available) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['address'], + message: `A write of ${width} from ${parameters.address} runs past the last address` + }) + }) export type WriteParameters = z.infer<typeof WriteParametersSchema> // diff --git a/src/shared/types/scan.ts b/src/shared/types/scan.ts index 1207e7a2..c0744415 100644 --- a/src/shared/types/scan.ts +++ b/src/shared/types/scan.ts @@ -1,5 +1,5 @@ import { RegisterType, RegisterTypeSchema } from './register' -import { maxReadQuantity, RegisterAddressSchema, UnitIdSchema } from './ranges' +import { maxReadQuantity, RegisterAddressSchema, registersFrom, UnitIdSchema } from './ranges' import z from 'zod' // Scan Registers @@ -16,11 +16,12 @@ export type ScanRegistersParameters = z.infer<typeof ScanRegistersParametersSche /** * What a unit id scan asks each unit for. * - * `length` and `registerTypes` bound each other, so the rule is the pair: one - * length goes out for every type selected, and the strictest of them is the one - * the request has to fit. Bare, `length` reached - * `this._readers[registerType](address, length)` unchanged and `modbus-serial` - * wrote it into the quantity field with nothing between. + * `length` is bound twice: by `registerTypes`, because one length goes out + * for every type selected and the strictest of them is the one the request has + * to fit, and by `address`, because the read has to end on an address there is. + * Bare, `length` reached `this._readers[registerType](address, length)` + * unchanged and `modbus-serial` wrote it into the quantity field with nothing + * between. */ export const ScanUnitIDParametersSchema = z .object({ @@ -32,12 +33,23 @@ export const ScanUnitIDParametersSchema = z }) .superRefine((parameters, ctx) => { const ceiling = maxReadQuantity(parameters.registerTypes) - if (parameters.length <= ceiling) return - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['length'], - message: `One read of these register types answers at most ${ceiling}` - }) + if (parameters.length > ceiling) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['length'], + message: `One read of these register types answers at most ${ceiling}` + }) + } + // The read also has to end on an address there is: 65535 with a length + // of 2 asked every unit for 65536. + const available = registersFrom(parameters.address) + if (parameters.length > available) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['length'], + message: `A read from ${parameters.address} reaches the last address after ${available}` + }) + } }) export type ScanUnitIDParameters = z.infer<typeof ScanUnitIDParametersSchema> From c34064a9e4ca87a29c7f3e50f2d9a961b481d9f9 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 22:03:19 +0200 Subject: [PATCH 132/137] fix: a unit id scan draws its results in batches A unit id scan sends one result per unit id, and the table drew itself again on each: with 255 ids the window lagged behind the scan. The results are now held back and written every 100 ms, on the timer the rows of a register scan already use, with what is left written when the scan ends and dropped when the results are cleared. `addScanUnitIdResult` became `addScanUnitIdResults`, taking the batch. Three tests cover one write per flush in the order the table shows, the flush on the state that ends the scan, and nothing outliving a clear; each rule was mutated and seen red. --- CHANGELOG.md | 3 + .../__tests__/scanUnitIdBatching.test.ts | 82 +++++++++++++++++++ .../context/__tests__/volatileState.test.ts | 4 +- src/renderer/src/context/data.zustand.ts | 46 +++++++++-- .../src/context/data.zustand.types.ts | 2 +- 5 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/context/__tests__/scanUnitIdBatching.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da11cbb1..59340b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 2 asked every unit for an address that does not exist. The Length field now stops at the addresses left, as the read's Length field does. A write that would run past the last address is refused too. +- **A unit ID scan of many IDs keeps up.** The results table drew itself + again for every unit ID, and a scan of 255 IDs lagged behind. It now draws + the new results ten times a second. - **Opening the other side's configuration says so.** A client configuration from before files carried a version, opened on the server, cleared the server and loaded nothing, while it reported "Configuration updated from diff --git a/src/renderer/src/context/__tests__/scanUnitIdBatching.test.ts b/src/renderer/src/context/__tests__/scanUnitIdBatching.test.ts new file mode 100644 index 00000000..2f81c148 --- /dev/null +++ b/src/renderer/src/context/__tests__/scanUnitIdBatching.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +// +// A unit id scan sends one result per unit id, and the table draws itself again +// on each one. With 255 ids the window lagged behind the scan, so the results +// are collected and written on a timer, the way the rows of a register scan are. +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState, type ScanUnitIDResult } from '@shared' +import { fireEvent, stubRenderer } from './stubRenderer' + +const SCAN_FLUSH_MS = 100 + +const result = (id: number): ScanUnitIDResult => ({ + id, + registerTypes: ['holding_registers'], + refusedRegisterTypes: [], + requestedRegisterTypes: ['holding_registers'], + errorMessage: { coils: '', discrete_inputs: '', input_registers: '', holding_registers: '' } +}) + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + localStorage.clear() + stubRenderer() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +const loaded = async (): Promise<{ + ids: () => number[] + writes: () => number + clear: () => void +}> => { + const { useDataZustand } = await import('../data.zustand') + useDataZustand.setState({ clientState: { ...defaultClientState, scanningUnitIds: true } }) + let writes = 0 + useDataZustand.subscribe((state, previous) => { + if (state.scanUnitIdResults !== previous.scanUnitIdResults) writes++ + }) + return { + ids: () => useDataZustand.getState().scanUnitIdResults.map((entry) => entry.id), + writes: () => writes, + clear: () => useDataZustand.getState().clearScanUnitIdResults() + } +} + +describe('results a unit id scan finds', () => { + it('reach the table in one write per flush, newest first', async () => { + const { ids, writes } = await loaded() + + fireEvent('scan_unit_id_result', result(1)) + fireEvent('scan_unit_id_result', result(2)) + fireEvent('scan_unit_id_result', result(3)) + expect(ids()).toEqual([]) + + vi.advanceTimersByTime(SCAN_FLUSH_MS) + + expect(ids()).toEqual([3, 2, 1]) + expect(writes()).toBe(1) + }) + + it('reach the table with the state that ends the scan', async () => { + const { ids } = await loaded() + fireEvent('scan_unit_id_result', result(1)) + + fireEvent('client_state', { ...defaultClientState, scanningUnitIds: false }) + + expect(ids()).toEqual([1]) + }) + + it('do not outlive a clear', async () => { + const { ids, clear } = await loaded() + fireEvent('scan_unit_id_result', result(1)) + + clear() + vi.advanceTimersByTime(SCAN_FLUSH_MS) + + expect(ids()).toEqual([]) + }) +}) diff --git a/src/renderer/src/context/__tests__/volatileState.test.ts b/src/renderer/src/context/__tests__/volatileState.test.ts index d43fed65..6916f613 100644 --- a/src/renderer/src/context/__tests__/volatileState.test.ts +++ b/src/renderer/src/context/__tests__/volatileState.test.ts @@ -59,7 +59,7 @@ describe('what main pushes about the client', () => { dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) dataZustand.addTransaction(transaction) - dataZustand.addScanUnitIdResult(scanResult) + dataZustand.addScanUnitIdResults([scanResult]) dataZustand.setScanProgress(50) dataZustand.setLastSuccessfulTransactionMillis(1) @@ -78,7 +78,7 @@ describe('what main pushes about the client', () => { dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) dataZustand.addTransaction(transaction) - dataZustand.addScanUnitIdResult(scanResult) + dataZustand.addScanUnitIdResults([scanResult]) dataZustand.setScanProgress(50) dataZustand.setLastSuccessfulTransactionMillis(1) diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 3c362f03..d800c721 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -13,7 +13,13 @@ import { DateTime } from 'luxon' // startup there is no React render behind that to catch it. import { useClientZustand } from './client.zustand' import { onEvent } from '@renderer/events' -import { ClientState, RegisterData, defaultClientState, dummyWords } from '@shared' +import { + ClientState, + RegisterData, + ScanUnitIDResult, + defaultClientState, + dummyWords +} from '@shared' /** * What main pushes about the client, held for as long as the window lives. @@ -77,15 +83,17 @@ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]> // Unit ID scanning scanUnitIdResults: [], - addScanUnitIdResult: (scanUnitIDResult) => + addScanUnitIdResults: (scanUnitIDResults) => set((state) => { - state.scanUnitIdResults.unshift(scanUnitIDResult) + state.scanUnitIdResults.unshift(...[...scanUnitIDResults].reverse()) while (state.scanUnitIdResults.length > 256) state.scanUnitIdResults.pop() }), - clearScanUnitIdResults: () => + clearScanUnitIdResults: () => { + dropPendingUnitIdResults() set((state) => { state.scanUnitIdResults = [] - }), + }) + }, // Scan progress scanProgress: 0, @@ -151,6 +159,29 @@ export const dropPendingScanRows = (): void => { pendingScanRows = [] } +/** + * Results a unit id scan found, held back and written on the same timer. + * + * One result arrives per unit id, and the table drew itself again on each: a + * scan of 255 ids lagged behind itself. + */ +let pendingUnitIdResults: ScanUnitIDResult[] = [] +let unitIdFlushTimeout: NodeJS.Timeout | undefined + +const flushUnitIdResults = (): void => { + clearTimeout(unitIdFlushTimeout) + unitIdFlushTimeout = undefined + if (pendingUnitIdResults.length === 0) return + useDataZustand.getState().addScanUnitIdResults(pendingUnitIdResults) + pendingUnitIdResults = [] +} + +const dropPendingUnitIdResults = (): void => { + clearTimeout(unitIdFlushTimeout) + unitIdFlushTimeout = undefined + pendingUnitIdResults = [] +} + /** Whether a `client_state` push has landed since the module was evaluated. */ let clientStatePushed = false @@ -213,6 +244,7 @@ onEvent('client_state', (clientState) => { // Main sends a scan's last rows before the state that ends it, so they are // written before the button says the scan stopped, not up to a flush later. if (!clientState.scanningRegisters) flushScanRows() + if (!clientState.scanningUnitIds) flushUnitIdResults() const dataZustand = useDataZustand.getState() dataZustand.setClientState(clientState) }) @@ -225,8 +257,8 @@ onEvent('transaction', (transaction) => { // Unit ID scanning results onEvent('scan_unit_id_result', (scanUnitIDResult) => { - const dataZustand = useDataZustand.getState() - dataZustand.addScanUnitIdResult(scanUnitIDResult) + pendingUnitIdResults.push(scanUnitIDResult) + if (!unitIdFlushTimeout) unitIdFlushTimeout = setTimeout(flushUnitIdResults, SCAN_FLUSH_MS) }) // Scan progress diff --git a/src/renderer/src/context/data.zustand.types.ts b/src/renderer/src/context/data.zustand.types.ts index ff93768d..53f5762d 100644 --- a/src/renderer/src/context/data.zustand.types.ts +++ b/src/renderer/src/context/data.zustand.types.ts @@ -21,7 +21,7 @@ export interface DataZustand { // Unit ID scanning scanUnitIdResults: ScanUnitIDResult[] - addScanUnitIdResult: (scanUnitIdResult: ScanUnitIDResult) => void + addScanUnitIdResults: (scanUnitIdResults: ScanUnitIDResult[]) => void clearScanUnitIdResults: () => void // Scan progress From 1991fad3079203371a2f2e895217dceedf1aa069 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 22:19:51 +0200 Subject: [PATCH 133/137] fix: switching read configuration reads nothing by itself Turning read configuration on asked main for a read (c69455b), so the grid showed values rather than the mapping's zeros. Jens found switching back and forth flaky: the read the switch started could land after the switch went back, and a read of the toolbar's range then filled a grid drawing the mapping. The switch now tells main the flag and asks for nothing; Read or the poll fills the values. The toggle still greys while a read, a write or a scan owns the client, because that answer would land after the switch the same way; the comment says so now. `setLittleEndian` keeps reading through `readWhenMainCan`, and its tests take over the states the toggle's covered (disconnected, polling, a register scan). Spec 29 pins the new behaviour: a second after the switch the grid still holds zeros, and Read brings 1 and 51. Spec 31 presses Read. Unit suite 1820 passed; e2e 11, 12, 14, 17 to 21, 23, 29 and 31 (252 passed). --- CHANGELOG.md | 4 + .../29-read-on-read-configuration.spec.ts | 23 ++-- .../31-split-view-client-state.spec.ts | 1 + .../client/RegisterConfig/RegisterConfig.tsx | 8 +- .../context/__tests__/readWhenMainCan.test.ts | 110 ++++++------------ src/renderer/src/context/client.zustand.ts | 11 +- 6 files changed, 63 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59340b2b..92fb023a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Switching read configuration reads nothing by itself.** Turning it on + shows the mapping, and Read or the poll fills in the values. A read the + switch started could land after you switched back, and filled the grid + with the other view's rows. - **Scans no longer pause between requests.** A register scan and a unit ID scan waited 10 ms after every request, so a device that answers quickly was asked at a fraction of its pace. A register scan of 10,000 addresses in diff --git a/e2e/specs/01-main/29-read-on-read-configuration.spec.ts b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts index ffd86d9c..8224898b 100644 --- a/e2e/specs/01-main/29-read-on-read-configuration.spec.ts +++ b/e2e/specs/01-main/29-read-on-read-configuration.spec.ts @@ -1,4 +1,4 @@ -import { test, resetApp } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, @@ -10,7 +10,8 @@ import { loadServerConfig, loadClientConfig, selectRegisterType, - expectCell + expectCell, + cell } from '../../fixtures/helpers' import { resolve } from 'path' @@ -23,11 +24,9 @@ test.beforeAll(async ({ electronApp, mainPage }) => { }) // Turning read configuration on fills the grid from the mapping, and every word -// in it is `dummyWords`, so address 0 reads 0 where the server holds 1. The -// store asks main for a read, and only while main can answer one: nothing -// presses Read here. `19-large-config` drives the same two configs and presses -// it. -test.describe.serial('Read configuration reads what it just put on screen', () => { +// in it is `dummyWords`, so address 0 reads 0 where the server holds 1 until a +// read. The switch asks for none. `19-large-config` drives the same two configs. +test.describe.serial('Read configuration shows the mapping, and Read fills it', () => { test('clean server state', async ({ mainPage }) => { await cleanServerState(mainPage) }) @@ -50,9 +49,17 @@ test.describe.serial('Read configuration reads what it just put on screen', () = await disableReadConfiguration(mainPage) }) - test('connected, the values are there without pressing Read', async ({ mainPage }) => { + // The switch reads nothing: a read it fired could land after it went back. + // A second after the switch the grid still holds the mapping's zeros, and + // Read brings the values. + test('connected, the switch reads nothing and Read brings the values', async ({ mainPage }) => { await connectClient(mainPage, '127.0.0.1', '502', '0') await enableReadConfiguration(mainPage) + await expectCell(mainPage, 0, 'comment', 'reg-000') + await mainPage.waitForTimeout(1000) + expect(await cell(mainPage, 0, 'word_uint16')).toBe('0') + + await mainPage.getByTestId('read-btn').click() await expectCell(mainPage, 0, 'word_uint16', '1') await expectCell(mainPage, 50, 'word_uint16', '51') diff --git a/e2e/specs/01-main/31-split-view-client-state.spec.ts b/e2e/specs/01-main/31-split-view-client-state.spec.ts index cca76be7..b8b8da26 100644 --- a/e2e/specs/01-main/31-split-view-client-state.spec.ts +++ b/e2e/specs/01-main/31-split-view-client-state.spec.ts @@ -53,6 +53,7 @@ test.describe.serial('Read configuration survives the split out server window', test('connected, read configuration fills every configured register', async ({ mainPage }) => { await connectClient(mainPage, '127.0.0.1', '502', '0') await enableReadConfiguration(mainPage) + await mainPage.getByTestId('read-btn').click() await expectCell(mainPage, 0, 'word_uint16', '1') await expectCell(mainPage, 50, 'word_uint16', '51') diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index f0fa95cd..b87915e7 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -156,10 +156,10 @@ const ReadConfiguration = meme(() => { ) ) - // Turning it on draws the mapping into the grid and then asks main to read - // it, and `readWhenMainCan` drops that ask without a word while anything - // owns the client. The press would leave the grid on `showMapping`'s zeros - // with no read coming, so the toggle greys for as long as that lasts. + // Switching draws the mapping into the grid, or empties it, and a read, a + // write or a scan that owns the client answers after the switch: its rows + // then land in a grid about the other question. So the toggle greys for as + // long as anything owns the client. // // `exceptPolling`, because a poll is the one owner that fills the grid on // its own: `setReadConfiguration` gives main the mapping and the flag, and diff --git a/src/renderer/src/context/__tests__/readWhenMainCan.test.ts b/src/renderer/src/context/__tests__/readWhenMainCan.test.ts index ccd39b47..56f02651 100644 --- a/src/renderer/src/context/__tests__/readWhenMainCan.test.ts +++ b/src/renderer/src/context/__tests__/readWhenMainCan.test.ts @@ -1,12 +1,10 @@ // @vitest-environment happy-dom // -// Two setters leave the grid holding something the device never answered for, -// and both put that right by asking main to read. `setReadConfiguration` fills -// the grid from the mapping, where every word is `dummyWords`. `setLittleEndian` -// leaves rows that were decoded in the other word order. Main refuses a read -// while a poll, either scan or a read already in flight owns the port, and -// again while nothing is connected, and says so in a snackbar, so neither -// setter may ask in those states. +// `setLittleEndian` leaves rows that were decoded in the other word order, and +// puts that right by asking main to read. Main refuses a read while a poll, +// either scan or a read already in flight owns the port, and again while +// nothing is connected, and says so in a snackbar, so the setter may not ask in +// those states. `setReadConfiguration` asks for no read at all. import { beforeEach, describe, expect, it, vi } from 'vitest' import { defaultClientState, emptyRegisterMapping } from '@shared' import type { ClientState, RegisterData } from '@shared' @@ -51,31 +49,22 @@ beforeEach(() => { recordApiCalls(calls) }) -describe('read configuration, turned on', () => { - it('asks main to read, after main has been told the flag', async () => { +// Turning read configuration on draws the mapping and reads nothing: a read +// fired by the switch could land after the switch went back, and a read of the +// toolbar's range then filled a grid that was drawing the mapping. The next +// Read or poll brings the values. +describe('read configuration, turned on or off', () => { + it('asks main for no read when on, connected and idle', async () => { const { useClientZustand, useDataZustand } = await load() useDataZustand.getState().setClientState(idle) calls.length = 0 useClientZustand.getState().setReadConfiguration(true) - expect(methods()).toEqual(['setReadConfiguration', 'read']) + expect(methods()).toEqual(['setReadConfiguration']) }) - it('reads on an empty grid, which is the grid `showMapping` has yet to fill', async () => { - const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState(idle) - useDataZustand.getState().setRegisterData([]) - calls.length = 0 - - useClientZustand.getState().setReadConfiguration(true) - - expect(methods()).toEqual(['setReadConfiguration', 'read']) - }) -}) - -describe('read configuration, in a state main would refuse', () => { - it('turned off, asks for nothing', async () => { + it('tells main the flag when off', async () => { const { useClientZustand, useDataZustand } = await load() useDataZustand.getState().setClientState(idle) useClientZustand.getState().setReadConfiguration(true) @@ -85,84 +74,57 @@ describe('read configuration, in a state main would refuse', () => { expect(methods()).toEqual(['setReadConfiguration']) }) +}) - it('disconnected, asks for nothing', async () => { - const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, connectState: 'disconnected' }) - calls.length = 0 - - useClientZustand.getState().setReadConfiguration(true) - - expect(methods()).toEqual(['setReadConfiguration']) - }) - - it('while polling, asks for nothing', async () => { - const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, polling: true }) - calls.length = 0 - - useClientZustand.getState().setReadConfiguration(true) - - expect(methods()).toEqual(['setReadConfiguration']) - }) - - it('while a unit id scan runs, asks for nothing', async () => { - const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, scanningUnitIds: true }) - calls.length = 0 - - useClientZustand.getState().setReadConfiguration(true) - - expect(methods()).toEqual(['setReadConfiguration']) - }) - - it('while a register scan runs, asks for nothing', async () => { +describe('the byte order, which reads through the same rule', () => { + it('asks for a read when rows are on screen', async () => { const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, scanningRegisters: true }) + useDataZustand.getState().setClientState(idle) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig', 'read']) }) - it('while a read is in flight, asks for nothing', async () => { + it('asks for nothing on an empty grid', async () => { const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, reading: true }) + useDataZustand.getState().setClientState(idle) + useDataZustand.getState().setRegisterData([]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig']) }) - it('while a write is in flight, asks for nothing', async () => { + it('asks for nothing disconnected', async () => { const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState({ ...idle, writing: true }) + useDataZustand.getState().setClientState({ ...idle, connectState: 'disconnected' }) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig']) }) -}) -describe('the byte order, which reads through the same rule', () => { - it('asks for a read when rows are on screen', async () => { + it('asks for nothing while polling', async () => { const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState(idle) + useDataZustand.getState().setClientState({ ...idle, polling: true }) useDataZustand.getState().setRegisterData([row]) calls.length = 0 await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['updateRegisterConfig', 'read']) + expect(methods()).toEqual(['updateRegisterConfig']) }) - it('asks for nothing on an empty grid', async () => { + it('asks for nothing while a register scan runs', async () => { const { useClientZustand, useDataZustand } = await load() - useDataZustand.getState().setClientState(idle) - useDataZustand.getState().setRegisterData([]) + useDataZustand.getState().setClientState({ ...idle, scanningRegisters: true }) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 await useClientZustand.getState().setLittleEndian(true) diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index edf0883e..dbd96909 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -541,15 +541,10 @@ export const useClientZustand = create< set((state) => { state.readConfiguration = readConfiguration }) + // No read follows. One fired by the switch could land after the switch + // went back, and a read of the toolbar's range then filled a grid + // drawing the mapping. The next Read or poll brings the values. window.api.setReadConfiguration(readConfiguration) - - // Turning it on puts the mapping in the grid through `showMapping`, - // which gives every row `dummyWords`, and that reads `uint16: 0`. A - // connected user was left looking at zeros nothing had asked a device - // for. Main holds what the read needs by then: `ReadConfiguration` - // flushes the mapping before it calls this, and the flag goes out on - // the line above. - if (readConfiguration) readWhenMainCan() }, // Reading setPollRate: (pollRate) => setRegisterConfigField(set, get, 'pollRate', pollRate), From 358e1a790fea0d64c044a1d265de4885c9e02e92 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 22:19:51 +0200 Subject: [PATCH 134/137] fix: transactions reach the log in batches, so a fast scan can be stopped With the scan pauses gone (ca27c9a) a register scan in chunks of one against a server that answers at once sent a transaction per request faster than the renderer wrote them. On the E2E run on ca27c9a the Stop click in 11-scan-registers waited 30 s for the window on ubuntu arm and macOS Intel. Transactions are now held and written every 100 ms, on the timer the scan rows and the unit id results use; the unit id results and the transactions share one helper, `heldOnTimer`. The same run failed 12-scan-unitids on three runners for a different reason: the test waited for the row of unit 200, and the table shows the newest result on top, so a scan faster than the check left 200 below what the grid draws. It waits for 255, the last id, instead. Tests: one write per flush in log order, and nothing outliving a clear; each rule mutated and seen red. A check that skipped an empty flush was unreachable, since an empty batch changes nothing, and is gone. Unit suite 1820 passed; e2e 11 and 12 among 252 passed locally. --- e2e/specs/01-main/12-scan-unitids.spec.ts | 6 +- .../__tests__/transactionBatching.test.ts | 77 +++++++++++++++++++ .../context/__tests__/volatileState.test.ts | 4 +- src/renderer/src/context/data.zustand.ts | 74 ++++++++++-------- .../src/context/data.zustand.types.ts | 2 +- 5 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/context/__tests__/transactionBatching.test.ts diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index db45a375..93664abc 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -356,9 +356,11 @@ test.describe.serial('Scan Unit IDs', () => { // The row rather than the button: the button reads Start Scanning both // before main takes the request and after it is done, so a refused request - // satisfies it without a scan. + // satisfies it without a scan. 255, the last id, rather than 200: the + // newest result is the top row, and a scan faster than the check left 200 + // below what the grid draws. const modal = mainPage.locator('.MuiModal-root') - await expect(modal.locator('.MuiDataGrid-row[data-id="200"]')).toBeVisible({ timeout: 60000 }) + await expect(modal.locator('.MuiDataGrid-row[data-id="255"]')).toBeVisible({ timeout: 60000 }) await expect(mainPage.getByTestId('scan-unitid-start-stop-btn')).toContainText( 'Start Scanning', diff --git a/src/renderer/src/context/__tests__/transactionBatching.test.ts b/src/renderer/src/context/__tests__/transactionBatching.test.ts new file mode 100644 index 00000000..e01d364d --- /dev/null +++ b/src/renderer/src/context/__tests__/transactionBatching.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +// +// Main sends one transaction per request. A register scan in chunks of one +// against a server that answers at once sent them faster than the renderer +// could write them, and a click on Stop waited 30 seconds for the window. They +// are collected and written on the timer the scan's rows use. +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import type { Transaction } from '@shared' +import { fireEvent, stubRenderer } from './stubRenderer' + +const FLUSH_MS = 100 + +const transaction = (id: string): Transaction => ({ + id, + timestamp: 0, + unitId: 1, + address: 0, + code: 3, + responseLength: 2, + timeout: false, + request: '0103000000010000', + responses: [] +}) + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + localStorage.clear() + stubRenderer() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +const loaded = async (): Promise<{ + ids: () => string[] + writes: () => number + clear: () => void +}> => { + const { useDataZustand } = await import('../data.zustand') + let writes = 0 + useDataZustand.subscribe((state, previous) => { + if (state.transactions !== previous.transactions) writes++ + }) + return { + ids: () => useDataZustand.getState().transactions.map((entry) => entry.id), + writes: () => writes, + clear: () => useDataZustand.getState().clearTransactions() + } +} + +describe('transactions main sends', () => { + it('reach the log in one write per flush, newest first', async () => { + const { ids, writes } = await loaded() + + fireEvent('transaction', transaction('a')) + fireEvent('transaction', transaction('b')) + fireEvent('transaction', transaction('c')) + expect(ids()).toEqual([]) + + vi.advanceTimersByTime(FLUSH_MS) + + expect(ids()).toEqual(['c', 'b', 'a']) + expect(writes()).toBe(1) + }) + + it('do not outlive a clear', async () => { + const { ids, clear } = await loaded() + fireEvent('transaction', transaction('a')) + + clear() + vi.advanceTimersByTime(FLUSH_MS) + + expect(ids()).toEqual([]) + }) +}) diff --git a/src/renderer/src/context/__tests__/volatileState.test.ts b/src/renderer/src/context/__tests__/volatileState.test.ts index 6916f613..9c890164 100644 --- a/src/renderer/src/context/__tests__/volatileState.test.ts +++ b/src/renderer/src/context/__tests__/volatileState.test.ts @@ -58,7 +58,7 @@ describe('what main pushes about the client', () => { keysWritten.length = 0 dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) - dataZustand.addTransaction(transaction) + dataZustand.addTransactions([transaction]) dataZustand.addScanUnitIdResults([scanResult]) dataZustand.setScanProgress(50) dataZustand.setLastSuccessfulTransactionMillis(1) @@ -77,7 +77,7 @@ describe('what main pushes about the client', () => { const dataZustand = useDataZustand.getState() dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) - dataZustand.addTransaction(transaction) + dataZustand.addTransactions([transaction]) dataZustand.addScanUnitIdResults([scanResult]) dataZustand.setScanProgress(50) dataZustand.setLastSuccessfulTransactionMillis(1) diff --git a/src/renderer/src/context/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index d800c721..f0a1ac4a 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -17,6 +17,7 @@ import { ClientState, RegisterData, ScanUnitIDResult, + Transaction, defaultClientState, dummyWords } from '@shared' @@ -66,15 +67,17 @@ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]> // Transaction log transactions: [], - addTransaction: (transaction) => + addTransactions: (transactions) => set((state) => { - state.transactions.unshift(transaction) + state.transactions.unshift(...[...transactions].reverse()) while (state.transactions.length > 1000) state.transactions.pop() }), - clearTransactions: () => + clearTransactions: () => { + pendingTransactions.drop() set((state) => { state.transactions = [] - }), + }) + }, lastSuccessfulTransactionMillis: null, setLastSuccessfulTransactionMillis: (value) => set((state) => { @@ -89,7 +92,7 @@ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]> while (state.scanUnitIdResults.length > 256) state.scanUnitIdResults.pop() }), clearScanUnitIdResults: () => { - dropPendingUnitIdResults() + pendingUnitIdResults.drop() set((state) => { state.scanUnitIdResults = [] }) @@ -160,27 +163,42 @@ export const dropPendingScanRows = (): void => { } /** - * Results a unit id scan found, held back and written on the same timer. + * Events that arrive one per request, held back and written on the same timer. * - * One result arrives per unit id, and the table drew itself again on each: a - * scan of 255 ids lagged behind itself. + * A unit id scan sends a result per unit id, and the table drew itself again + * on each: a scan of 255 ids lagged behind itself. Main sends a transaction per + * request, and a register scan in chunks of one against a server that answers + * at once sent them faster than the window could write them. */ -let pendingUnitIdResults: ScanUnitIDResult[] = [] -let unitIdFlushTimeout: NodeJS.Timeout | undefined - -const flushUnitIdResults = (): void => { - clearTimeout(unitIdFlushTimeout) - unitIdFlushTimeout = undefined - if (pendingUnitIdResults.length === 0) return - useDataZustand.getState().addScanUnitIdResults(pendingUnitIdResults) - pendingUnitIdResults = [] +const heldOnTimer = <T>( + write: (items: T[]) => void +): { push: (item: T) => void; flush: () => void; drop: () => void } => { + let pending: T[] = [] + let timeout: NodeJS.Timeout | undefined + const drop = (): void => { + clearTimeout(timeout) + timeout = undefined + pending = [] + } + const flush = (): void => { + const items = pending + drop() + write(items) + } + const push = (item: T): void => { + pending.push(item) + if (!timeout) timeout = setTimeout(flush, SCAN_FLUSH_MS) + } + return { push, flush, drop } } -const dropPendingUnitIdResults = (): void => { - clearTimeout(unitIdFlushTimeout) - unitIdFlushTimeout = undefined - pendingUnitIdResults = [] -} +const pendingUnitIdResults = heldOnTimer<ScanUnitIDResult>((results) => + useDataZustand.getState().addScanUnitIdResults(results) +) + +const pendingTransactions = heldOnTimer<Transaction>((transactions) => + useDataZustand.getState().addTransactions(transactions) +) /** Whether a `client_state` push has landed since the module was evaluated. */ let clientStatePushed = false @@ -244,22 +262,16 @@ onEvent('client_state', (clientState) => { // Main sends a scan's last rows before the state that ends it, so they are // written before the button says the scan stopped, not up to a flush later. if (!clientState.scanningRegisters) flushScanRows() - if (!clientState.scanningUnitIds) flushUnitIdResults() + if (!clientState.scanningUnitIds) pendingUnitIdResults.flush() const dataZustand = useDataZustand.getState() dataZustand.setClientState(clientState) }) // Transactions from the transation log -onEvent('transaction', (transaction) => { - const dataZustand = useDataZustand.getState() - dataZustand.addTransaction(transaction) -}) +onEvent('transaction', (transaction) => pendingTransactions.push(transaction)) // Unit ID scanning results -onEvent('scan_unit_id_result', (scanUnitIDResult) => { - pendingUnitIdResults.push(scanUnitIDResult) - if (!unitIdFlushTimeout) unitIdFlushTimeout = setTimeout(flushUnitIdResults, SCAN_FLUSH_MS) -}) +onEvent('scan_unit_id_result', (scanUnitIDResult) => pendingUnitIdResults.push(scanUnitIDResult)) // Scan progress onEvent('scan_progress', (scanProgress) => { diff --git a/src/renderer/src/context/data.zustand.types.ts b/src/renderer/src/context/data.zustand.types.ts index 53f5762d..9eeba19e 100644 --- a/src/renderer/src/context/data.zustand.types.ts +++ b/src/renderer/src/context/data.zustand.types.ts @@ -14,7 +14,7 @@ export interface DataZustand { // Transaction log transactions: Transaction[] - addTransaction: (transaction: Transaction) => void + addTransactions: (transactions: Transaction[]) => void clearTransactions: () => void lastSuccessfulTransactionMillis: number | null setLastSuccessfulTransactionMillis: (value: number | null) => void From 910373662cf0f0301b0aaca6e68c76526ce25467 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 22:39:20 +0200 Subject: [PATCH 135/137] fix: exception 11 no longer tells the user to retry later `modbus-serial` words exception 11 as "Gateway target device failed to respond (retry request again later)". The spec (V1.1b3, section 7) says no response came from the target and the device is usually not present on the network, and Modbux's own server sends 11 for a unit it does not host, where retrying changes nothing. Filed upstream as yaacov/node-modbus-serial#631. `errorText` in the client reads an error's message with that one hint replaced, and every site that passed a library message on (the snackbar, the transaction log, read configuration's error rows, the scan results) reads it through `errorText`. Exception 6 keeps "retry request again later", which is right there. Tests: exception 11 reaches the snackbar in the spec's words, exception 6 is left alone, and an error with no message still reads "Connection error"; the replacement and the empty-message fallback were each mutated and seen red. Lint, typecheck, yarn test (1823 passed). --- CHANGELOG.md | 4 ++ .../modules/__tests__/modbusClient.test.ts | 46 +++++++++++++++++++ src/main/modules/modbusClient.ts | 34 ++++++++++---- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fb023a..f06786bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Exception 11 no longer tells you to retry later.** A gateway or a server + answering exception 11 read "retry request again later", while the Modbus + specification says the target device is usually not there, and Modbux's own + server sends it for a unit ID it does not host. The message now says so. - **A unit ID scan stops at the last address.** Address 65535 with a length of 2 asked every unit for an address that does not exist. The Length field now stops at the addresses left, as the read's Length field does. A write that diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 58d89515..95427c92 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -775,6 +775,52 @@ describe('ModbusClient', () => { }) }) + // `modbus-serial` adds "(retry request again later)" to exception 11, and the + // spec says the target is usually not there, which is also what Modbux's own + // server means by it: a unit it does not host. Filed upstream as + // yaacov/node-modbus-serial#631. + describe('the text of exception 11', () => { + const LIBRARY = + 'Modbus exception 11: Gateway target device failed to respond (retry request again later)' + const SPEC = + 'Modbus exception 11: Gateway target device failed to respond (device is usually not present on the network)' + + it('reaches the message as the spec says it', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockRejectedValue( + Object.assign(new Error(LIBRARY), { modbusCode: 11 }) + ) + + await client.read() + + const messages = getWindowCalls('backend_message').map((message) => message[1].message) + expect(messages.some((message) => message.startsWith(SPEC))).toBe(true) + expect(messages.some((message) => message.includes('retry request again later'))).toBe(false) + }) + + it('leaves an error with no message to say Connection error', async () => { + await connectClient() + + fireClientEvent('error', {}) + + const messages = getWindowCalls('backend_message').map((message) => message[1].message) + expect(messages).toContain('Connection error') + }) + + it('leaves exception 6, where retrying is the point', async () => { + await connectClient() + const busy = 'Modbus exception 6: Slave device busy (retry request again later)' + mockModbusRTU.readHoldingRegisters.mockRejectedValue( + Object.assign(new Error(busy), { modbusCode: 6 }) + ) + + await client.read() + + const messages = getWindowCalls('backend_message').map((message) => message[1].message) + expect(messages.some((message) => message.startsWith(busy))).toBe(true) + }) + }) + describe('auto-reconnect', () => { it('schedules reconnect on close event', async () => { await connectClient() diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index d9ee02a6..9be29710 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -67,6 +67,22 @@ type WriteAttempt = * is the whole test, and it separates a unit that refused a request from a * unit that was never there. */ +/** + * The message of an error `modbus-serial` threw, with exception 11 read the + * way the spec reads it. + * + * The library adds "(retry request again later)" to exception 11. The spec + * (V1.1b3, section 7) says no response came from the target and the device is + * usually not present, which is also what Modbux's own server means by it: a + * unit it does not host. Retrying is the advice for exception 6, where the + * library's text stays. Filed as yaacov/node-modbus-serial#631. + */ +const errorText = (error: unknown): string => + ((error as Error).message ?? '').replace( + 'Gateway target device failed to respond (retry request again later)', + 'Gateway target device failed to respond (device is usually not present on the network)' + ) + const isModbusException = (error: unknown): boolean => typeof (error as { modbusCode?: unknown })?.modbusCode === 'number' @@ -133,7 +149,7 @@ export class ModbusClient { this._clientState.connectState = 'disconnected' this._sendClientState() this._emitMessage({ - message: (error as Error).message || 'Connection error', + message: errorText(error) || 'Connection error', variant: 'error', error: error }) @@ -543,7 +559,7 @@ export class ModbusClient { }) this._setDisconnected() } catch (error) { - this._emitMessage({ message: (error as Error).message, variant: 'error', error: error }) + this._emitMessage({ message: errorText(error), variant: 'error', error: error }) // The promise above takes `resolve` alone, so nothing rejects it and this // runs only if `ModbusRTU.close` throws where it stands. That leaves a @@ -645,7 +661,7 @@ export class ModbusClient { data.push(...rows) } catch (error) { const readError = error as Error - errorMessage = readError.message + errorMessage = errorText(readError) if (this._appState.readConfiguration) { // Generate error placeholder rows for configured addresses in this failed group @@ -921,8 +937,8 @@ export class ModbusClient { this._client.writeFC15(unitId, address, value, next) ) } catch (error) { - this._emitMessage({ message: (error as Error).message, variant: 'error', error }) - return { sent: true, transactionIdKey, errorMessage: (error as Error).message } + this._emitMessage({ message: errorText(error), variant: 'error', error }) + return { sent: true, transactionIdKey, errorMessage: errorText(error) } } return { sent: true, transactionIdKey, errorMessage: undefined } @@ -974,8 +990,8 @@ export class ModbusClient { this._client.writeFC16(unitId, address, registers, next) ) } catch (error) { - this._emitMessage({ message: (error as Error).message, variant: 'error', error: error }) - return { sent: true, transactionIdKey, errorMessage: (error as Error).message } + this._emitMessage({ message: errorText(error), variant: 'error', error: error }) + return { sent: true, transactionIdKey, errorMessage: errorText(error) } } return { sent: true, transactionIdKey, errorMessage: undefined } } @@ -1049,7 +1065,7 @@ export class ModbusClient { await this._readers[registerType](address, length) result.registerTypes.push(registerType) } catch (error) { - errorMessage = (error as Error).message + errorMessage = errorText(error) result.errorMessage[registerType] = errorMessage if (isModbusException(error)) result.refusedRegisterTypes.push(registerType) } @@ -1122,7 +1138,7 @@ export class ModbusClient { data = await this._readers[type](address, length) } catch (error) { const readError = error as Error - errorMessage = readError.message + errorMessage = errorText(readError) this._emitMessage({ message: errorMessage, variant: 'error', error }) } From b62abc8bbadb641804704a2ba545358132fe2f19 Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 23:05:04 +0200 Subject: [PATCH 136/137] test: 18 scrolls to each row it configures, 25 waits out the last snackbar Both failed on the E2E runs at ca27c9a and 57ae017, one mode each, and neither for a reason in the code under test; the traces say why. 25, RTU deliberate disconnect: the disconnect worked (the button read Connect 30 ms after the click), but "Disconnected from server" never showed. The TCP half's snackbar with the same text was still leaving, and `preventDuplicate` in main.tsx drops a message equal to one notistack still holds during its exit transition. A socat pty closes within milliseconds, so the second message landed in that window. The test now waits until the first is gone before it disconnects. 18, Huawei manual client: row 40736 was never in the DOM. The grid virtualises, and at the 1024 by 768 window macos-26 had, a group of 31 rows reaches past what it draws. `configureRegister` scrolls to the row first, and the comment that said 33 rows avoid virtualization no longer does. The same spec failed on macos-26 at f5832ad the same way. Both specs pass locally (44 passed). --- e2e/specs/01-main/18-huawei-manual-client.spec.ts | 8 ++++++-- e2e/specs/01-main/25-disconnect-messages.spec.ts | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/e2e/specs/01-main/18-huawei-manual-client.spec.ts b/e2e/specs/01-main/18-huawei-manual-client.spec.ts index 1a2d78c0..038dc145 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -15,7 +15,8 @@ import { cleanServerState, loadServerConfig, loadDummyData, - expectCell + expectCell, + scrollToRow } from '../../fixtures/helpers' import { resolve } from 'path' import { readFileSync } from 'fs' @@ -41,7 +42,7 @@ const holdingRegisters: Record<string, ClientRegister> = const TOTAL_REGISTERS = Object.keys(holdingRegisters).length -// ─── Build address groups from JSON (max 33 rows to avoid virtualization) ── +// ─── Build address groups from JSON (at most 33 rows each) ── const DATA_TYPE_WIDTH: Record<string, number> = { uint16: 1, @@ -122,6 +123,9 @@ async function configureRegister(p: any, rowId: number, reg: ClientRegister): Pr const label = `${reg.dataType.toUpperCase()} @ ${rowId}${reg.comment ? ` (${reg.comment})` : ''}` await test.step(`configure: ${label}`, async () => { + // The grid virtualises, and at 1024 by 768 a group of 31 rows reaches + // past what it draws: row 40736 was never in the DOM. + await scrollToRow(p, rowId) const row = p.locator(`.MuiDataGrid-row[data-id="${rowId}"]`) // Data type (dropdown select) diff --git a/e2e/specs/01-main/25-disconnect-messages.spec.ts b/e2e/specs/01-main/25-disconnect-messages.spec.ts index 0b5df1d2..2eeba2ea 100644 --- a/e2e/specs/01-main/25-disconnect-messages.spec.ts +++ b/e2e/specs/01-main/25-disconnect-messages.spec.ts @@ -122,6 +122,13 @@ test.describe.serial('Disconnect messaging — RTU', () => { }) test('deliberate disconnect reports no error', async ({ mainPage }) => { + // `preventDuplicate` drops a message equal to one still on screen, and the + // TCP half's "Disconnected from server" is still leaving when this one + // arrives: a pty closes within milliseconds. Measured on CI, where this one + // was dropped and never shown. + await expect(mainPage.locator(SNACKBARS)).not.toContainText('Disconnected from server', { + timeout: 10_000 + }) await disconnectClient(mainPage) await expectCleanDisconnect(mainPage) }) From 3313c0f6bf1df8be36ca47c4cc7dfff877d1184b Mon Sep 17 00:00:00 2001 From: Harted <deketelaerejens@gmail.com> Date: Wed, 23 Sep 2026 23:07:47 +0200 Subject: [PATCH 137/137] chore: React Developer Tools in the window's DevTools under yarn dev `electron-devtools-installer` (4.0.0, devDependency) loads React Developer Tools before the first window opens, the way Electron's docs recommend; they list React Developer Tools among the extensions tested to work. The branch sits behind `import.meta.env.DEV`, which a build drops with the import: a search of out/ for the installer and the extension's id finds nothing, so neither a build nor the e2e suite downloads anything. Checked by Jens under yarn dev: the Components and Profiler tabs find React after one reload with DevTools open. --- package.json | 1 + src/main/index.ts | 25 +++++++++++++- yarn.lock | 83 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9fdfb0df..ed39fe23 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@vitest/coverage-v8": "^4.1", "electron": "^43.3.0", "electron-builder": "26.15.3", + "electron-devtools-installer": "^4.0.0", "electron-vite": "^5", "eslint": "^8.57.1", "eslint-plugin-react": "^7.37.5", diff --git a/src/main/index.ts b/src/main/index.ts index 2a447d3c..f546d4ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -174,7 +174,30 @@ let splash: BrowserWindow | null = null // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. -app.whenReady().then(() => { +/** + * React Developer Tools in the window's own DevTools, under `yarn dev` only. + * + * Electron's docs recommend `electron-devtools-installer` and list React + * Developer Tools among the extensions tested to work. The extension has to be + * loaded before the page is, so the first window waits for it. + * `import.meta.env.DEV` is false in a build, which drops this branch and the + * import with it, so neither a build nor the e2e suite downloads anything. + * The Components and Profiler tabs find React after one reload (Cmd+R) with + * DevTools open; before it they report that the page has no React. + */ +const installReactDevtools = async (): Promise<void> => { + if (!import.meta.env.DEV) return + try { + const { installExtension, REACT_DEVELOPER_TOOLS } = await import('electron-devtools-installer') + await installExtension(REACT_DEVELOPER_TOOLS) + } catch (error) { + console.error('React Developer Tools did not load:', error) + } +} + +app.whenReady().then(async () => { + await installReactDevtools() + if (os.platform() !== 'darwin') { splash = new BrowserWindow({ width: 400, diff --git a/yarn.lock b/yarn.lock index cf4fc383..6b108917 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3856,6 +3856,15 @@ __metadata: languageName: node linkType: hard +"electron-devtools-installer@npm:^4.0.0": + version: 4.0.0 + resolution: "electron-devtools-installer@npm:4.0.0" + dependencies: + unzip-crx-3: "npm:^0.2.0" + checksum: 10c0/54ba5b7c5f51b9e9857d840fd8ab1958c80efe1c71ad1455f0c93fb3d87696261a429453d561074edcf5e2296b37e1bc65c98b61281ec704add8a563f454f4d0 + languageName: node + linkType: hard + "electron-publish@npm:26.15.3": version: 26.15.3 resolution: "electron-publish@npm:26.15.3" @@ -5227,6 +5236,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -5797,6 +5813,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:^3.1.0": + version: 3.10.2 + resolution: "jszip@npm:3.10.2" + dependencies: + lie: "npm:~3.3.0" + pako: "npm:~1.0.2" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:^1.0.5" + checksum: 10c0/c51b1473062bb7ac81c0c76047d99a2b56a062f2427ac989e28b46b31ecab636a440f137d15ff6320a961b7e1048c802f0bed83a2e10ed0ba9c2cfd84e818bc6 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -5823,6 +5851,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: "npm:~3.0.5" + checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808 + languageName: node + linkType: hard + "lightningcss-android-arm64@npm:1.33.0": version: 1.33.0 resolution: "lightningcss-android-arm64@npm:1.33.0" @@ -6278,6 +6315,17 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^0.5.1": + version: 0.5.6 + resolution: "mkdirp@npm:0.5.6" + dependencies: + minimist: "npm:^1.2.6" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/e2e2be789218807b58abced04e7b49851d9e46e88a2f9539242cc8a92c9b5c3a0b9bab360bd3014e02a140fc4fbc58e31176c408b493f8a2a6f4986bd7527b01 + languageName: node + linkType: hard + "modbus-serial@npm:^8.0.25": version: 8.0.25 resolution: "modbus-serial@npm:8.0.25" @@ -6326,6 +6374,7 @@ __metadata: deepmerge: "npm:^4.3.1" electron: "npm:^43.3.0" electron-builder: "npm:26.15.3" + electron-devtools-installer: "npm:^4.0.0" electron-vite: "npm:^5" eslint: "npm:^8.57.1" eslint-plugin-react: "npm:^7.37.5" @@ -6675,6 +6724,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:~1.0.2": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -7100,7 +7156,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.2": +"readable-stream@npm:^2.0.2, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -7643,6 +7699,13 @@ __metadata: languageName: node linkType: hard +"setimmediate@npm:^1.0.5": + version: 1.0.5 + resolution: "setimmediate@npm:1.0.5" + checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -8330,6 +8393,17 @@ __metadata: languageName: node linkType: hard +"unzip-crx-3@npm:^0.2.0": + version: 0.2.0 + resolution: "unzip-crx-3@npm:0.2.0" + dependencies: + jszip: "npm:^3.1.0" + mkdirp: "npm:^0.5.1" + yaku: "npm:^0.16.6" + checksum: 10c0/e551cb3d57d0271da41825e9bd9a7f4ef9ec5c3f15edc37bf909928c8327f21a6938ddc922787ee2b1b31f95ac83232dac79fd5a44e2727f9e800df9017a3b91 + languageName: node + linkType: hard + "unzipper@npm:^0.12.3": version: 0.12.5 resolution: "unzipper@npm:0.12.5" @@ -8769,6 +8843,13 @@ __metadata: languageName: node linkType: hard +"yaku@npm:^0.16.6": + version: 0.16.7 + resolution: "yaku@npm:0.16.7" + checksum: 10c0/de45a2f6c31ab905174c4e5a3aad93972cb3cf2946d206ab9718ad5fb9adf0781c011d8b1d576daf20ebfa02fa120a8e7552a742a88bb3d288eea5a3a693187c + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1"