diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md index f4ad4a4e..b2e04761 100644 --- a/.claude/skills/prose/SKILL.md +++ b/.claude/skills/prose/SKILL.md @@ -107,6 +107,12 @@ way to tell which is the false claim is to go and run the thing. **In a test file, the sentence naming what the test discriminates stays and the incident that produced it goes.** +**What the code was is not what it does.** A prop that is gone, a ref that no +longer exists, a test that was deleted, an alternative nobody is about to retry: +that is the file's diary, and git holds it. The commit message is where it goes. +What survives in the comment is the mechanism as it stands, and a past state +earns a line only where a reader would otherwise undo the change. + ## Then: can this be written with fewer sentences? Ask of each sentence: 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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f92095d6..6ce9d156 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -146,14 +146,28 @@ 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 + # 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. @@ -166,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/CHANGELOG.md b/CHANGELOG.md index df0f987a..f06786bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,117 @@ 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. Each view keeps its last 100 steps until Modbux closes. + +### 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 + 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 +- **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 + 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 + 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 + 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 + 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 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 + 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 + address, the length and the register type already cleared it. A running poll + fills the grid on its own and is left alone, as before. A read already on the + line when you change it no longer fills the grid back in either: its answer + says nothing about which unit ID, address or register type it was sent for, + so Modbux drops it rather than draw it under the new one. +- **A server configuration that puts one register at two addresses is now + refused.** A register is stored under its address and names that address + again in its own settings, and a file whose two disagreed opened: the server + answered at one address and the grid drew the other, so a generator's values + never reached the row and Delete took the register out of the server and left + the row behind. A file like that is refused by name now, and a register like + that in a saved session is dropped rather than opened. +- **A second write no longer goes out while the first is still on the line.** + Modbux sends one request at a time, and a poll, a scan or a read already + running refused a second. A write did not: two bit toggles a click apart in + 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 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 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 + 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 + 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 @@ -31,10 +137,17 @@ 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 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 - 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 @@ -46,47 +159,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 @@ -95,7 +193,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. @@ -135,9 +234,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 @@ -149,7 +249,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 @@ -250,7 +350,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 @@ -286,6 +386,66 @@ 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. 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. +- **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 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 + 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. +- **Deleting a server now releases its registers.** The button freed the value + generators and the listener and left every register of every unit ID behind, + so a session that added and deleted servers kept growing. It also said "No + server found" for a server that was never started, which is a server you can + delete like any other. +- **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. +- **A port another server already holds now says so.** Typing it into a second + server's Port field put the old number back with nothing said, which reads + like the field ignored you. The message names the port. +- **Clear Config asks before it throws the mapping away.** One click on the + button beside Save dropped the data type, scaling factor, comment, group end + and bitmap of every mapped register, and the configuration name with them, + with nothing to undo it. It now says how many registers carry something, and + that read configuration goes off with them, and waits for an answer. With + nothing mapped and no name there is nothing to lose, and it still clears at + once. +- **A client configuration that cannot be read no longer costs you the Load + button.** A file that is moved or deleted between picking it and opening it + left the button greyed out and the file picker gone, until you walked out to + Home and back. It says what went wrong and takes the next file, and a file it + refuses no longer empties the grid either. Opening a server configuration was + 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 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. ### Changed @@ -300,6 +460,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/CONTRIBUTING.md b/CONTRIBUTING.md index 0f2117ca..74e96d60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,12 +64,24 @@ population holds no violation, because a meter that reads no files passes every rule it has. **One store selector per field.** `useClientZustand((z) => z.a)` and then -`((z) => z.b)`, never one selector returning an object. An object literal is a -new reference on every render, so a selector that returns one re-renders its -component on every flush of any field. The same goes for a call with no selector -and for `(z) => z`, which take the whole store the long way round. The renderer -has zero of all three and zero `useShallow`, and that is why it draws a -two-thousand-row grid without either. +`((z) => z.b)`, never one selector answering something it built. zustand runs +the selector under `useSyncExternalStore` and compares its answer with +`Object.is`, so a fresh reference is a new snapshot every time React reads it, +and React reads it again on the render that follows: the component does not +render more often, it throws `Maximum update depth exceeded`. Measured at +zustand 5.0.11 on an object literal and on `Object.keys(z.servers)`, which is +the spelling that cost the whole server view on 22 Sep 2026. Anything built in +the selector counts, an array as much as an object, and so do a call with no +selector and `(z) => z`, which take the whole store the long way round. Where a +component wants a list of keys, select a value that compares equal, such as the +joined string, and split it in a `useMemo`. The renderer has zero of all of +these and zero `useShallow`, and that is why it draws a two-thousand-row grid +without either. + +`conformance.test.ts` reads the object literal, the bare call and `(z) => z`. +It cannot read a call that builds one, because whether `f(x)` answers a fresh +reference is not in the AST, so a selector calling anything is a reviewer's +question. **An action is fetched where it runs, not subscribed to.** A selector that hands back a store function puts that function in the dependency list, and a @@ -92,9 +104,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 @@ -122,13 +141,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 @@ -265,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/e2e/fixtures/arduino-port.ts b/e2e/fixtures/arduino-port.ts index 45a92912..04b549b3 100644 --- a/e2e/fixtures/arduino-port.ts +++ b/e2e/fixtures/arduino-port.ts @@ -17,9 +17,9 @@ export type PortChoice = { port: string; reason?: undefined } | { port?: undefin /** * The Arduino's serial port, or why there isn't one. * - * Replaces the page.pause() these specs used to open, which needed a person to - * pick the port by hand and so kept the hardware round out of the unattended - * suites. The vendor ID is what identifies the board: `manufacturer` reads + * The board is found here rather than through a `page.pause()` that waits for + * a person to pick the port, which is what keeps the hardware round in the + * unattended suites. The vendor ID is what identifies it: `manufacturer` reads * "Microsoft" on Windows, where the generic usbser driver claims the device. * * Returns a reason rather than throwing, so the caller decides between skipping diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 4e0ba676..0aa2a839 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -1,58 +1,108 @@ -import { - test as base, - _electron as electron, - type ElectronApplication, - type Page -} from '@playwright/test' -import { launchOptions } from './launch' -import { createWriteStream, mkdirSync } from 'fs' -import { join } from 'path' +import { test as base, expect, type ElectronApplication, type Page } from '@playwright/test' +import { launchElectron, evaluateMain } from './launch' +import { cleanServerState, navigateToHome } from './helpers' + +export type ElectronFixtures = { + electronApp: ElectronApplication + mainPage: Page +} + +/** The spec files whose `beforeAll` ran `resetApp` in this worker. */ +const resetFiles = new Set() + +const NO_MOTION = + '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' /** - * Everything the app writes, kept next to the traces. + * Brings the worker's app back to what a fresh launch shows, for the spec + * file whose `beforeAll` calls it. * - * 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 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. * - * The workflow uploads test-results/ when a run fails, so the file travels with - * the trace it belongs to. + * 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 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 async function resetApp(app: ElectronApplication, page: Page): Promise { + await evaluateMain(() => + app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows() + .filter((w) => w.getTitle() !== 'Modbux') + .forEach((w) => w.close()) + }) + ) + await page.evaluate(() => window.api.disconnect()) + + 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.addStyleTag({ content: NO_MOTION }) + await navigateToHome(page) + await expect(page.getByTestId('home-client-btn')).toBeVisible() + + resetFiles.add(base.info().file) } -export type ElectronFixtures = { - electronApp: ElectronApplication - mainPage: Page -} +/** + * 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. 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 } -// eslint-disable-next-line @typescript-eslint/ban-types -export const test = base.extend<{}, ElectronFixtures>({ +export const test = base.extend({ + electronTrace: [ + async ({ electronApp }, use, testInfo): Promise => { + if (!resetFiles.has(testInfo.file)) { + throw new Error(`${testInfo.file} has no beforeAll that calls resetApp`) + } + const context = tracing ? electronApp.context().tracing : undefined + await context?.startChunk({ title: testInfo.title }) + await use() + if (testInfo.status === testInfo.expectedStatus) { + await context?.stopChunk().catch(() => undefined) + return + } + for (const [i, window] of electronApp.windows().entries()) { + await window + .screenshot({ path: testInfo.outputPath(`window-${i}.png`) }) + .catch(() => undefined) + } + await context + ?.stopChunk({ path: testInfo.outputPath('electron-trace.zip') }) + .catch(() => undefined) + }, + { auto: true } + ], electronApp: [ // eslint-disable-next-line no-empty-pattern async ({}, use): Promise => { - const app = await electron.launch(launchOptions()) - keepOutput(app) + const app = await launchElectron() + if (tracing) await app.context().tracing.start({ snapshots: true }) - await app.evaluate((ctx) => - ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + await evaluateMain(() => + app.evaluate((ctx) => + ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] }) + ) ) await use(app) @@ -70,8 +120,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) { @@ -88,10 +140,7 @@ export const test = base.extend<{}, ElectronFixtures>({ 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 page.addStyleTag({ content: NO_MOTION }) await use(page) }, diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts index 4dad7296..0067533c 100644 --- a/e2e/fixtures/helpers.ts +++ b/e2e/fixtures/helpers.ts @@ -34,6 +34,28 @@ export async function selectRegisterType(p: Page, name: string): Promise<void> { await p.waitForTimeout(200) } +/** + * Clear the client's register configuration. + * + * `confirms` says whether the dialog is expected, which the caller knows and a + * sample after a fixed wait only guesses: the button asks whenever the mapping + * or the name holds anything, and clears at once when neither does. Both + * answers are asserted, so a configuration that is not in the state the caller + * thinks it is fails here rather than several lines later. + */ +export async function clearClientConfig(p: Page, confirms = true): Promise<void> { + await p.getByTestId('clear-config-btn').click() + const confirm = p.getByTestId('clear-config-confirm-btn') + + if (!confirms) { + await expect(confirm).toHaveCount(0) + return + } + + await confirm.click() + await expect(confirm).toHaveCount(0) +} + /** Locate a specific cell in the MUI DataGrid (client side) */ export function cellLocator(p: Page, rowId: number, field: string): Locator { return p.locator(`.MuiDataGrid-row[data-id="${rowId}"] [data-field="${field}"]`) @@ -840,3 +862,21 @@ export async function splitOutServerWindow(app: ElectronApplication, p: Page): P await serverPage.waitForLoadState('domcontentloaded') return serverPage } + +/** + * Close the add register dialog with Escape, pressed inside it. + * + * MUI 9.3.1 puts the Escape handler on the modal root: `getRootProps` in + * `Modal/useModal.js` hands `onKeyDown` to that element, so the key closes the + * dialog only while focus is inside it. Add & Next disables the button under + * the pointer while the submit is in flight, which blurs it to `body`, and + * `FocusTrap` puts focus back from an interval of 50 ms. Measured with a probe: + * `document.activeElement` is `BODY` immediately after the click and a DIV + * inside the modal 300 ms later. `keyboard.press` between those two lands on + * `body` and the dialog stays open. Pressing on the address field focuses it + * first, which is what the assertion below then holds. + */ +export async function closeAddRegisterModal(p: Page): Promise<void> { + await p.getByTestId('add-reg-address-input').locator('input').press('Escape') + await expect(p.getByTestId('add-reg-address-input')).not.toBeVisible() +} diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index 15a489a2..fc1c00ae 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -1,5 +1,5 @@ -import { _electron as electron } from '@playwright/test' -import { existsSync, mkdtempSync } from 'fs' +import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { createWriteStream, existsSync, mkdirSync, mkdtempSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' @@ -125,3 +125,76 @@ 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 + * 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<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. + reduceMotion(page).catch(() => undefined) + }) + 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..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 } from './launch' +import { launchOptions, evaluateMain, keepOutput } from './launch' export type PresentationFixtures = { electronApp: ElectronApplication @@ -17,9 +17,12 @@ 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 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 +39,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..23b4c4c7 100644 --- a/e2e/specs/01-main/01-home-navigation.spec.ts +++ b/e2e/specs/01-main/01-home-navigation.spec.ts @@ -1,11 +1,18 @@ -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 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/02-input-validation.spec.ts b/e2e/specs/01-main/02-input-validation.spec.ts index f11a628a..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) @@ -276,9 +280,9 @@ test.describe.serial('Input validation — AddRegister modal and client inputs', }) /** - * A comma used to reach the setter, and `Number()` answers `NaN` to it. The - * store wrote that, `JSON.stringify` persisted `null`, and the next start - * refused the connection config and reset it. + * A comma that reaches the setter is a `NaN` from `Number()`. A store that + * writes it persists `null` through `JSON.stringify`, and the next start + * refuses the connection config and resets it. * * They run before the two clamping tests, so those still leave the fields as * they did for the specs after this one. diff --git a/e2e/specs/01-main/03-server-config.spec.ts b/e2e/specs/01-main/03-server-config.spec.ts index a74f88d6..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,7 +10,14 @@ 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 = '' + test('navigate to server view', async ({ mainPage }) => { await navigateToServer(mainPage) }) @@ -403,8 +410,8 @@ test.describe.serial('Server configuration', () => { test('second server has different port', async ({ mainPage }) => { const portInput = mainPage.getByTestId('server-port-input').locator('input') - const port = await portInput.inputValue() - expect(port).not.toBe('502') + secondServerPort = await portInput.inputValue() + expect(secondServerPort).not.toBe('502') }) test('configure server 2', async ({ mainPage }) => { @@ -420,6 +427,26 @@ test.describe.serial('Server configuration', () => { await expect(mainPage.getByTestId('section-holding_registers')).toContainText('(12)') }) + /** + * The store refuses this one before main is called, so there is no + * `backend_message` to report and `PortInput`'s blur puts the old port back. + * It took two servers to see: with one, every refusal comes from main and + * arrives with a message. + */ + test('a port the other server holds is refused with a message', async ({ mainPage }) => { + const portInput = mainPage.getByTestId('server-port-input').locator('input') + await portInput.click() + await portInput.selectText() + await portInput.pressSequentially(secondServerPort) + await portInput.blur() + + await expect(mainPage.locator('.notistack-SnackbarContainer')).toContainText( + `Port ${secondServerPort} is already used by another server`, + { timeout: 5000 } + ) + await expect(portInput).toHaveValue('502') + }) + // ─── Delete server ──────────────────────────────────────────────── test('switch to server 2 and delete it', async ({ mainPage }) => { 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 6d4fe465..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,7 +1,17 @@ -import { test, expect } from '../../fixtures/electron-app' -import { navigateToServer, selectDataType, selectUnitId, addRegister } from '../../fixtures/helpers' +import { test, expect, resetApp } from '../../fixtures/electron-app' +import { + navigateToServer, + selectDataType, + selectUnitId, + addRegister, + closeAddRegisterModal +} 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) @@ -114,8 +124,7 @@ test.describe.serial('AddRegister modal — state management and validation', () // Close modal, and leave it closed: the next test opens it again, and a // backdrop still up swallows that click. - await mainPage.keyboard.press('Escape') - await expect(mainPage.getByTestId('add-reg-address-input')).not.toBeVisible() + await closeAddRegisterModal(mainPage) }) test('Add & Next for UTF-8: string reset, address advances by register length', async ({ @@ -148,8 +157,7 @@ test.describe.serial('AddRegister modal — state management and validation', () // String value reset expect(await stringInput.inputValue()).toBe('') - await mainPage.keyboard.press('Escape') - await mainPage.waitForTimeout(300) + await closeAddRegisterModal(mainPage) }) test('switch to input_registers — verify modal reset between register types', async ({ @@ -345,8 +353,7 @@ test.describe.serial('AddRegister modal — state management and validation', () // Submit should be disabled await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeDisabled() - await mainPage.keyboard.press('Escape') - await mainPage.waitForTimeout(300) + await closeAddRegisterModal(mainPage) // Clean up: remove register at 50 await mainPage.getByTestId('server-edit-reg-holding_registers-50').click() @@ -414,8 +421,7 @@ test.describe.serial('AddRegister modal — state management and validation', () await expect(mainPage.getByTestId('add-reg-address-in-use')).toBeVisible() await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeDisabled() - await mainPage.keyboard.press('Escape') - await mainPage.waitForTimeout(300) + await closeAddRegisterModal(mainPage) // The comment proves which register survived. await mainPage.getByTestId('server-edit-reg-holding_registers-65535').click() diff --git a/e2e/specs/01-main/05-file-io.spec.ts b/e2e/specs/01-main/05-file-io.spec.ts index 0004b62c..9d953852 100644 --- a/e2e/specs/01-main/05-file-io.spec.ts +++ b/e2e/specs/01-main/05-file-io.spec.ts @@ -1,8 +1,9 @@ /* 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' +import { evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const CONFIG_FILES = { @@ -13,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) @@ -154,11 +159,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) @@ -189,9 +196,9 @@ test.describe.serial('File I/O — open, save, clear server and client configs', // ─── Bools that are off are still configuration ─────────────────────── /** - * A coil is added off, so a unit holding nothing else used to be dropped from - * the file: `checkHasConfig` read the value where it should have counted the - * entry, and both the save and the load asked it. + * A coil is added off, and both the save and the load ask `checkHasConfig` + * whether the unit holds anything. Reading the value rather than counting the + * entry drops a unit holding nothing else from the file. */ test('save and reopen a unit whose coils are all off', async ({ electronApp, mainPage }) => { await mainPage.getByTestId('server-clear-btn').click() @@ -202,11 +209,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/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 6a45b857..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 ─────────── @@ -159,9 +163,9 @@ test.describe.serial('Write Operations', () => { await clearData(mainPage) }) - // The value field is milliseconds, and its range used to be stated in - // seconds, so everything it accepted fell before the year 2000 the format - // starts at and went out as the clamp floor. + // The value field is milliseconds. Stated in seconds, its range puts + // everything it accepts before the year 2000 the format starts at, and the + // write goes out as the clamp floor. test('write DATETIME via FC16', async ({ mainPage }) => { await readRegisters(mainPage, '0', '10') await writeRegister(mainPage, 6, '1717245296789', 'fc16', 'DATETIME') diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index 144cacfe..0b79a6ea 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -1,21 +1,21 @@ -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' +import { evaluateMain } from '../../fixtures/launch' let serverPage: Page 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(async ({ electronApp }) => { + test.afterAll(() => { master?.destroy() - await 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 +39,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') }) @@ -70,9 +72,10 @@ test.describe.serial('Split View — Server in separate window', () => { /** * 0 is a port number the way "any" is a name, and `ModbusServer.setPort` - * refuses it. The message used to go to every window, and the server window - * had no listener, so in split view the field snapped back to its old port - * with nothing said in the window the user was looking at. + * refuses it. The message is addressed to the window showing the server, and + * `MessageReceiver` listens in both: unaddressed it was a snackbar in each, + * and the server window returning early instead left the field snapping back + * to its old port with nothing said where the user was looking. */ test('a refused port reports in the window showing the server', async () => { const portInput = serverPage.getByTestId('server-port-input').locator('input') @@ -92,11 +95,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/11-scan-registers.spec.ts b/e2e/specs/01-main/11-scan-registers.spec.ts index 2569e3ab..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 ────────────────────────────────────────────────────────── @@ -347,12 +351,12 @@ test.describe.serial('Scan Registers', () => { // The scan reached something, and stopping stopped it. // - // This used to assert that address 26, the last holding register on the - // server, had not been reached yet. That was a guess about how fast the - // machine is: the scan walks one register at a time with a 5ms pause, so - // 26 of them take a few hundred milliseconds, which is about what the five - // assertions above cost. The fastest runner got there first and the test - // failed for being right. + // Nothing here asserts how far it got. The scan walks one address at a + // time and waits 10 ms per chunk, so the 26 up to the last address this + // server answers for take about a quarter of a second, which is about what + // the five assertions above cost: an assertion that one of them had not + // been reached is a guess about the machine, and the fastest runner gets + // there first. // // What stopping means is that no more rows arrive, so that is what this // waits to see. diff --git a/e2e/specs/01-main/12-scan-unitids.spec.ts b/e2e/specs/01-main/12-scan-unitids.spec.ts index bb4b237d..93664abc 100644 --- a/e2e/specs/01-main/12-scan-unitids.spec.ts +++ b/e2e/specs/01-main/12-scan-unitids.spec.ts @@ -1,14 +1,29 @@ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import { navigateToClient, connectClient, disconnectClient, enableAdvancedMode, - selectRegisterType + selectRegisterType, + cleanServerState, + loadServerConfig } from '../../fixtures/helpers' +import { resolve } from 'path' +import net from 'net' + +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 (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) @@ -203,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 } @@ -277,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 } @@ -319,37 +339,33 @@ test.describe.serial('Scan Unit IDs', () => { expect(count).toBeGreaterThanOrEqual(3) }) - // ─── Fields disabled during scanning + stop mid-scan ───────────── + // ─── 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) - 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') + await startUnit.fill('200') 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 count.fill('100') 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() + // 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. 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="255"]')).toBeVisible({ timeout: 60000 }) - // 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 } + { timeout: 60000 } ) - - // 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() }) // ─── Address base toggle ────────────────────────────────────────── @@ -386,6 +402,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() 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 7caba7f2..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,15 +1,17 @@ /* 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, enableReadConfiguration, disableReadConfiguration, expectCellContains, - expectCell + expectCell, + clearClientConfig } 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 = { @@ -18,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) @@ -81,11 +87,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) @@ -110,7 +118,7 @@ test.describe.serial('Client config I/O — view, save, clear, load', () => { }) test('clear client config — verify mappings removed', async ({ mainPage }) => { - await mainPage.getByTestId('clear-config-btn').click() + await clearClientConfig(mainPage) // After clearing, read-config toggle should be disabled (no mappings) const readConfigBtn = mainPage.getByTestId('reg-read-config-btn') diff --git a/e2e/specs/01-main/15-client-toolbar.spec.ts b/e2e/specs/01-main/15-client-toolbar.spec.ts index e637a923..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, @@ -13,12 +13,19 @@ import { loadServerConfig, expectCell, expectColumn, - openColumnMenu + openColumnMenu, + 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.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) test.describe.serial('Client toolbar — display options and utilities', () => { // ─── Setup ────────────────────────────────────────────────────────── @@ -28,8 +35,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') }) @@ -41,7 +49,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') @@ -73,7 +81,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"]') @@ -283,7 +291,20 @@ test.describe.serial('Client toolbar — display options and utilities', () => { for (const regType of ['Holding Registers', 'Input Registers']) { test(`[${regType}] read config: clear config → button disabled`, async ({ mainPage }) => { await selectRegisterType(mainPage, regType) - await mainPage.getByTestId('clear-config-btn').click() + + // 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' + + if (asks) { + await mainPage.getByTestId('clear-config-btn').click() + await mainPage.getByTestId('clear-config-cancel-btn').click() + await expect(mainPage.getByTestId('clear-config-confirm-btn')).toHaveCount(0) + } + + await clearClientConfig(mainPage, asks) const btn = mainPage.getByTestId('reg-read-config-btn') await expect(btn).toBeDisabled() 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 c28cea24..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, @@ -19,7 +19,8 @@ import { navigateToServer, expectCellContains, expectCell, - sectionCount + sectionCount, + clearClientConfig } from '../../fixtures/helpers' import { HUAWEI_UNIT_0 } from '../../fixtures/test-data' import { resolve } from 'path' @@ -27,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) @@ -314,7 +319,7 @@ test.describe.serial('Huawei Smart Logger — comprehensive integration test', ( // ─── Cleanup ─────────────────────────────────────────────────────── test('clear client config', async ({ mainPage }) => { - await mainPage.getByTestId('clear-config-btn').click() + await clearClientConfig(mainPage) const readConfigBtn = mainPage.getByTestId('reg-read-config-btn') await expect(readConfigBtn).toBeDisabled() 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..038dc145 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, @@ -14,13 +14,14 @@ import { disableReadConfiguration, cleanServerState, loadServerConfig, - clearData, loadDummyData, - expectCell + expectCell, + scrollToRow } from '../../fixtures/helpers' 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') @@ -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) @@ -179,6 +183,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) ────────────────────────────────── @@ -230,16 +238,22 @@ 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) 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 }) => { @@ -339,11 +353,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/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 786e1571..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, @@ -11,10 +11,15 @@ 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') +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) @@ -125,11 +130,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) @@ -197,9 +204,9 @@ test.describe.serial('Bitmap settings — color, invert & config persistence', ( const grew = expandedHeight - collapsedHeight expect(grew).toBeGreaterThan(0) - // The scroller's own model, not the rendered row. It came from a fixed - // rowHeight that the panel's height reached in no way, so this used to be - // zero and the last row ended below the furthest the grid would scroll. + // The scroller's own model, not the rendered row. A fixed rowHeight the + // panel's height reaches in no way leaves this at zero, and the last row + // ends below the furthest the grid will scroll. expect(expandedScroll - collapsedScroll).toBeGreaterThanOrEqual(grew) // An address stops being a bitmap while its panel is open: the type cell is 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 f8161d0c..2eeba2ea 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, @@ -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' @@ -45,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) @@ -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) }) diff --git a/e2e/specs/01-main/26-server-layout.spec.ts b/e2e/specs/01-main/26-server-layout.spec.ts index 8ca5c2fb..e576f9d0 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 { test, expect, resetApp } 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 @@ -13,6 +14,10 @@ import { navigateToServer, addBool } from '../../fixtures/helpers' 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) @@ -29,14 +34,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 +56,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/27-serial-line-settings.spec.ts b/e2e/specs/01-main/27-serial-line-settings.spec.ts index 972faa06..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, @@ -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<void> => { + 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 }) } @@ -125,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') @@ -208,8 +221,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 ab7ffcc1..f41ad0a7 100644 --- a/e2e/specs/01-main/28-connect-cancel.spec.ts +++ b/e2e/specs/01-main/28-connect-cancel.spec.ts @@ -2,14 +2,16 @@ * Cancelling a connect * * The Connect button is a Cancel for as long as the state is 'connecting', and - * it sends `disconnect`. On RTU the cancelled connect used to finish its open - * anyway and report "Connected over Modbus RTU" over the cancel, leaving the - * button on Disconnect and the port held. + * 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. 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. */ -import { test, expect } from '../../fixtures/electron-app' +import { test, expect, resetApp } from '../../fixtures/electron-app' import type { Page } from '@playwright/test' import { cleanServerState, @@ -20,30 +22,26 @@ 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' -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(' | ') + return [...seen] } +test.beforeAll(async ({ electronApp, mainPage }) => { + await resetApp(electronApp, mainPage) +}) + test.describe.serial('Cancelling a connect', () => { test.skip(!hasSocat, 'socat not available') @@ -102,8 +100,7 @@ test.describe.serial('Cancelling a 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') }) 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..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 } 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' @@ -18,12 +19,14 @@ 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 -// 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) }) @@ -46,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/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 60f9026e..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 @@ -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 @@ -32,14 +36,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 electronApp.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows() - .filter((w) => w.getTitle() === 'Server') - .forEach((w) => w.close()) - }) - }) - test('clean server state', async ({ mainPage }) => { await cleanServerState(mainPage) }) @@ -57,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/e2e/specs/01-main/32-undo-redo.spec.ts b/e2e/specs/01-main/32-undo-redo.spec.ts new file mode 100644 index 00000000..47fa6440 --- /dev/null +++ b/e2e/specs/01-main/32-undo-redo.spec.ts @@ -0,0 +1,204 @@ +/** + * Undo and redo + * + * A focused text field keeps Cmd+Z and Cmd+Shift+Z: the field's own history + * runs, and what it puts back goes through the setter like a keystroke, so the + * 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, resetApp } from '../../fixtures/electron-app' +import type { Locator, Page } from '@playwright/test' +import { + addBool, + cleanServerState, + navigateToClient, + navigateToHome, + navigateToServer, + 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 }> => + p.evaluate(() => { + const saved = JSON.parse(localStorage.getItem('client.zustand') ?? '{}') + const connectionConfig = saved.state?.connectionConfig + return { + host: connectionConfig?.tcp?.host, + unitId: connectionConfig?.unitId + } + }) + +/** Replaces what the field holds by typing, one key at a time. */ +const typeOver = async (p: Page, input: Locator, text: string): Promise<void> => { + await input.click() + await p.keyboard.press('ControlOrMeta+a') + 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) + await mainPage.getByTestId('protocol-tcp-btn').click() + }) + + test('the host field undoes and redoes what was typed, and the config follows', async ({ + mainPage + }) => { + const input = mainPage.getByTestId('tcp-host-input').locator('input') + await input.fill('127.0.0.1') + await expect.poll(async () => (await savedConnection(mainPage)).host).toBe('127.0.0.1') + + // 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') + await expect(input).toHaveValue('127.0.0.1') + await expect.poll(async () => (await savedConnection(mainPage)).host).toBe('127.0.0.1') + + await mainPage.keyboard.press('ControlOrMeta+Shift+z') + await expect(input).toHaveValue('10.0.0.5') + await expect.poll(async () => (await savedConnection(mainPage)).host).toBe('10.0.0.5') + + await input.fill('127.0.0.1') + }) + + test('the unit id field undoes and redoes what was typed, and the config follows', async ({ + mainPage + }) => { + const input = mainPage.getByTestId('client-unitid-input').locator('input') + await input.fill('1') + await expect.poll(async () => (await savedConnection(mainPage)).unitId).toBe(1) + + await typeOver(mainPage, input, '17') + await expect.poll(async () => (await savedConnection(mainPage)).unitId).toBe(17) + + await mainPage.keyboard.press('ControlOrMeta+z') + await expect(input).toHaveValue('1') + await expect.poll(async () => (await savedConnection(mainPage)).unitId).toBe(1) + + await mainPage.keyboard.press('ControlOrMeta+Shift+z') + await expect(input).toHaveValue('17') + await expect.poll(async () => (await savedConnection(mainPage)).unitId).toBe(17) + + 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<void> => { + 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<boolean> => + 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('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) + 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) + }) +}) + +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 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') + await expect(mainRow).toBeVisible() + await leaveTheField(mainPage) + + await mainPage.keyboard.press('ControlOrMeta+z') + await expect(mainRow).toHaveCount(0) + + await cleanServerState(mainPage) + }) +}) diff --git a/e2e/specs/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index e8200a48..ee60ae25 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -1,14 +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 { keepOutput } from '../../fixtures/electron-app' +import { launchElectron, evaluateMain } from '../../fixtures/launch' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-integration.json') @@ -18,22 +11,22 @@ let app: ElectronApplication let page: Page async function launchApp(clearStorage = true): Promise<void> { - app = await electron.launch(launchOptions()) - // 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) + 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'] }) + ) ) } // 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) { @@ -181,3 +174,57 @@ test.describe.serial('Persistence — a config saved under the former key', () = expect(former).not.toBeNull() }) }) + +// +// A port is stored as the string the field holds, and a hand edit can leave one +// `PortSchema` refuses. `init` then asked main to open the server on it, main +// answered nothing, and the toggle button is labelled with the port it has: the +// server came back as a button with no label and no listener. +// +// Only this suite can see that. The unit test covers what `init` asks main for, +// and what comes back is main's walk for a free port. +test.describe.serial('Persistence — a server whose stored port is no port', () => { + test.afterAll(async () => { + if (app) await app.close() + }) + + test('add a second server, then write a port over it that is none', async () => { + await launchApp(true) + await page.getByTestId('home-server-btn').click() + await expect(page.getByTestId('server-name-input')).toBeVisible({ timeout: 5000 }) + + await page.getByTestId('add-server-btn').click() + await expect(page.locator('[data-testid^="select-server-"]')).toHaveCount(2) + await page.waitForTimeout(500) // let zustand persist + + const stripped = await page.evaluate(() => { + const saved = localStorage.getItem('server.zustand') + if (saved === null) return false + const blob = JSON.parse(saved) + const uuids = Object.keys(blob.state.servers) + const [, second] = uuids + if (second === undefined) return false + blob.state.servers[second].port = 'nope' + localStorage.setItem('server.zustand', JSON.stringify(blob)) + return uuids.length === 2 + }) + expect(stripped).toBe(true) + }) + + test('close app', async () => { + await app.close() + await new Promise((r) => setTimeout(r, 1000)) + }) + + test('reopen and find both servers labelled with a port', async () => { + await launchApp(false) + await page.getByTestId('home-server-btn').click() + await expect(page.getByTestId('server-name-input')).toBeVisible({ timeout: 5000 }) + + const toggles = page.locator('[data-testid^="select-server-"]') + await expect(toggles).toHaveCount(2) + + const labels = await toggles.allInnerTexts() + expect(labels.every((label) => /^\d+$/.test(label))).toBe(true) + }) +}) diff --git a/e2e/specs/02-standalone/02-macos-dock.spec.ts b/e2e/specs/02-standalone/02-macos-dock.spec.ts index dff32cd0..1755bf87 100644 --- a/e2e/specs/02-standalone/02-macos-dock.spec.ts +++ b/e2e/specs/02-standalone/02-macos-dock.spec.ts @@ -6,8 +6,7 @@ import { type Page } from '@playwright/test' import net from 'net' -import { keepOutput } from '../../fixtures/electron-app' -import { launchOptions, ownProfileDir } from '../../fixtures/launch' +import { launchElectron, launchOptions, ownProfileDir, evaluateMain } from '../../fixtures/launch' import { connectClient, navigateToClient, navigateToServer } from '../../fixtures/helpers' /** @@ -74,7 +73,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) + ) } /** @@ -98,8 +99,7 @@ test.describe.serial('macOS dock — the app outlives its windows', () => { test.beforeAll(async () => { await startDevice() profile = ownProfileDir() - app = await electron.launch(launchOptions(profile)) - keepOutput(app) + app = await launchElectron(profile) page = await waitForWindow() await page.waitForTimeout(500) }) @@ -133,8 +133,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 5f5e1761..335fbbcd 100644 --- a/e2e/specs/02-standalone/03-split-view-persistence.spec.ts +++ b/e2e/specs/02-standalone/03-split-view-persistence.spec.ts @@ -1,18 +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 { keepOutput } from '../../fixtures/electron-app' +import { launchElectron, evaluateMain } from '../../fixtures/launch' let app: ElectronApplication let page: Page @@ -21,18 +14,21 @@ async function launchApp(clearStorage: boolean): Promise<void> { // 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()) - keepOutput(app) + 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) { @@ -92,11 +88,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/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/98-privileged-port/01-privileged-port-modal.spec.ts b/e2e/specs/98-privileged-port/01-privileged-port-modal.spec.ts index b7977426..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 @@ -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, evaluateMain } from '../../fixtures/launch' import { navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { readFileSync } from 'fs' @@ -36,18 +30,22 @@ function kernelFloor(): number { } async function launchApp(clearStorage = true): Promise<void> { - app = await electron.launch(launchOptions()) + 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)) @@ -171,8 +169,8 @@ test.describe.serial('Privileged port modal (manual, Linux only)', () => { /** * Splitting from Home is the path where the main window never shows the * server view at all, so the window that pops out is the only one that can - * ask. The check used to return on `isServerWindow`, which left it unasked in - * both windows. + * ask. A check that returns on `isServerWindow` leaves it unasked in both + * windows. */ test('the split-out server window asks', async () => { await closeApp() 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 = '' diff --git a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts index f6ad1234..f4bb0685 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, evaluateMain } from '../../fixtures/launch' import { findArduinoPort, selectComPort } from '../../fixtures/arduino-port' @@ -37,17 +31,21 @@ let page: Page let arduinoPort = '' async function launchApp(clearStorage = true): Promise<void> { - app = await electron.launch(launchOptions()) + 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) { 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/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 }) diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts index 0667faf8..8ce491d4 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' @@ -55,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<string> => { + const names = new Set<string>() + 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) @@ -65,8 +79,128 @@ 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, `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<string>; inputComponent: Set<string> } => { + const slots = new Set<string>() + const inputComponent = new Set<string>() + + 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('<Grid slots={{ footer: CustomFooter, row: BitMapRow }} />').slots).toEqual([ + 'BitMapRow', + 'CustomFooter' + ]) + }) + + it('reads inputComponent as a nested property, past its casts', () => { + expect( + read( + '<Field slotProps={{ input: { inputComponent: PortInput as unknown as ElementType } }} />' + ).inputComponent + ).toEqual(['PortInput']) + }) + + it('reads inputComponent handed straight to an element', () => { + expect(read('<InputBase inputComponent={PortInput} />').inputComponent).toEqual(['PortInput']) + }) + + it('reads nothing out of a slot whose value is not a name', () => { + expect( + read('<Grid slots={{ footer: () => null }} slotProps={{ footer: { sx: {} } }} />') + ).toEqual({ + slots: [], + inputComponent: [] + }) + }) +}) describe('every component is wrapped in meme', () => { const files = sourceFiles(rendererRoot) @@ -74,6 +208,11 @@ describe('every component is wrapped in meme', () => { const renderedAsJsx = new Set<string>() const defaultExported = new Set<string>() + // 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<string>() + const handedToInputComponent = new Set<string>() for (const { source } of parsed) { eachNode(source, (node) => { @@ -93,6 +232,9 @@ describe('every component is wrapped in meme', () => { defaultExported.add(node.name.text) } }) + 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. */ @@ -156,7 +298,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 +315,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>): 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([]) @@ -223,8 +392,16 @@ describe('shared does not import from main', () => { // ─── One store selector per field ──────────────────────────────────────────── // // A selector returning an object literal is a whole-store subscription wearing a -// selector's clothes: a fresh object every render, so every flush re-renders. -// This is why the grid draws two thousand rows without useShallow. +// selector's clothes, and it costs more than the flushes: zustand compares the +// selector's answer with `Object.is` under `useSyncExternalStore`, so a fresh +// reference is a new snapshot on the render that follows and React loops. +// Measured at zustand 5.0.11, on an object literal and on `Object.keys`: +// `Maximum update depth exceeded`. This is why the grid draws two thousand rows +// without useShallow. +// +// The three object spellings below, the bare call and `(z) => z` are what an +// AST can read. A call that builds its answer, `Object.keys(z.servers)`, is the +// same defect and is not, so CONTRIBUTING names it as a reviewer's question. describe('one store selector per field', () => { const files = sourceFiles(rendererRoot) @@ -625,18 +802,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<string> => { - const names = new Set<string>() - 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<string> => literalsIn(spec, declaration) /** The member names of an interface declared in `ipc.ts`. */ const membersOf = (declaration: string): Set<string> => { @@ -653,7 +819,8 @@ describe('the lists that name a channel agree', () => { const channels = literalsOf('IPC_CHANNELS') const specced = membersOf('IpcHandlerSpec') - const events = literalsOf('IPC_EVENTS') + // 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') /** Every channel `initIpc` registered a handler for. */ @@ -696,6 +863,101 @@ 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. + // + // `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, 'isServerWindow']).size).toBe(channels.size + 1) + }) +}) + +// +// ─── 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`. + * + * `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<string> => { + const found = new Set<string>() + for (const file of sourceFiles(join(repoRoot, root))) { + const source = parse(file) + eachNode(source, (node) => { + if (!ts.isCallExpression(node)) return + 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) + }) + } + return found + } + + const toRenderer = literalsIn(spec, 'EVENTS_TO_RENDERER') + const toMain = literalsIn(spec, 'EVENTS_TO_MAIN') + + 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') + + const missing = (events: Set<string>, found: Set<string>): 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([]) + }) }) // @@ -845,10 +1107,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/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts index c6beb6f2..20e533de 100644 --- a/src/main/__tests__/ipc.test.ts +++ b/src/main/__tests__/ipc.test.ts @@ -9,10 +9,25 @@ 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, CreateServerParamsSchema, + MAX_WRITE_BITS, PortSchema, PrivilegedPortFixModeSchema, RemoveRegisterParamsSchema, @@ -287,6 +302,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<boolean>(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, @@ -563,9 +593,6 @@ describe('each guarded channel got its own schema', () => { * `undefined` is the one payload every schema here refuses, so a channel that * answers with a message has a schema and one that stays silent has none. A * string would not do it: `ServerUuidSchema` takes one. - * - * The list above was written by hand and covered fourteen of the twenty, so a - * channel added with a schema did not join it. */ const guardedChannels = async (): Promise<string[]> => { const { sent } = start() @@ -586,19 +613,49 @@ 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. + // + // 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', + async (channel) => { + start() + + expect(await invoke(channel, validPayloads[channel])).toBe(true) + } + ) + it.each(Object.keys(validPayloads))('lets a valid %s payload through', async (channel) => { const { sent } = start() await invoke(channel, validPayloads[channel]) expect(sent.map(({ message }) => message.error)).toEqual([]) }) - // `undefined` is the payload every schema here refuses. A string used to be, - // until `delete_server` and `reset_server` took one. + 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. + // + // 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/main/__tests__/state.test.ts b/src/main/__tests__/state.test.ts index 3549a9ad..9068a8fc 100644 --- a/src/main/__tests__/state.test.ts +++ b/src/main/__tests__/state.test.ts @@ -90,9 +90,9 @@ describe('AppState', () => { }) // What a payload carrying an explicit `undefined` does. Zod's deepPartial - // keeps the key, structured clone carries it over IPC, and deepmerge used to - // copy it over the stored value. The last one separates dropping the key from - // dropping the whole update. + // keeps the key and structured clone carries it over IPC, so the merge is + // what has to drop it rather than copy it over the stored value. The last + // case separates dropping the key from dropping the whole update. describe('updateConnectionConfig with an undefined field', () => { it('keeps the stored host', () => { state.updateConnectionConfig({ tcp: { host: '10.0.0.1' } }) diff --git a/src/main/index.ts b/src/main/index.ts index a9c6bd4b..f546d4ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,6 +145,14 @@ onIpcEvent('open_server_window', () => { backgroundColor: '#181818' }) + // 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' } + }) + // 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']) { @@ -166,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, @@ -243,5 +274,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. diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e3888c49..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, @@ -51,6 +51,25 @@ type PayloadSchema<C extends keyof IpcHandlerMap> = ? ZodType<IpcHandlerMap[C]['args'][0]> : never +/** + * 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<C>` 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. + * + * `[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 +}[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<IpcHandlerMap[C]['return']> ? C : never @@ -115,7 +134,7 @@ const CLIENT_CHANNELS: readonly RefusableChannel[] = [ */ export const createIpcHandle = (windows: Windows) => - <C extends keyof IpcHandlerMap>( + <C extends OneArgumentChannel>( channel: C, listener: IpcListener<C>, schema?: PayloadSchema<C> @@ -194,7 +213,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 ) @@ -295,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<E extends IpcEvent>( +export function onIpcEvent<E extends EventToMain>( event: E, listener: (event: IpcMainEvent, ...args: IpcEventPayloadMap[E]) => void ): void { diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 7d2f375b..95427c92 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { Protocol, RawTransaction } from '@shared' +import type { Protocol, RawTransaction, WriteParameters } from '@shared' import type { Windows } from '../../windows' import { AppState } from '../../state' @@ -99,10 +99,10 @@ const createMockModbusRTU = () => ({ writeFC15: vi.fn(), writeFC16: vi.fn(), _transactions: {} as Record<string, unknown>, - // 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 } @@ -763,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() @@ -775,6 +833,65 @@ describe('ModbusClient', () => { expect(messages.some((m) => m[1].message.includes('reconnecting'))).toBe(true) }) + /** + * The second between a reconnect and the resume it schedules. + * + * The resume goes through `startPolling`, which refuses while anything + * owns the client and says so. A user pressing Read in that second got a + * warning about a poll they never asked for, and the resume cleared its + * own memory on the next line, so nothing tried again. + */ + describe('the poll a reconnect resumes', () => { + const dropAndReconnect = async (): Promise<void> => { + mockModbusRTU.isOpen = false + fireClientEvent('close') + await vi.advanceTimersByTimeAsync(3500) + } + + it('is resumed when the client is idle', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + client.startPolling() + await vi.advanceTimersByTimeAsync(100) + + await dropAndReconnect() + await vi.advanceTimersByTimeAsync(1100) + + expect(client.state.polling).toBe(true) + client.stopPolling() + }) + + it('says nothing and stays owed while a read holds the client', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + client.startPolling() + await vi.advanceTimersByTimeAsync(100) + + // The reconnect lands at 3000 ms and schedules the resume 1000 after + // it, so this stops 900 ms short of the resume with the poll already + // ended by the drop. + mockModbusRTU.isOpen = false + fireClientEvent('close') + await vi.advanceTimersByTimeAsync(3100) + expect(client.state.polling).toBe(false) + + const gated = gateTheReads() + void client.read() + await vi.advanceTimersByTimeAsync(0) + expect(client.state.reading).toBe(true) + + await vi.advanceTimersByTimeAsync(1000) + + expect(client.state.polling).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).not.toContain( + 'Cannot poll during another read' + ) + + gated.resolveAll() + await vi.advanceTimersByTimeAsync(0) + }) + }) + it('gives up after max consecutive reconnects', async () => { await connectClient() @@ -794,6 +911,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() @@ -1026,6 +1234,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], @@ -1271,6 +1504,55 @@ describe('ModbusClient', () => { expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(100, 1) }) + // 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 }) + setupHoldingRegisterReadMock([100]) + + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledWith(65500, 36) + }) + + 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({ + 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, 4) + }) + // 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. @@ -1450,6 +1732,151 @@ describe('ModbusClient', () => { }) }) + /** + * A reply carries no unit id, no register type and no address of its own, so + * what it describes is whatever main asked for. These hold a read open, move + * one of those four under it, and then let it answer. + */ + describe('a read answers for what it was addressed to', () => { + /** + * A gated read that files its transaction, which `gateTheReads` does not. + * A logged transaction is what says the request went out and only its + * answer was dropped. + */ + const gateTheReadsFilingTransactions = () => { + const gates: Array<() => void> = [] + mockModbusRTU.readHoldingRegisters.mockImplementation( + (address: number, length: number) => + new Promise((resolve) => { + fileTransaction(createMockTransaction(address, length)) + gates.push(() => resolve({ data: [100], buffer: Buffer.from([0x00, 0x64]) })) + }) + ) + return { + resolveAll: (): void => { + gates.forEach((gate) => gate()) + } + } + } + it('drops the data when the unit id moved while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.updateConnectionConfig({ unitId: 3 }) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + expect(getWindowCalls('address_groups')).toEqual([]) + }) + + it('drops the data when the register type moved while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.updateRegisterConfig({ type: 'coils' }) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + }) + + it('drops the data when the address moved while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.updateRegisterConfig({ address: 40 }) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + }) + + it('drops the data when the length moved while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.updateRegisterConfig({ length: 3 }) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + }) + + it('drops the data when the mapping arrived while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.setRegisterMapping({ + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { 0: { dataType: 'uint16' } } + }) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + }) + + it('drops the data when read configuration was switched while it was on the wire', async () => { + await connectClient() + const gate = gateTheReadsFilingTransactions() + + const read = client.read() + appState.setReadConfiguration(true) + gate.resolveAll() + await read + + expect(getWindowCalls('transaction').length).toBe(1) + expect(getWindowCalls('register_data')).toEqual([]) + }) + + // The poll rate is what the next read waits, and the timeout is how long a + // request may take. Neither changes what this read asked the device, so + // neither may cost the user the answer. + it('sends the data when a field the read did not ask about moved', async () => { + await connectClient() + const gate = gateTheReads() + + const read = client.read() + appState.updateRegisterConfig({ pollRate: 2000, timeout: 4000 }) + gate.resolveAll() + await read + + expect(getWindowCalls('register_data').length).toBe(1) + }) + + // The drop is a `return` inside the read the chain is awaiting, so the + // chain has to arm the next one anyway. + it('polls on after the read it dropped', async () => { + await connectClient() + const gate = gateTheReads() + + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + appState.updateConnectionConfig({ unitId: 3 }) + gate.resolveAll() + await vi.advanceTimersByTimeAsync(1100) + gate.resolveAll() + await vi.advanceTimersByTimeAsync(0) + client.stopPolling() + + expect(mockModbusRTU.readHoldingRegisters.mock.calls.length).toBe(2) + expect(getWindowCalls('register_data').length).toBe(1) + }) + }) + describe('_logTransaction', () => { it('formats and sends transaction data after read', async () => { await connectClient() @@ -1626,21 +2053,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 @@ -1911,11 +2336,88 @@ describe('ModbusClient', () => { }) }) + /** + * `setTimeout` is client state, not request state. + * + * `_read` calls it with `registerConfig.timeout` and both scans call it + * with their own, and the write path called neither, so FC5, FC6, FC15 and + * FC16 went out under whatever ran last: the scan field's floor is 100 ms + * where the toolbar's is 1000, and a connect opens on 3000. + * + * Asserted against the order the write went out in rather than against the + * last call, because a write ends on a read back and that read calls + * `setTimeout` with the right value on its way: both tests here passed + * with the write setting nothing at all. + */ + describe('the timeout a write gives the device', () => { + /** + * The timeout the client was left on when `writeFC6` was called. + * + * `invocationCallOrder` is one counter across every mock of a run, so the + * `setTimeout` calls before the write are the ones that decided what it + * went out under, and the last of those is the one in force. + */ + const timeoutWhenWritten = (): unknown => { + const [writeOrder] = mockModbusRTU.writeFC6.mock.invocationCallOrder + if (writeOrder === undefined) throw new Error('writeFC6 was never called') + + const calls: unknown[] = mockModbusRTU.setTimeout.mock.calls.map( + (call: unknown[]) => call[0] + ) + const order: number[] = mockModbusRTU.setTimeout.mock.invocationCallOrder + return calls.filter((_, index) => (order[index] ?? 0) < writeOrder).at(-1) + } + + const writeOne = async (): Promise<void> => { + mockModbusRTU.writeFC6.mockImplementation( + (_uid: number, _addr: number, _val: number, cb: (err: null) => void) => cb(null) + ) + await client.write({ + address: 0, + type: 'holding_registers', + value: 1, + dataType: 'uint16', + single: true + }) + } + + it('is the register config timeout after a scan set its own', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockResolvedValue({ + data: [0], + buffer: Buffer.alloc(2) + }) + + const scan = client.scanRegisters({ addressRange: [0, 1], length: 1, timeout: 100 }) + await vi.advanceTimersByTimeAsync(1000) + await scan + expect(mockModbusRTU.setTimeout).toHaveBeenLastCalledWith(100) + + await writeOne() + + expect(timeoutWhenWritten()).toBe(appState.registerConfig.timeout) + }) + + // `_connect` opens on 3000, which is the state a write arriving before + // any read finds. + it('is the register config timeout on the first write after a connect', async () => { + await connectClient() + expect(mockModbusRTU.setTimeout).toHaveBeenLastCalledWith(3000) + + await writeOne() + + expect(timeoutWhenWritten()).toBe(appState.registerConfig.timeout) + }) + }) + // What `_logTransaction` takes is the last entry in `_transactions`, and it // deletes the entry it logs. So a write that files no transaction of its // own logs somebody else's and takes it out of the table, and `_onReceive` // drops a response whose entry is gone. describe('the transaction a write logs', () => { + let gatedReads: ReturnType<typeof gateTheReads> | undefined + let writeInFlight: Promise<void> | undefined + /** * A read in flight, which is the entry the table holds when a write * arrives. The read is gated so it stays in flight for the whole test: @@ -1923,15 +2425,35 @@ describe('ModbusClient', () => { * own. */ const aReadInFlight = () => { - gateTheReads() + gatedReads = gateTheReads() fileTransaction() } + /** + * A write, run up to the read back it ends on. + * + * `write` awaits that read back and holds the client until it answers, + * and the gate above is what it would answer through, so the write is + * still running when this returns. What the write logs it has logged by + * then. `afterEach` lets the read answer and waits for the write. + */ + const writeUpToTheReadBack = async (parameters: WriteParameters): Promise<void> => { + writeInFlight = client.write(parameters) + await vi.advanceTimersByTimeAsync(0) + } + + afterEach(async () => { + gatedReads?.resolveAll() + await writeInFlight + gatedReads = undefined + writeInFlight = undefined + }) + it('logs nothing when the coil list is empty', async () => { await connectClient() aReadInFlight() - await client.write({ address: 5, type: 'coils', value: [], single: true }) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [], single: true }) expect(getWindowCalls('transaction')).toHaveLength(0) expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) @@ -1943,7 +2465,7 @@ describe('ModbusClient', () => { writeCoilsLikeTheLibrary(null) - await client.write({ address: 5, type: 'coils', value: [], single: false }) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [], single: false }) expect(mockModbusRTU.writeFC15).not.toHaveBeenCalled() const messages = getWindowCalls('backend_message') @@ -1956,7 +2478,7 @@ describe('ModbusClient', () => { await connectClient() aReadInFlight() - await client.write({ + await writeUpToTheReadBack({ address: 0, type: 'holding_registers', value: 70000, @@ -1972,7 +2494,7 @@ describe('ModbusClient', () => { await connectClient() aReadInFlight() - await client.write({ + await writeUpToTheReadBack({ address: 0, type: 'holding_registers', value: 100, @@ -2015,7 +2537,7 @@ describe('ModbusClient', () => { aReadInFlight() writeCoilsLikeTheLibrary(null) - await client.write({ address: 5, type: 'coils', value: [true], single: true }) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [true], single: true }) expect(mockModbusRTU.writeFC5).not.toHaveBeenCalled() const messages = getWindowCalls('backend_message') @@ -2032,7 +2554,7 @@ describe('ModbusClient', () => { aReadInFlight() writeCoilsLikeTheLibrary(null) - await client.write({ address: 5, type: 'coils', value: [true], single: true }) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [true], single: true }) const transactions = getWindowCalls('transaction') expect(transactions).toHaveLength(1) @@ -2041,49 +2563,36 @@ describe('ModbusClient', () => { expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) }) - // Nothing refuses a poll the user starts while a write is on the wire, - // so two requests are filed and only one of them is this write's. - it('logs its own while a read it did not start is in flight', async () => { + /** + * A poll the user starts while a write is on the wire. + * + * It was not refused, so both filed a transaction and the write logged + * the entry the poll's read was still waiting on. On a serial port both + * file under key 1, where the delete takes the only entry there is and + * `_onReceive` drops the answer into nothing. + * + * The poll is refused now, so the only entry the table holds after the + * write is the read this test put there, and the write logs its own. + */ + it('logs its own, and the poll that would have filed a second is refused', async () => { await connectClient() - const finishers: Array<() => void> = [] - mockModbusRTU.writeFC5.mockImplementation( - (_unitId: number, address: number, _value: boolean, callback: (err: null) => void) => { - fileTransaction({ ...createMockTransaction(address), nextCode: 5 }) - finishers.push(() => callback(null)) - } - ) - // The poll's read files its transaction and then waits for an answer - // that never comes, which is where the write finds it. - mockModbusRTU.readHoldingRegisters.mockImplementation( - (address: number) => - new Promise(() => { - fileTransaction(createMockTransaction(address)) - }) - ) + aReadInFlight() + writeCoilsLikeTheLibrary(null) - const writePromise = client.write({ - address: 5, - type: 'coils', - value: [true], - single: true - }) - await vi.advanceTimersByTimeAsync(0) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [true], single: true }) client.startPolling() await vi.advanceTimersByTimeAsync(0) - const [finishWrite] = finishers - if (!finishWrite) throw new Error('writeFC5 was never called') - finishWrite() - await writePromise + expect(client.state.polling).toBe(false) + const messages = getWindowCalls('backend_message').map((message) => message[1].message) + expect(messages).toContain('Cannot poll during another write') const transactions = getWindowCalls('transaction') expect(transactions).toHaveLength(1) - expect(transactions[0]?.[1].id).toContain('1__') + expect(transactions[0]?.[1].id).toContain('2__') expect(transactions[0]?.[1].code).toBe(5) // The read's entry, which `_onReceive` delivers its answer into. - expect(Object.keys(mockModbusRTU._transactions)).toEqual(['2']) - - client.stopPolling() + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) }) it('logs the transaction of a write the device refused', async () => { @@ -2091,7 +2600,7 @@ describe('ModbusClient', () => { aReadInFlight() writeCoilsLikeTheLibrary(new Error('Modbus exception 2: Illegal data address')) - await client.write({ address: 5, type: 'coils', value: [true], single: true }) + await writeUpToTheReadBack({ address: 5, type: 'coils', value: [true], single: true }) const transactions = getWindowCalls('transaction') expect(transactions).toHaveLength(1) @@ -2141,9 +2650,18 @@ describe('ModbusClient', () => { client.stopScanningRegisters() }) - it('skips the read after a write when a scan starts during it', async () => { + /** + * A scan the user starts while a write is on the wire. + * + * It took the client, so the write's read back found a loop reading and + * skipped itself, and the scan's own requests went out under a write that + * had not answered. The scan is refused now, so the write reads back what + * it wrote, which is the whole point of holding the client to the end of + * it. + */ + it('refuses a scan started while a write is on the wire, and reads back', async () => { await connectClient() - gateTheReads() + const gated = gateTheReads() const finishers: Array<() => void> = [] mockModbusRTU.writeFC5.mockImplementation( (_uid: number, _addr: number, _val: boolean, cb: (err: null) => void) => { @@ -2154,26 +2672,209 @@ describe('ModbusClient', () => { const writePromise = client.write({ address: 0, type: 'coils', value: [true], single: true }) await vi.advanceTimersByTimeAsync(0) - // The scan owns the client from here, and its own first read is the one - // call that follows. client.scanRegisters({ addressRange: [50, 69], length: 10, timeout: 1000 }) await vi.advanceTimersByTimeAsync(0) - expect(client.state.scanningRegisters).toBe(true) + + expect(client.state.scanningRegisters).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toContain( + 'Cannot scan during another write' + ) + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() const [finishWrite] = finishers if (!finishWrite) throw new Error('writeFC5 was never called') - // The read after a write would be refused and say so, and the user asked - // for neither, so the messages after the write are the ones before it. - const messagesBeforeFinish = getWindowCalls('backend_message').map((m) => m[1].message) finishWrite() + await vi.advanceTimersByTimeAsync(0) + gated.resolveAll() await writePromise - expect(getWindowCalls('backend_message').map((m) => m[1].message)).toEqual( - messagesBeforeFinish - ) expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + }) - client.stopScanningRegisters() + /** + * Who may put a request on the wire, asked of main rather than of a + * control. + * + * `read` and `write` asked; the three loops claimed the client by setting + * their own flag and asking nobody, so a poll or a scan started during any + * of the five put a second master on the bus. A greyed button is not the + * guard: both windows load the same renderer and every client channel + * reaches this class. + * + * A scan is the one caller that does not ask about a poll, because + * `scanUnitIds` and `scanRegisters` stop one rather than being refused by + * one, and that is settled before `stopPolling` so a refused scan leaves + * the poll where it found it. + */ + describe('a loop that finds the client taken', () => { + /** The client in one owned state, and the state left over afterwards. */ + const whileReading = async (): Promise<void> => { + gateTheReads() + void client.read() + await vi.advanceTimersByTimeAsync(0) + expect(client.state.reading).toBe(true) + } + + const whileScanningUnitIds = async (): Promise<void> => { + gateTheReads() + void client.scanUnitIds({ + range: [5, 6], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + await vi.advanceTimersByTimeAsync(0) + expect(client.state.scanningUnitIds).toBe(true) + } + + it('refuses a poll started during a read', async () => { + await connectClient() + await whileReading() + + client.startPolling() + + expect(client.state.polling).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toContain( + 'Cannot poll during another read' + ) + }) + + it('refuses a poll started during a unit id scan', async () => { + await connectClient() + await whileScanningUnitIds() + + client.startPolling() + + expect(client.state.polling).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toContain( + 'Cannot poll during a unit id scan' + ) + client.stopScanningUnitIds() + }) + + it('refuses a register scan started during a unit id scan', async () => { + await connectClient() + await whileScanningUnitIds() + + await client.scanRegisters({ addressRange: [0, 1], length: 1, timeout: 1000 }) + + expect(client.state.scanningRegisters).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toContain( + 'Cannot scan during a unit id scan' + ) + client.stopScanningUnitIds() + }) + + it('refuses a unit id scan started during a read', async () => { + await connectClient() + await whileReading() + + await client.scanUnitIds({ + range: [5, 6], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 1000 + }) + + expect(client.state.scanningUnitIds).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).toContain( + 'Cannot scan during another read' + ) + }) + + // The poll a scan stops, which is the one state a scan does not ask + // about. Refusing it here would have made the scan dialog's Start button + // unusable while polling, where today it stops the poll and runs. + it('takes a register scan started during a poll, and stops the poll', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + client.startPolling() + await vi.advanceTimersByTimeAsync(100) + expect(client.state.polling).toBe(true) + + const scan = client.scanRegisters({ addressRange: [0, 1], length: 1, timeout: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + await scan + + expect(client.state.polling).toBe(false) + expect(getWindowCalls('backend_message').map((m) => m[1].message)).not.toContain( + 'Cannot scan during a poll' + ) + }) + + /** + * The poll's last read, which `stopPolling` does not wait for. + * + * It ends the chain and returns, and a chain inside `_read`'s group loop + * stays there: that loop breaks on the connect state, not on the + * generation. So the scan's requests went out over the poll's, which on + * a serial port is the transaction key 1 collision the whole refusal + * exists to prevent. + */ + it('waits for the poll read still on the wire before it scans', async () => { + await connectClient() + const gated = gateTheReads() + client.startPolling() + await vi.advanceTimersByTimeAsync(0) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + + const scan = client.scanRegisters({ addressRange: [0, 1], length: 1, timeout: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + + // The poll's read has not answered, so the scan has put nothing on the + // client: one call, and it is the poll's. + expect(client.state.polling).toBe(false) + expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalledTimes(1) + + // The scan's own reads answer through a fresh mock, because + // `resolveAll` only resolves the gates that were open when it ran. + setupHoldingRegisterReadMock([100]) + gated.resolveAll() + await vi.advanceTimersByTimeAsync(1000) + await scan + + expect(mockModbusRTU.readHoldingRegisters.mock.calls.length).toBeGreaterThan(1) + }) + + // A poll starting twice is two callers arriving at once rather than a + // mistake, so it stays the silent no-op it was. + it('says nothing when a poll is started while polling', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + client.startPolling() + await vi.advanceTimersByTimeAsync(100) + const before = getWindowCalls('backend_message').length + + client.startPolling() + + expect(client.state.polling).toBe(true) + expect(getWindowCalls('backend_message')).toHaveLength(before) + client.stopPolling() + }) + }) + + // Every `sent: false` branch says why in a snackbar and puts nothing on the + // wire, so the device holds what it held and there is nothing to read back. + it('reads nothing back when the write never reached the wire', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + + await client.write({ + address: 0, + type: 'holding_registers', + value: 100, + dataType: 'utf8', + single: false + }) + + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(getWindowCalls('backend_message').at(-1)?.[1]).toMatchObject({ + message: 'Modbux cannot write a value as UTF-8', + variant: 'warning' + }) + expect(client.state.writing).toBe(false) }) it('triggers auto-read after write when not polling', async () => { @@ -2190,6 +2891,134 @@ describe('ModbusClient', () => { // A read was triggered (readHoldingRegisters was called from the auto-read) expect(mockModbusRTU.readHoldingRegisters).toHaveBeenCalled() }) + + describe('a write while a write is in flight', () => { + /** + * FC6, held open. The callback is what the library answers through, so + * the write is on the wire until a finisher runs. + */ + const gateTheRegisterWrites = () => { + const gates: Array<() => void> = [] + mockModbusRTU.writeFC6.mockImplementation( + (_unitId: number, address: number, _value: number, callback: (error: null) => void) => { + fileTransaction({ ...createMockTransaction(address), nextCode: 6 }) + gates.push(() => callback(null)) + } + ) + return { + resolveAll: (): void => { + gates.forEach((gate) => gate()) + } + } + } + + const aRegisterWrite = { + address: 0, + type: 'holding_registers', + value: 1, + dataType: 'uint16', + single: true + } as const + + it('sends one request and tells the second caller what is running', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + const gate = gateTheRegisterWrites() + + const first = client.write(aRegisterWrite) + await vi.advanceTimersByTimeAsync(0) + await client.write({ ...aRegisterWrite, value: 2 }) + + expect(mockModbusRTU.writeFC6).toHaveBeenCalledTimes(1) + expect(getWindowCalls('backend_message').at(-1)?.[1]).toMatchObject({ + message: 'Cannot write during another write', + variant: 'warning' + }) + + gate.resolveAll() + await first + }) + + it('says a write is running while it runs, and stops saying so', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + const gate = gateTheRegisterWrites() + + const first = client.write(aRegisterWrite) + await vi.advanceTimersByTimeAsync(0) + expect(client.state.writing).toBe(true) + expect(getLastClientState()?.writing).toBe(true) + + gate.resolveAll() + await first + + expect(client.state.writing).toBe(false) + expect(getLastClientState()?.writing).toBe(false) + }) + + it('refuses a read while it runs, which shares the client', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + const gate = gateTheRegisterWrites() + + const first = client.write(aRegisterWrite) + await vi.advanceTimersByTimeAsync(0) + await client.read() + + expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() + expect(getWindowCalls('backend_message').at(-1)?.[1]).toMatchObject({ + message: 'Cannot read during another write', + variant: 'warning' + }) + + gate.resolveAll() + await first + }) + + /** + * The read back is the second half of the write, and the sequence the + * flag covers is write, wait, read back, wait, free. A second write + * arriving between the device's answer and the read back's is what a + * double click on the write cell or a second bit toggle is. + */ + it('is still running while its read back is', async () => { + await connectClient() + const reads = gateTheReads() + const writes = gateTheRegisterWrites() + + const first = client.write(aRegisterWrite) + await vi.advanceTimersByTimeAsync(0) + writes.resolveAll() + await vi.advanceTimersByTimeAsync(0) + + expect(client.state.reading).toBe(true) + expect(client.state.writing).toBe(true) + await client.write({ ...aRegisterWrite, value: 2 }) + expect(mockModbusRTU.writeFC6).toHaveBeenCalledTimes(1) + expect(getWindowCalls('backend_message').at(-1)?.[1]).toMatchObject({ + message: 'Cannot write during another write', + variant: 'warning' + }) + + reads.resolveAll() + await first + expect(client.state.writing).toBe(false) + }) + + it('writes again once the first has answered', async () => { + await connectClient() + setupHoldingRegisterReadMock([100]) + mockModbusRTU.writeFC6.mockImplementation( + (_unitId: number, _address: number, _value: number, callback: (error: null) => void) => + callback(null) + ) + + await client.write(aRegisterWrite) + await client.write({ ...aRegisterWrite, value: 2 }) + + expect(mockModbusRTU.writeFC6).toHaveBeenCalledTimes(2) + }) + }) }) describe('scan unit ids full flow', () => { @@ -2323,9 +3152,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({ @@ -2353,9 +3183,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() @@ -2367,7 +3198,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 @@ -2608,7 +3440,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 +3454,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([]) }) }) @@ -2728,9 +3557,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/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 5621d936..ef46aeb6 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -83,13 +83,13 @@ vi.mock('net', () => ({ } })) +import { ModbusServer } from '../modbusServer' +import { BIND_TIMEOUT_MS } from '../modbusServer/tcp' import { - ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS, - GATEWAY_TARGET_FAILED, - BIND_TIMEOUT_MS -} from '../modbusServer' + GATEWAY_TARGET_FAILED +} from '../modbusServer/vector' import { ServerTCP, ServerSerial } from 'modbus-serial' /** Every handler `createServer` and `startRtuServer` put on their vector. */ @@ -281,8 +281,9 @@ describe('ModbusServer', () => { }) // `undefined` is what the store reads as nothing changed, so the refusal - // reuses the answer a refused payload already has. It used to throw, and - // `createIpcHandle` puts no try around a listener. + // reuses the answer a refused payload already has. A throw would reject the + // invoke rather than answer it, because `createIpcHandle` puts no try + // around a listener. it('refuses a value its data type cannot encode and writes nothing', () => { const answer = server.addRegister({ uuid, @@ -433,7 +434,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 +452,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 +460,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 +479,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) }) @@ -518,9 +519,9 @@ describe('ModbusServer', () => { }) /** - * The byte order used to travel with every add and every sync, which was - * the same field of the same server read again each time. The server holds - * it, and until it is told, a register is big-endian. + * The server holds the byte order rather than taking it with every add and + * every sync, which is the same field of the same server read again each + * time. Until it is told, a register is big-endian. */ it('swaps the words of a 32-bit value once the server is told', () => { server.setEndianness({ uuid, littleEndian: true }) @@ -790,11 +791,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 +809,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 +849,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) } }) @@ -1099,11 +1100,10 @@ describe('ModbusServer', () => { ) }) - // `createRegisters` threw straight out of `addRegister`, and this loop is a - // bare `for`, so one register the encoder refused took every register after - // it in the unit with it and rejected the invoke the renderer's `init` - // awaits. `RegisterParamsSchema` refuses the payload before it gets here - // now, so this is the class answering for its own input. + // This loop is a bare `for`, so a throw out of `addRegister` would take + // every register after it in the unit with it and reject the invoke the + // renderer's `init` awaits. `RegisterParamsSchema` refuses such a payload + // at the boundary, so this is the class answering for its own input. it('keeps the registers after one the encoder refuses', () => { server.syncServerRegisters({ uuid, @@ -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, @@ -1331,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) @@ -1406,10 +1407,57 @@ 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) + }) + + // 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. + 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) }) @@ -1437,10 +1485,65 @@ describe('ModbusServer', () => { expect(vi.mocked(ServerTCP).mock.results[0]?.value.close).not.toHaveBeenCalled() }) - it('emits error when server not found', async () => { + it('says nothing about a uuid that never bound a listener', async () => { await server.deleteServer('non-existent') const messages = getWindowCalls('backend_message') - expect(messages.some((m) => m[1].message.includes('No server found'))).toBe(true) + expect(messages).toEqual([]) + }) + + it('frees the register data, so the uuid hosts no unit afterwards', async () => { + await server.createServer({ uuid, port: 5020 }) + server.addRegister({ + uuid, + unitId, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1234, + min: undefined, + max: undefined, + interval: undefined + } + }) + + await server.deleteServer(uuid) + await server.createServer({ uuid, port: 5020 }) + + const cb = vi.fn() + lastVector(ServerTCP).getHoldingRegister(0, unitIdNumber, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) + }) + + it('frees the register data of a uuid that never bound a listener', async () => { + server.addRegister({ + uuid, + unitId, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1234, + min: undefined, + max: undefined, + interval: undefined + } + }) + + await server.deleteServer(uuid) + await server.createServer({ uuid, port: 5020 }) + + const cb = vi.fn() + lastVector(ServerTCP).getHoldingRegister(0, unitIdNumber, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) }) it('emits error when close fails', async () => { @@ -1680,7 +1783,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) @@ -1721,7 +1824,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) @@ -1733,7 +1836,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' ]) @@ -1748,7 +1851,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' ]) @@ -1794,7 +1897,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) }) @@ -1806,7 +1909,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') @@ -1887,7 +1990,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 () => { @@ -1939,7 +2042,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 () => { @@ -2026,7 +2129,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 +2166,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 @@ -2096,26 +2199,74 @@ describe('ModbusServer', () => { }) }) + /** + * The four maps hold what was written and nothing else, so an address + * inside the range is either an entry or the accessor's fallback. The + * arrays these replaced held 65536 entries and answered the same two + * values, which is what keeps this a refactor. + */ + describe('an address inside the range with nothing in it', () => { + it('answers false for a coil the unit never wrote', () => { + server.setBool({ uuid, unitId, registerType: 'coils', address: 0, state: true }) + const cb = vi.fn() + vector.getCoil(9, 1, cb) + expect(cb).toHaveBeenCalledWith(null, false) + }) + + it('answers 0 for a holding register the unit never wrote', () => { + hostUnit() + const cb = vi.fn() + vector.getHoldingRegister(9, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 0) + }) + + it('answers 0 for an input register the unit never wrote', () => { + hostUnit() + const cb = vi.fn() + vector.getInputRegister(9, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 0) + }) + + it('answers 0 where a register was removed', () => { + hostUnit() + const before = vi.fn() + vector.getHoldingRegister(100, 1, before) + expect(before).toHaveBeenCalledWith(null, 1) + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 100, + dataType: 'uint16' + }) + + const after = vi.fn() + vector.getHoldingRegister(100, 1, after) + expect(after).toHaveBeenCalledWith(null, 0) + }) + }) + 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 +2275,7 @@ describe('ModbusServer', () => { }) describe('getInputRegister', () => { - it('returns input register value', async () => { + it('returns input register value', () => { server.addRegister({ uuid, unitId, @@ -2141,22 +2292,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 +2316,7 @@ describe('ModbusServer', () => { }) describe('getHoldingRegister', () => { - it('returns holding register value', async () => { + it('returns holding register value', () => { server.addRegister({ uuid, unitId, @@ -2182,22 +2333,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 +2357,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 +2378,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 +2388,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 +2406,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 +2426,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 +2441,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 +2451,51 @@ 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. + // + // 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() + 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() + const cb = vi.fn(() => { + throw new Error('the frame could not be written') + }) + expect(() => vector.setRegister(100, 7, unitIdNumber, cb)).toThrow( + 'the frame could not be written' + ) + }) + }) }) // ─── C5: which unit ids the server answers for ──────────────────────────── @@ -2360,7 +2540,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 +2549,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 +2561,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 +2571,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 +2588,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 +2602,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 +2611,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 +2620,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 +2629,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 +2638,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 +2654,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 +2663,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) }) @@ -2499,11 +2679,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/modbusClient.ts b/src/main/modules/modbusClient.ts index 684b352b..9be29710 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -5,23 +5,30 @@ import { BackendMessage, BaseDataType, ClientState, + clientOwner, convertBitData, + configuredReadGroups, convertRegisterData, createRegisters, - groupAddressInfos, + defaultClientState, humanizeSerialError, + isBooleanRegister, + maxReadQuantity, PROTOCOL_LABELS, - RawTransaction, + readLoopOwner, RegisterData, + registersFrom, RegisterType, ScanRegistersParameters, ScanUnitIDParameters, ScanUnitIDResult, - Transaction, WriteParameters } from '@shared' import { Windows } from '../windows' +import * as serialPorts from './modbusClient/serialPorts' +import { TransactionLog } from './modbusClient/transactionLog' import { + NodeStyleCallback, ReadCoilResult, ReadRegisterResult, WriteCoilResult, @@ -29,8 +36,6 @@ import { WriteRegisterResult } from 'modbus-serial/ModbusRTU' import round from 'lodash/round' -import { DateTime } from 'luxon' -import { v4 } from 'uuid' type ReadRegisters = (address: number, length: number) => Promise<RegisterData[]> @@ -62,17 +67,25 @@ 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' -/** Modbus frames read the way a protocol analyser prints them. */ -const toHexString = (bytes: Uint8Array | undefined): string => - bytes === undefined - ? '' - : Array.from(bytes) - .map((byte) => Number(byte).toString(16).toUpperCase().padStart(2, '0')) - .join(' ') - /** * The `removeAllListeners` under a `ModbusRTU`. * @@ -85,19 +98,7 @@ interface ModbusRTUEmitter extends ModbusRTU { removeAllListeners(): void } -/** - * The two internals the transaction log reads. - * - * `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. - */ -interface ModbusRTUInternals extends ModbusRTU { - _transactions: Record<string, RawTransaction | undefined> - _port: { _transactionIdWrite?: number } | undefined -} - -export interface ClientParams { +interface ClientParams { appState: AppState windows: Windows } @@ -107,13 +108,7 @@ export class ModbusClient { private _appState: AppState private _windows: Windows - private _clientState: ClientState = { - connectState: 'disconnected', - polling: false, - scanningUnitIds: false, - scanningRegisters: false, - reading: false - } + private _clientState: ClientState = { ...defaultClientState } private _pollTimeout: NodeJS.Timeout | undefined private _pollGeneration = 0 @@ -129,10 +124,14 @@ export class ModbusClient { private _reconnectWasPolling = false private _reconnectResumePollingTimeout: NodeJS.Timeout | undefined + /** What went out and what came back, read off modbus-serial's own table. */ + private _transactionLog: TransactionLog + constructor({ appState, windows }: ClientParams) { this._client = new ModbusRTU() this._appState = appState this._windows = windows + this._transactionLog = new TransactionLog({ client: (): ModbusRTU => this._client, windows }) this._attachClientHandlers() } @@ -150,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 }) @@ -160,6 +159,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) { @@ -210,9 +213,6 @@ export class ModbusClient { private _sendData = (data: RegisterData[]): void => { this._windows.send('register_data', data, 'main') } - private _sendTransaction = (transaction: Transaction): void => { - this._windows.send('transaction', transaction, 'main') - } private _sendUnitIdResult = (result: ScanUnitIDResult): void => { this._windows.send('scan_unit_id_result', result, 'main') } @@ -221,11 +221,24 @@ export class ModbusClient { this._windows.send('address_groups', groups, 'main') } - private _sendScanProgress = async (): Promise<void> => { + /** + * Counts one step of a scan, and says how far it has got at most every + * 100 ms, and on the last step. + * + * 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 _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)) } // @@ -273,20 +286,25 @@ export class ModbusClient { } /** - * The read loop that owns the client, named, or nothing. + * Whether `verb` may go ahead, saying who has the client when it may not. * - * One request at a time is what this class can promise, and each of the - * three loops puts one on the wire without asking. Before this, `read` - * refused a poll and not a scan, and nothing refused a write: the write - * action cell and the bitmap panel disable on `polling`, which is a button - * rather than an answer. + * The three loops asked nobody and claimed the client by setting their own + * flag, so a poll started during a write put a second request on the wire + * under it. On RTU both file under transaction key 1, where the write's + * `TransactionLog.log` deletes the entry the read is waiting on and that read + * times out. A control being greyed is not this guard: both windows load the + * same renderer and every channel reaches here, so the refusal is main's. */ - private _readLoopOwner = (): string | undefined => { - if (this._clientState.polling) return 'a poll' - if (this._clientState.scanningUnitIds) return 'a unit id scan' - if (this._clientState.scanningRegisters) return 'a register scan' - if (this._clientState.reading) return 'another read' - return undefined + private _requireClient = (verb: string, exceptPolling = false): boolean => { + const owner = clientOwner(this._clientState, { exceptPolling }) + if (!owner) return true + + this._emitMessage({ + message: `Cannot ${verb} during ${owner}`, + variant: 'warning', + error: null + }) + return false } // @@ -305,6 +323,7 @@ export class ModbusClient { error: null }) this._reconnectWasPolling = false + this._reconnectTriggered = false clearTimeout(this._reconnectResumePollingTimeout) this._setDisconnected() return @@ -420,11 +439,18 @@ export class ModbusClient { variant: 'success', error: null }) - // Resume polling + // Resume polling, and stay quiet about a second nobody was watching. + // The user can press Read in the second between the reconnect and + // this, and `startPolling` would then warn about a poll they did not + // ask for and drop the resume on the floor. `clientOwner` is the same + // question `_requireClient` asks, and leaving the memory set is what + // gives the next reconnect something to resume. clearTimeout(this._reconnectResumePollingTimeout) this._reconnectResumePollingTimeout = setTimeout(() => { - if (this._reconnectWasPolling) this.startPolling() + if (!this._reconnectWasPolling) return + if (clientOwner(this._clientState)) return this._reconnectWasPolling = false + this.startPolling() }, 1000) } else { this._emitMessage({ @@ -445,11 +471,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 @@ -524,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 @@ -539,20 +574,28 @@ export class ModbusClient { * One read, and nothing else on the port until it has answered. * * `_read` puts one request per group on the line and waits for each, so two - * of them running at once is two masters on one bus. The read loops own the - * port through `_readLoopOwner`, and this makes a read in flight the fourth - * owner: the state goes out before the first request and comes back after the + * of them running at once is two masters on one bus. `_clientOwner` is who + * holds it, and a read in flight is one of the five: `_readOwningTheClient` + * puts that state out before the first request and takes it back after the * last, and the caller that finds it set is refused the way a caller during a * poll is. The loops do not go through here, so a poll blocks a read without * a read ever blocking a poll. */ public read = async (): Promise<void> => { - const owner = this._readLoopOwner() - if (owner) { - this._emitMessage({ message: `Cannot read during ${owner}`, variant: 'warning', error: null }) - return - } + if (!this._requireClient('read')) return + await this._readOwningTheClient() + } + + /** + * `_read`, with `reading` around it. + * + * `read` asks whether it may and this does the owning, because a write reads + * back what it wrote and that read is the write's rather than a caller's: it + * passed the question once already, and asking again during its own write + * would refuse it. + */ + private _readOwningTheClient = async (): Promise<void> => { this._clientState.reading = true this._sendClientState() try { @@ -566,6 +609,9 @@ export class ModbusClient { private _read = async (): Promise<void> => { if (!this._requireConnected('read', this._clientState.polling)) return + // What this read is addressed to, taken before the first request goes out. + const readGeneration = this._appState.readGeneration + // Set unit id before reading (in case of TCP) const { unitId } = this._appState.connectionConfig this._client.setID(unitId) @@ -575,24 +621,38 @@ export class ModbusClient { const { type, address, length } = this._appState.registerConfig - // Read configuration groups by data type, and a bit type carries none: the - // grid mounts the data type column for input and holding registers alone, - // so a comment is all it writes into a coil. A config file is another - // matter, because `RegisterMappingSchema` uses one object schema for all - // four types, and a data type it puts on a coil address is ignored here the - // way the toolbar button refuses it. - const groupable = type === 'input_registers' || type === 'holding_registers' - const configGroups = - this._appState.readConfiguration && groupable - ? groupAddressInfos(this._appState.registerMapping?.[type]) - : [] - const groups = configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) + // Read configuration groups by data type, and a bit type carries none. + // `configuredReadGroups` is that question, in `@shared` because the + // renderer asks it too: `clearRegisterDataWhenIdle` draws the mapping and + // asks for a read, and an empty answer here is the toolbar's group coming + // back instead of what it drew. + const configGroups = configuredReadGroups( + this._appState.readConfiguration, + type, + this._appState.registerMapping + ) + // 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, + // Per group: `TransactionLog.log` below runs whether the group threw or not, // so an errorMessage that outlives its group logs a clean group as failed. let errorMessage: string | undefined - const transactionIdKey = this._nextTransactionIdKey() + const transactionIdKey = this._transactionLog.nextTransactionIdKey() try { const rows = await this._readers[type](groupAddress, groupLength) rows.forEach((row) => { @@ -601,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 @@ -636,10 +696,19 @@ export class ModbusClient { }) } } - this._logTransaction(transactionIdKey, errorMessage) + this._transactionLog.log(transactionIdKey, errorMessage) if (this._clientState.connectState !== 'connected') break } + // A reply describes the unit id, type, address and length the requests + // went out under, and carries none of them. `register_data` replaces the + // grid with what arrives, and `clearRegisterDataWhenIdle` has emptied it or + // drawn the new mapping by then, so the old unit's values would land in the + // rows drawn for the new one. The renderer cannot tell the two apart: only + // main knows what its read asked. The transactions above are logged either + // way, because they happened. + if (this._appState.readGeneration !== readGeneration) return + if (data.length > 0) { // Send the groups so we can slice the utf8 string correctly. this._sendGroups(groups) @@ -647,98 +716,6 @@ export class ModbusClient { } } - // - // - // - // - // Log Transaction - // - // Everything here is read out of modbus-serial's internals, so it is worth - // writing down what those actually guarantee. - // - // A transaction is created by the writeFCx methods and never removed again: - // the library only ever assigns _transactions in its constructor. Removing - // them is ours to do, or a session grows one entry per request. - // - // request and responses are stashed only while debug mode is on, and only - // by the write that reaches the port, so a transaction can carry neither. - // 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. - private _internals = (): ModbusRTUInternals => this._client as ModbusRTUInternals - - /** - * The key modbus-serial files the next request under. - * - * A `writeFCx` reads `_port._transactionIdWrite` to file its transaction and - * the port increments it once the buffer is out, so this is the key of the - * request that goes next. Read it immediately before the call: nothing awaits - * in between, so nothing else can file first. - */ - private _nextTransactionIdKey = (): string => String(this._internals()._port?._transactionIdWrite) - - /** - * The data address the request asked for. - * - * Read off the request frame, not off `nextDataAddress`. modbus-serial files - * that field on two of its twelve transaction records, inside `writeFC4` and - * `writeFC6`, and `writeFC1` delegates to `writeFC2` while `writeFC3` - * delegates to `writeFC4`. So FC3, FC4 and FC6 carry one and FC1, FC2, FC5, - * FC15 and FC16 do not, and the Addr column was blank for every coil read, - * every discrete input read and every write Modbux sends. - * - * Every `writeFCx` builds its frame as unit id, function code, then the data - * address at offset 2 as a big-endian word: measured over FC2, FC4, FC5, FC6, - * FC15 and FC16, which is every code Modbux sends. `request` is stashed by - * `_writeBufferToPort` while debug mode is on, and `connect` sets - * `isDebugEnabled` on every client it opens, including the replacement the - * disconnect timeout builds, so a frame that went out over a connection this - * file made has one. `nextDataAddress` is the fallback either way. - */ - private _requestedAddress = (rawTransaction: RawTransaction): number | undefined => { - const request = rawTransaction.request - if (request && request.length >= 4) return request.readUInt16BE(2) - return rawTransaction.nextDataAddress - } - - /** - * 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. - */ - private _logTransaction = (transactionIdKey: string, errorMessage: string | undefined): void => { - const rawTransactions = this._internals()._transactions - const rawTransaction = rawTransactions[transactionIdKey] - if (!rawTransaction) return - - // Only the entry being logged, so the same one is not logged again on the - // next call. Emptying the table takes entries for requests still in flight - // with it, and `_onReceive` drops a response whose entry is gone, so the - // request times out rather than resolving. - delete rawTransactions[transactionIdKey] - - const transaction: Transaction = { - id: `${transactionIdKey}__${v4()}`, - timestamp: DateTime.now().toMillis(), - unitId: rawTransaction.nextAddress, - address: this._requestedAddress(rawTransaction), - code: rawTransaction.nextCode, - responseLength: rawTransaction.nextLength, - timeout: rawTransaction._timeoutFired, - request: toHexString(rawTransaction.request), - responses: (rawTransaction.responses ?? []).map(toHexString), - errorMessage - } - - this._sendTransaction(transaction) - } - // // // @@ -754,6 +731,8 @@ export class ModbusClient { */ public startPolling = (): void => { if (this._clientState.polling) return + if (!this._requireClient('poll')) return + this._clientState.polling = true this._sendClientState() this._poll(++this._pollGeneration) @@ -766,6 +745,23 @@ export class ModbusClient { this._sendClientState() } + /** + * The port, once the poll has let go of it. + * + * `stopPolling` ends the chain and returns, and a chain awaiting a read is + * still inside `_read`'s group loop, which breaks on the connect state and + * not on the generation. So a scan that stopped a poll put its own requests + * on the client while the poll's last read was still on the wire, and on a + * serial port both file under transaction key 1, where the scan's + * `TransactionLog.log` deletes the entry that read is waiting on. That is the + * collision `_requireClient` exists to prevent, reached through the one + * owner a scan does not refuse. + * + * Awaited rather than refused, because stopping a poll to scan is what the + * Start button has always done. + */ + private _pollRead: Promise<void> | undefined + /** * Read, then arm the next read, as long as this chain is still the current one. * @@ -776,7 +772,12 @@ export class ModbusClient { * timer nothing can clear. */ private _poll = async (generation: number): Promise<void> => { - await this._read() + this._pollRead = this._read() + try { + await this._pollRead + } finally { + this._pollRead = undefined + } if (generation !== this._pollGeneration) return this._pollTimeout = setTimeout( () => this._poll(generation), @@ -811,9 +812,10 @@ export class ModbusClient { * * The length is the read's own, not `registerConfig.length`. modbus-serial * answers a bit read with eight booleans per byte, so a read of three comes - * back as eight and the row count has to come from the request. The two are - * one number for a read out of the toolbar, where the only group is - * `[address, registerConfig.length]`. Every other caller passes its own. + * back as eight and the row count has to come from the request. Even the + * toolbar's own group is not `registerConfig.length` any more: it is that + * length under both read ceilings, so 2000 coils asked for at 65500 is a + * group of 36. */ private _toBits = (result: ReadCoilResult, address: number, length: number): RegisterData[] => convertBitData(result, address, length, this._clientState.scanningRegisters) @@ -837,37 +839,69 @@ export class ModbusClient { // a closed port has nothing of its own to log. if (!this._requireConnected('write')) return - const owner = this._readLoopOwner() - if (owner) { - this._emitMessage({ - message: `Cannot write during ${owner}`, - variant: 'warning', - error: null - }) - return - } + if (!this._requireClient('write')) return const { address, type, value, dataType, single } = writeParameters - let attempt: WriteAttempt + // The timeout the user set, the way `_read` does it. Nothing on this path + // called `setTimeout`, so a write inherited whatever ran last on the + // client: a register scan at the scan field's floor of 100 ms left FC6 a + // tenth of the 1000 ms the toolbar's own floor promises, and a write with + // no read since the connect got the 3000 ms `_connect` opens with. The + // read back at the end corrects it, which is one request too late. + this._client.setTimeout(this._appState.registerConfig.timeout) - switch (type) { - case 'coils': - attempt = await this._writeCoil(address, value, single) - break - case 'holding_registers': - attempt = await this._writeRegister(address, value, dataType, single) - break - } + this._clientState.writing = true + this._sendClientState() + try { + let attempt: WriteAttempt + + switch (type) { + case 'coils': + attempt = await this._writeCoil(address, value, single) + break + case 'holding_registers': + attempt = await this._writeRegister(address, value, dataType, single) + break + } - // Log the write transaction, and only the write's own. - if (attempt.sent) this._logTransaction(attempt.transactionIdKey, attempt.errorMessage) + // A refused write has nothing to log and nothing to read back: every + // `sent: false` above emitted its own warning and put no request on the + // wire, so the device holds what it held. + if (!attempt.sent) return + this._transactionLog.log(attempt.transactionIdKey, attempt.errorMessage) - // Read back what the device now holds, unless a loop started during the - // write and is reading anyway. - if (!this._readLoopOwner()) this.read() + // Read back what the device now holds, unless a loop started during the + // write and is reading anyway. `reading` is not in that question: this + // write owns the client, so nothing else can have set it. + if (!readLoopOwner(this._clientState)) await this._readOwningTheClient() + } finally { + this._clientState.writing = false + this._sendClientState() + } } + /** + * 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`. One + * conversion rather than one per code, where the four copies differ in the + * method name, the type argument and the value they pass. 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 = <R>(write: (next: NodeStyleCallback<R>) => void): Promise<R> => + new Promise<R>((resolve, reject) => + write((error, data) => { + if (error) { + reject(error) + return + } + resolve(data) + }) + ) + private _writeCoil = async ( address: number, value: boolean[], @@ -889,34 +923,22 @@ export class ModbusClient { return { sent: false } } - const transactionIdKey = this._nextTransactionIdKey() + const transactionIdKey = this._transactionLog.nextTransactionIdKey() try { if (single) { - await new Promise<WriteCoilResult>((resolve, reject) => - this._client.writeFC5(unitId, address, first, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite<WriteCoilResult>((next) => + this._client.writeFC5(unitId, address, first, next) ) return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple coils - await new Promise<WriteMultipleResult>((resolve, reject) => - this._client.writeFC15(unitId, address, value, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite<WriteMultipleResult>((next) => + 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 } @@ -953,35 +975,23 @@ export class ModbusClient { const { unitId } = this._appState.connectionConfig const registers = createRegisters(dataType, value, littleEndian) - const transactionIdKey = this._nextTransactionIdKey() + const transactionIdKey = this._transactionLog.nextTransactionIdKey() try { if (single) { // Write single register - await new Promise<WriteRegisterResult>((resolve, reject) => - this._client.writeFC6(unitId, address, registers[0], (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite<WriteRegisterResult>((next) => + this._client.writeFC6(unitId, address, registers[0], next) ) return { sent: true, transactionIdKey, errorMessage: undefined } } // Write multiple registers - await new Promise<WriteMultipleResult>((resolve, reject) => - this._client.writeFC16(unitId, address, registers, (err, data) => { - if (err) { - reject(err) - return - } - resolve(data) - }) + await this._awaitWrite<WriteMultipleResult>((next) => + 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 } } @@ -997,7 +1007,9 @@ export class ModbusClient { // Scan Unit ID public scanUnitIds = async (params: ScanUnitIDParameters): Promise<void> => { if (!this._requireConnected('scan')) return + if (!this._requireClient('scan', true)) return this.stopPolling() + await this._pollRead this._client.setTimeout(params.timeout) this._clientState.scanningUnitIds = true @@ -1007,6 +1019,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 }) @@ -1047,18 +1060,18 @@ export class ModbusClient { } let errorMessage: string | undefined - const transactionIdKey = this._nextTransactionIdKey() + const transactionIdKey = this._transactionLog.nextTransactionIdKey() try { 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) } - this._logTransaction(transactionIdKey, errorMessage) + this._transactionLog.log(transactionIdKey, errorMessage) - await this._sendScanProgress() + this._countScanStep() } if (!this._clientState.scanningUnitIds) { @@ -1066,7 +1079,6 @@ export class ModbusClient { return } - await new Promise((resolve) => setTimeout(resolve, 10)) this._sendUnitIdResult(result) } @@ -1077,27 +1089,34 @@ export class ModbusClient { // Scan Registers public scanRegisters = async (params: ScanRegistersParameters): Promise<void> => { if (!this._requireConnected('scan')) return + if (!this._requireClient('scan', true)) return this.stopPolling() + await this._pollRead const { unitId } = this._appState.connectionConfig 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._scanProgressSentAt = 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() + this._countScanStep() if (!this._clientState.scanningRegisters) break if (this._clientState.connectState !== 'connected') break - await new Promise((resolve) => setTimeout(resolve, 5)) } this._clientState.scanningRegisters = false @@ -1106,26 +1125,27 @@ export class ModbusClient { private _scanRegister = async (address: number, length: number): Promise<void> => { const type = this._appState.registerConfig.type - if (address + length > 65536) length = 65536 - 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 - const transactionIdKey = this._nextTransactionIdKey() + const transactionIdKey = this._transactionLog.nextTransactionIdKey() try { 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 }) } - this._logTransaction(transactionIdKey, errorMessage) + this._transactionLog.log(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) } @@ -1135,39 +1155,13 @@ export class ModbusClient { this._clientState.scanningRegisters = false } - // Serial port discovery - public listSerialPorts = async (): Promise<{ path: string; manufacturer?: string }[]> => { - try { - const ports = await ModbusRTU.getPorts() - return ports.map((port) => ({ - path: port.path, - manufacturer: port.manufacturer ?? undefined - })) - } catch (error) { - const message = humanizeSerialError(error as Error) - this._emitMessage({ message, variant: 'error', error }) - return [] - } - } + /** The serial ports this machine has, for the two RTU COM fields. */ + public listSerialPorts = (): Promise<{ path: string; manufacturer?: string }[]> => + serialPorts.listSerialPorts(this._emitMessage) - public validateSerialPort = async ( - portPath: string - ): Promise<{ valid: boolean; message: string }> => { - try { - const ports = await ModbusRTU.getPorts() - const found = ports.some((port) => port.path.toLowerCase() === portPath.toLowerCase()) - return { - valid: found, - message: found - ? `Port "${portPath}" is available` - : `Port "${portPath}" was not found in available ports` - } - } catch (error) { - const message = humanizeSerialError(error as Error, portPath) - this._emitMessage({ message, variant: 'error', error }) - return { valid: false, message } - } - } + /** Whether a path is one of the ports this machine has. */ + public validateSerialPort = (portPath: string): Promise<{ valid: boolean; message: string }> => + serialPorts.validateSerialPort(portPath, this._emitMessage) get state(): ClientState { return this._clientState diff --git a/src/main/modules/modbusClient/serialPorts.ts b/src/main/modules/modbusClient/serialPorts.ts new file mode 100644 index 00000000..8d8a04e4 --- /dev/null +++ b/src/main/modules/modbusClient/serialPorts.ts @@ -0,0 +1,49 @@ +import { BackendMessage, humanizeSerialError } from '@shared' +import ModbusRTU from 'modbus-serial' + +/** What a failed enumeration says, which is the client's `_emitMessage`. */ +type EmitMessage = (message: BackendMessage) => void + +/** + * The serial ports this machine has, as the RTU fields list them. + * + * `ModbusRTU.getPorts` is static, so neither of these reads a connection. They + * answer for the server window's COM field as well as the client's, which is + * why `list_serial_ports` and `validate_serial_port` are not refused from it. + */ +export const listSerialPorts = async ( + emitMessage: EmitMessage +): Promise<{ path: string; manufacturer?: string }[]> => { + try { + const ports = await ModbusRTU.getPorts() + return ports.map((port) => ({ + path: port.path, + manufacturer: port.manufacturer ?? undefined + })) + } catch (error) { + const message = humanizeSerialError(error as Error) + emitMessage({ message, variant: 'error', error }) + return [] + } +} + +/** Whether a path is one of the ports, compared without case. */ +export const validateSerialPort = async ( + portPath: string, + emitMessage: EmitMessage +): Promise<{ valid: boolean; message: string }> => { + try { + const ports = await ModbusRTU.getPorts() + const found = ports.some((port) => port.path.toLowerCase() === portPath.toLowerCase()) + return { + valid: found, + message: found + ? `Port "${portPath}" is available` + : `Port "${portPath}" was not found in available ports` + } + } catch (error) { + const message = humanizeSerialError(error as Error, portPath) + emitMessage({ message, variant: 'error', error }) + return { valid: false, message } + } +} diff --git a/src/main/modules/modbusClient/transactionLog.ts b/src/main/modules/modbusClient/transactionLog.ts new file mode 100644 index 00000000..53815584 --- /dev/null +++ b/src/main/modules/modbusClient/transactionLog.ts @@ -0,0 +1,141 @@ +import { RawTransaction, Transaction } from '@shared' +import { DateTime } from 'luxon' +import ModbusRTU from 'modbus-serial' +import { v4 } from 'uuid' +import { Windows } from '../../windows' + +/** Modbus frames read the way a protocol analyser prints them. */ +const toHexString = (bytes: Uint8Array | undefined): string => + bytes === undefined + ? '' + : Array.from(bytes) + .map((byte) => Number(byte).toString(16).toUpperCase().padStart(2, '0')) + .join(' ') + +/** + * The two internals the transaction log reads. + * + * `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<string, RawTransaction | undefined> + _port: { _transactionIdWrite?: number } | undefined +} + +/** + * The data address the request asked for. + * + * Read off the request frame, not off `nextDataAddress`. modbus-serial files + * that field on two of its twelve transaction records, inside `writeFC4` and + * `writeFC6`, and `writeFC1` delegates to `writeFC2` while `writeFC3` + * delegates to `writeFC4`. So FC3, FC4 and FC6 carry one and FC1, FC2, FC5, + * FC15 and FC16 do not, and the Addr column was blank for every coil read, + * every discrete input read and every write Modbux sends. + * + * Every `writeFCx` builds its frame as unit id, function code, then the data + * address at offset 2 as a big-endian word: measured over FC2, FC4, FC5, FC6, + * FC15 and FC16, which is every code Modbux sends. `request` is stashed by + * `_writeBufferToPort` while debug mode is on, and `connect` sets + * `isDebugEnabled` on every client it opens, including the replacement the + * disconnect timeout builds, so a frame that went out over a connection this + * file made has one. `nextDataAddress` is the fallback either way. + */ +const requestedAddress = (rawTransaction: RawTransaction): number | undefined => { + const request = rawTransaction.request + if (request && request.length >= 4) return request.readUInt16BE(2) + return rawTransaction.nextDataAddress +} + +interface TransactionLogParams { + /** + * The client as it is now, not as it was. + * + * `ModbusClient.disconnect` builds a replacement `ModbusRTU` when a close + * times out, so a reference taken once would read the table of a client + * nothing sends on any more. + */ + client: () => ModbusRTU + windows: Windows +} + +/** + * What went out and what came back, read off modbus-serial's own table. + * + * This is the only part of the client that names an undeclared internal, which + * is what a library bump breaks. + */ +export class TransactionLog { + private _client: () => ModbusRTU + private _windows: Windows + + constructor({ client, windows }: TransactionLogParams) { + this._client = client + this._windows = windows + } + + // 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 + + /** + * The key modbus-serial files the next request under. + * + * A `writeFCx` reads `_port._transactionIdWrite` to file its transaction and + * the port increments it once the buffer is out, so this is the key of the + * request that goes next. Read it immediately before the call: nothing awaits + * in between, so nothing else can file first. + */ + public nextTransactionIdKey = (): string => String(this._internals()._port?._transactionIdWrite) + + /** + * Log the request filed under `transactionIdKey`, and only that one. + * + * 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 `_clientOwner` answers, and a + * poll awaits each request before it sends the next. + */ + public log = (transactionIdKey: string, errorMessage: string | undefined): void => { + const rawTransactions = this._internals()._transactions + const rawTransaction = rawTransactions[transactionIdKey] + if (!rawTransaction) return + + // Only the entry being logged, so the same one is not logged again on the + // next call. Emptying the table takes entries for requests still in flight + // with it, and `_onReceive` drops a response whose entry is gone, so the + // request times out rather than resolving. + delete rawTransactions[transactionIdKey] + + const transaction: Transaction = { + id: `${transactionIdKey}__${v4()}`, + timestamp: DateTime.now().toMillis(), + unitId: rawTransaction.nextAddress, + address: requestedAddress(rawTransaction), + code: rawTransaction.nextCode, + responseLength: rawTransaction.nextLength, + timeout: rawTransaction._timeoutFired, + request: toHexString(rawTransaction.request), + responses: (rawTransaction.responses ?? []).map(toHexString), + errorMessage + } + + this._windows.send('transaction', transaction, 'main') + } +} diff --git a/src/main/modules/modbusServer.ts b/src/main/modules/modbusServer.ts index 247ff822..3953f717 100644 --- a/src/main/modules/modbusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -3,1036 +3,144 @@ import { SetBooleanParameters, SyncBoolsParameters, SyncRegisterValueParams, - createRegisters, - createStringRegisters, ResetRegistersParams, ResetBoolsParams, CreateServerParams, - ServerData, - ValueGenerators, AddRegisterParams, - UnitIdString, - UnitIdStringSchema, StartRtuServerParams, - RegisterType, - RegisterValue, - DataBits, - StopBits, - ValuedDataType + ServerEndianness } from '@shared' -import { ServerTCP, ServerSerial } from 'modbus-serial' -import { ServerEndianness } from '@shared' import { Windows } from '../windows' -import { ValueGenerator } from './modbusServer/valueGenerator' -import type { IServiceVector, FCallbackVal, FCallback } from 'modbus-serial' -import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' -import net from 'net' +import { ServerRegistry } from './modbusServer/registry' +import { RtuServer } from './modbusServer/rtu' +import { TcpServers } from './modbusServer/tcp' -const getDefaultGenerators = (): ValueGenerators => ({ - input_registers: new Map(), - holding_registers: new Map() -}) - -const getDefaultServerData = (): { - coils: boolean[] - discrete_inputs: boolean[] - input_registers: number[] - holding_registers: number[] -} => ({ - coils: new Array(65536).fill(false), - discrete_inputs: new Array(65536).fill(false), - input_registers: new Array(65536).fill(0), - holding_registers: new Array(65536).fill(0) -}) - -/** - * The three Modbus exception codes this server sends. - * - * The other seven of the protocol's table stood here as exported constants - * nothing named. Keeping them costs a lint disable, which is a worse comment - * than none. `modbusServer.test.ts` asserts on all three of these. - */ -export const ILLEGAL_DATA_ADDRESS = 2 -export const SERVER_DEVICE_FAILURE = 4 -export const GATEWAY_TARGET_FAILED = 11 -export const DEFAULT_MOBUS_PORT = 502 - -/** - * The transport a vector answers on. RS-485 is shared and a socket is not, so a - * request for a unit id this server does not host cannot get the same answer on - * both. - */ -export type ServerTransport = 'tcp' | 'rtu' - -/** Unit 0 addresses every device on an RTU bus at once. */ -export 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 - -/** - * How long a bind may take before the listener is treated as failed. - * - * `listen` answers with one of its two events, so nobody sits through this. It - * is here because a promise that neither event resolves would hang - * `createServer` and every caller behind it. - */ -export const BIND_TIMEOUT_MS = 5000 - -type ServerDataUnitMap = Map<UnitIdString, ServerData> -type ValueGeneratorsUnitMap = Map<UnitIdString, ValueGenerators> - -type ServerDataMap = Map<string, ServerDataUnitMap> -type ValueGeneratorsMap = Map<string, ValueGeneratorsUnitMap> - -/** - * One accessor shape per direction, because `IServiceVector`'s four getters and - * two setters differ only in the value they carry. - * - * These are written out rather than derived from `IServiceVector`, so what - * checks them is the assignment in `_getVector`. That catches a signature - * modbus-serial changes incompatibly and not one it widens, because a version - * that adds an optional parameter stays assignable to these. - */ -type IServiceVectorGet<T> = (addr: number, unitID: number, cb: FCallbackVal<T>) => void -type IServiceVectorSet<T> = (addr: number, value: T, unitID: number, cb: FCallback) => void - -/** - * 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. - */ -interface RtuSerialPort { - on(event: 'error' | 'close', listener: (err?: Error) => void): void -} - -interface ServerSerialWithPort extends ServerSerial { - getPort(): RtuSerialPort +interface ServerParams { + windows: Windows } /** - * `ServerSerial`'s third constructor argument, which its typings leave out. + * The four parts of a Modbus server, and what one uuid means across them. * - * `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`. + * `ServerRegistry` holds the registers, `TcpServers` holds the listeners, + * `RtuServer` holds the serial port, and `createVector` is what the two + * transports ask the registry through. Everything public here is what + * `main/ipc.ts` calls; a method that names more than one part is one this + * class answers itself, and the rest hand their parameters over. */ -interface ServerSerialPortOptions { - dataBits: DataBits - stopBits: StopBits -} - -type ServerSerialConstructor = new ( - ...args: [...ConstructorParameters<typeof ServerSerial>, ServerSerialPortOptions] -) => ServerSerial +export class ModbusServer { + /** What every server holds, keyed by uuid and then by unit id. */ + private _registry: ServerRegistry -export interface ServerParams { - windows: Windows -} + /** The TCP listeners, one per uuid. */ + private _tcp: TcpServers -/** - * ModbusServer class manages Modbus TCP servers, register data, and value generators for each server and unitId. - * Handles server creation, deletion, register management, and value generator lifecycle. - */ -export class ModbusServer { - private _port: Map<string, number> = new Map() - private _servers: Map<string, ServerTCP> = new Map() - private _rtuServer: ServerSerial | null = null - private _rtuUuid: string | null = null - private _rtuActive: boolean = false + /** The one RTU server, and the uuid whose registers it serves. */ + private _rtu: RtuServer /** Whether the RTU server is running, for a window that has to ask. */ public get rtuActive(): boolean { - return this._rtuActive + return this._rtu.active } - private _rtuGeneration: number = 0 - private _broadcastWarningSent: boolean = false - private _windows: Windows - - // Map to store server data for each unit ID of a server UUID - /** - * The byte order each server encodes its registers in, big-endian until told. - * - * The renderer used to send it with every add and every sync, which is the - * same field of the same server read again each time. It is set once here - * and read where a register is encoded. - */ - private _littleEndian: Map<string, boolean> = new Map() - private _serverData: ServerDataMap = new Map() - private _generatorMap: ValueGeneratorsMap = new Map() /** * Construct a ModbusServer instance. * @param windows - Windows IPC interface for backend/frontend communication. */ constructor({ windows }: ServerParams) { - this._windows = windows - } - - /** - * Ensures an inner map exists for a given UUID in the outer map, creating it if necessary. - * @param outerMap - The outer map (by UUID) - * @param uuid - The server UUID - * @returns The inner map for the UUID - */ - private _ensureInnerMap<K extends UnitIdString, V>( - outerMap: Map<string, Map<K, V>>, - uuid: string - ): Map<K, V> { - let inner = outerMap.get(uuid) - if (!inner) { - inner = new Map() - outerMap.set(uuid, inner) - } - return inner - } - - /** - * Returns a Modbus service vector for a given server UUID and transport. - * This vector provides all the Modbus register accessors and mutators. - */ - private _getVector = (uuid: string, transport: ServerTransport): IServiceVector => ({ - getCoil: this._get('coils', uuid, transport, false), - getDiscreteInput: this._get('discrete_inputs', uuid, transport, false), - getInputRegister: this._get('input_registers', uuid, transport, 0), - getHoldingRegister: this._get('holding_registers', uuid, transport, 0), - setCoil: this._set('coils', uuid, transport), - setRegister: this._set('holding_registers', uuid, transport) - }) - - /** - * A unit id is one of ours when it has data under this uuid. The Select - * offers all 256, and nothing but a register makes one of them exist. - */ - private _hostsUnit(uuid: string, unitId: UnitIdString): boolean { - return this._serverData.get(uuid)?.has(unitId) ?? false - } - - /** - * Unit 0 is broadcast on RTU. On TCP there is no broadcast at all and the - * unit identifier routes through a gateway, so 0 is an address like any other. - */ - private _isBroadcast(transport: ServerTransport, unitId: UnitIdString): boolean { - return transport === 'rtu' && unitId === BROADCAST_UNIT_ID - } - - /** - * Answers a request for a unit id this server does not host. - * - * modbus-serial writes a frame when the vector calls `cb` and writes nothing - * when it does not, so returning without calling it is silence on the wire. - * On RS-485 silence is the only safe answer: the id belongs to a real device - * answering at that moment, and a second frame collides with it. A socket - * carries one device, so silence there is a client timeout instead, and the - * gateway code says what happened. - */ - private _refuseUnit<T>(transport: ServerTransport, cb: FCallbackVal<T>, value: T): void { - if (transport === 'rtu') return - this._mbError(GATEWAY_TARGET_FAILED, cb, value) - } - - /** - * Says once per RTU session that registers on unit 0 are unreachable. - * - * The renderer opens the port before it syncs registers, so on a fresh start - * the data arrives after `initialized` and on a config load it is already - * there. Hence the two call sites, and the flag that keeps them to one - * message. - */ - private _warnBroadcastUnit(uuid: string): void { - if (!this._rtuActive || this._rtuUuid !== uuid) return - if (this._broadcastWarningSent) return - if (!this._hostsUnit(uuid, BROADCAST_UNIT_ID)) return - - this._broadcastWarningSent = true - this._emitMessage({ - message: 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.', - variant: 'warning' + // The registry's callback names `_rtu`, so the RTU server is built before + // anything that could write a register. Nothing here does today, and the + // order is what keeps that from mattering. + this._registry = new ServerRegistry({ + windows, + onUnitData: (uuid): void => this._rtu.warnBroadcastUnit(uuid) }) + this._rtu = new RtuServer({ windows, registry: this._registry }) + this._tcp = new TcpServers({ windows, registry: this._registry }) } - /** - * Helper to set server data for a unitId in the server data map. - */ - private _setServerData(uuid: string, unitId: UnitIdString, serverData: ServerData): void { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - perUnitMap.set(unitId, serverData) - this._warnBroadcastUnit(uuid) - } + /** Binds a listener for a uuid, and answers the port it took. */ + public createServer = (params: CreateServerParams): Promise<number | undefined> => + this._tcp.create(params) /** - * Helper to dispose all value generators in a ValueGeneratorsUnitMap. - * This stops all intervals and clears the generator maps. - */ - private _disposeAllGenerators(unitMap: ValueGeneratorsUnitMap): void { - for (const registerTypeGenerators of unitMap.values()) { - registerTypeGenerators.holding_registers.forEach((g) => g.dispose()) - registerTypeGenerators.input_registers.forEach((g) => g.dispose()) - } - } - - /** - * Emits a backend message to the frontend via the Windows IPC interface. - */ - /** - * A server message goes to the window showing the server, which in split view - * is the popped out one. Broadcasting put "A server needs a port between 1 and - * 65535" in the window on the client view and in the one that asked. - */ - private _emitMessage({ - message, - variant, - error - }: { - message: string - variant: 'default' | 'error' | 'success' | 'warning' | 'info' - error?: Error - }): void { - this._windows.send('backend_message', { message, variant, error }, 'serverView') - } - - /** - * Checks if a TCP port is available for binding. - * Returns an object with availability and optional error code (e.g. EACCES, EADDRINUSE). - */ - private async _isPortAvailable( - port: number - ): Promise<{ available: boolean; errorCode?: string }> { - return new Promise((resolve) => { - const tester = net.createServer() - tester.once('error', (err: NodeJS.ErrnoException) => { - resolve({ available: false, errorCode: err.code }) - }) - tester.once('listening', () => { - tester.close(() => resolve({ available: true })) - }) - tester.listen(port, '0.0.0.0') - }) - } - - /** - * Closes the listener registered for a UUID and forgets it, if there is one. + * Deletes a server for the given UUID, releasing everything held under it. * - * `ServerTCP.close` destroys every socket in `modbus.socks`, so whoever was - * connected gets a FIN. - */ - private async _closeAndForget(uuid: string): Promise<void> { - const existingServer = this._servers.get(uuid) - if (!existingServer) return - await new Promise<void>((resolve) => { - existingServer.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - this._servers.delete(uuid) - this._port.delete(uuid) - } - - /** - * Binds a TCP listener for a UUID and answers what the socket did. - * - * The constructor returns before `listen` has finished, and a refused bind - * arrives as a `serverError` carrying `EADDRINUSE` rather than as a throw. A - * constructor that returned is therefore no evidence of a listener, so the - * maps are written only once one of the two events has said so. - */ - private async _bindServer( - uuid: string, - port: number - ): Promise<{ ok: boolean; errorCode?: string }> { - const server = new ServerTCP(this._getVector(uuid, 'tcp'), { host: '0.0.0.0', port }) - - const result = await new Promise<{ ok: boolean; errorCode?: string }>((resolve) => { - const timer = setTimeout( - () => resolve({ ok: false, errorCode: 'ETIMEDOUT' }), - BIND_TIMEOUT_MS - ) - server.on('initialized', () => { - clearTimeout(timer) - resolve({ ok: true }) - }) - server.on('serverError', (err) => { - clearTimeout(timer) - resolve({ ok: false, errorCode: (err as NodeJS.ErrnoException | null)?.code }) - }) - }) - - if (!result.ok) { - server.close(() => {}) - return result - } - - this._servers.set(uuid, server) - this._port.set(uuid, port) - return result - } - - /** - * Creates a Modbus TCP server for the given UUID and port. - * Returns the actual port used (may differ from requested if taken). + * What the uuid holds goes first, whether or not a TCP listener was ever + * bound. The registry holds every register of every unit id under the uuid, + * and the delete button does not go through `resetServer`, which is the other + * place that frees it. * - * A listener already on the requested port is the answer to this call. The - * vectors read `_serverData` when a request arrives rather than when they are - * 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. - */ - public createServer = async ({ uuid, port }: CreateServerParams): Promise<number> => { - // 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 - const maxAttempts = 10000 - - if (this._servers.has(uuid) && this._port.get(uuid) === actualPort) return actualPort - - await this._closeAndForget(uuid) - - for (let i = 0; i < maxAttempts; i++) { - const result = await this._isPortAvailable(actualPort) - if (result.available) { - const bind = await this._bindServer(uuid, actualPort) - if (bind.ok) return actualPort - } - actualPort++ - } - this._emitMessage({ - message: 'No available port found', - variant: 'error', - error: undefined - }) - return actualPort - } - - /** - * Deletes a Modbus TCP server for the given UUID, cleaning up all resources. + * A uuid with no listener is silence rather than an error: a server whose + * bind was refused keeps `ready` false in the store and its Delete button, + * and it is still a server the user is deleting. */ public deleteServer = async (uuid: string): Promise<void> => { // Clean up RTU server if this UUID is the RTU server - if (this._rtuUuid === uuid) { - await this.stopRtuServer() + if (this._rtu.uuid === uuid) { + await this._rtu.stop() } - if (!this._servers.has(uuid)) { - this._emitMessage({ message: `No server found for UUID ${uuid}`, variant: 'error' }) - return - } - await this._closeAndForget(uuid) - const unitIdGenerators = this._generatorMap.get(uuid) - if (unitIdGenerators) { - this._disposeAllGenerators(unitIdGenerators) - } - this._generatorMap.delete(uuid) - this._littleEndian.delete(uuid) + this._registry.deleteUuid(uuid) + + await this._tcp.closeAndForget(uuid) } /** * Resets the server for a given UUID: disposes its value generators and * 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. - */ - public resetServer = async (uuid: string): Promise<void> => { - const unitIdGenerators = this._generatorMap.get(uuid) - if (unitIdGenerators) { - this._disposeAllGenerators(unitIdGenerators) - } - this._serverData.delete(uuid) - this._generatorMap.delete(uuid) - const port = this._port.get(uuid) - if (port) await this.createServer({ uuid, port }) - } - - /** Sets the byte order this server encodes its registers in. */ - public setEndianness = ({ uuid, littleEndian }: ServerEndianness): void => { - this._littleEndian.set(uuid, littleEndian) - } - - /** - * The words a fixed register holds, or nothing at all when it cannot be - * encoded. + * The vectors read the registry per request, so the cleared data is what a + * master gets from the listener that is already up. * - * `RegisterParamsSchema` bounds the pair of `dataType` and `value` at the IPC - * boundary, and `getValueRangeError` there reads the same table this asks - * about. This is the class answering for its own input: `addRegister` is - * public, and a caller inside main reaches it without crossing that boundary. + * `create` rebinds nothing from here. It writes `_port` and `_servers` on + * consecutive lines and `closeAndForget` deletes both, so a stored port means + * a listener on that port, which is `create`'s first return. After a spell in + * RTU mode there is no stored port and the call does not happen at all. */ - private _encode = ({ - dataType, - value, - littleEndian, - stringValue, - length - }: { - dataType: ValuedDataType - value: number - littleEndian: boolean - stringValue?: string - length?: number - }): number[] | undefined => { - try { - return dataType === 'utf8' - ? createStringRegisters(stringValue ?? '', length ?? DEFAULT_UTF8_LENGTH) - : createRegisters(dataType, value, littleEndian) - } catch { - return undefined - } - } - - private _unitData = (uuid: string, unitId: UnitIdString): ServerData => { - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) - return serverData - } - - /** - * Adds a register or value generator for a given server and unitId. - * If a generator already exists at the address, it is disposed and replaced. - * If a fixed value is provided, sets the register directly. - * - * Answers the words now held from `address` on, which is an empty list for - * `none` because that writes none. The renderer's store waits for this answer - * before it writes, and every `register_value` below goes out before the - * answer does, so those words would otherwise reach a store with no entry to - * put them in and be dropped. The store folds what comes back through the same - * merge the event feeds, which is where a word becomes a value. - * - * `undefined` is the refusal, which is what the store already reads as - * nothing changed. An encoder that cannot take the register used to throw - * straight out of here: `createIpcHandle` puts no try around a listener, so - * the invoke rejected rather than answering, and `syncServerRegisters` adds - * 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. - */ - public addRegister = ({ uuid, unitId, params }: AddRegisterParams): number[] | undefined => { - const littleEndian = this._littleEndian.get(uuid) ?? false - const { - address, - registerType, - dataType, - min, - max, - interval, - value, - comment, - stringValue, - length - } = params - - // Ensure generator map for this server and unitId - const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( - this._generatorMap, - uuid - ) - const serverGenerators = perUnitGeneratorMap.get(unitId) ?? getDefaultGenerators() - - if (!perUnitGeneratorMap.has(unitId)) { - perUnitGeneratorMap.set(unitId, serverGenerators) - } - - const generators = serverGenerators[registerType] - - /** Frees the register this call is replacing, and answers its data map. */ - const takeTheAddress = (): ServerData => { - generators.get(address)?.dispose() - generators.delete(address) - const serverData = this._unitData(uuid, unitId) - this._setServerData(uuid, unitId, serverData) - return serverData - } - - // `none` is an address held open with nothing in it, so there is nothing to - // write and nothing to generate. The generator is disposed either way, - // which is what editing a register to `none` has to do. - if (dataType === 'none') { - takeTheAddress() - return [] - } - - // If a fixed value is provided, set the register directly - const fixedValue = !interval && value !== undefined - if (fixedValue) { - // Encoded before the address is taken, because a refusal answers - // `undefined` and the store reads that as nothing changed. Disposing - // first zeroed the words of the generator being replaced and dropped it, - // so the grid went on drawing a generator that no longer ran. - const registers = this._encode({ dataType, value, littleEndian, stringValue, length }) - if (!registers) { - this._emitMessage({ - message: `The ${dataType} register at ${address} was not added: main cannot encode that value`, - variant: 'error' - }) - return undefined - } - - 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' - ) - }) - this._setServerData(uuid, unitId, serverData) - return registers - } - - // Otherwise, add a value generator for this register - const serverData = takeTheAddress() - generators.set( - address, - new ValueGenerator({ - uuid, - unitId, - windows: this._windows, - serverData, - address, - dataType, - min, - max, - interval, - littleEndian, - registerType, - comment, - stringValue, - length - }) - ) - - // `ValueGenerator` writes its first value from its own constructor, so this - // reads what it just put there rather than answering nothing for a minute. - const width = registerWidth(dataType, length) - return serverData[registerType].slice(address, address + width) - } - - /** - * Removes a register or value generator for a given server and unitId. - * Disposes the generator if it exists and resets the register value. - */ - public removeRegister = ({ - uuid, - unitId, - registerType, - address, - dataType, - length - }: RemoveRegisterParams): void => { - const serverData = this._unitData(uuid, unitId) - - // Reset all registers occupied by this data type - const registerCount = registerWidth(dataType, length) - for (let i = 0; i < registerCount; i++) { - serverData[registerType][address + i] = 0 - } - - const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( - this._generatorMap, - uuid - ) - const serverGenerators = perUnitGeneratorMap.get(unitId) - if (!serverGenerators) return - const generator = serverGenerators[registerType].get(address) - if (!generator) return - generator.dispose() - serverGenerators[registerType].delete(address) - } - - /** - * Synchronizes all register values for a given server and unitId. - * Resets all holding and input registers, then adds all provided registers. - */ - 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 }) - } - - /** - * Resets all registers of a given type for a server and unitId. - * Disposes all generators for that register type and clears the register data. - */ - public resetRegisters = ({ uuid, unitId, registerType }: ResetRegistersParams): void => { - // Dispose and clear only generators for this unitId and registerType - const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( - this._generatorMap, - uuid - ) - const serverGenerators = perUnitGeneratorMap.get(unitId) - if (serverGenerators) { - const generators = serverGenerators[registerType] - generators.forEach((generator) => generator.dispose()) - generators.clear() - } - - const serverData = this._unitData(uuid, unitId) - serverData[registerType] = new Array(65536).fill(0) - this._setServerData(uuid, unitId, serverData) - } - - /** - * Sets a boolean value (coil or discrete input) for a given server and unitId. - * Updates the server data and emits a value change event. - */ - public setBool = ({ uuid, unitId, registerType, address, state }: SetBooleanParameters): void => { - const serverData = this._unitData(uuid, unitId) - serverData[registerType][address] = state - this._setServerData(uuid, unitId, serverData) - this._windows.send( - 'register_value', - { uuid, unitId, registerType, address, value: state }, - 'serverView' - ) - } - - /** - * Resets all boolean values (coils or discrete inputs) for a given server and unitId. - */ - public resetBools = ({ uuid, unitId, registerType }: ResetBoolsParams): void => { - const serverData = this._unitData(uuid, unitId) - serverData[registerType] = new Array(65536).fill(false) - this._setServerData(uuid, unitId, serverData) - } - - /** - * Synchronizes all boolean values (coils and discrete inputs) for a given server and unitId. - */ - public syncBools = (params: SyncBoolsParameters): void => { - const { uuid, unitId } = params - const serverData = this._unitData(uuid, unitId) - params['coils'].forEach((value, index) => (serverData['coils'][index] = value)) - params['discrete_inputs'].forEach((value, index) => { - serverData['discrete_inputs'][index] = value - }) - this._setServerData(uuid, unitId, serverData) - } - - /** - * Reports the RTU server down: the message, and the status the view reads. - * - * A generation that is not the current one belongs to a server `startRtuServer` - * has already replaced, and that server says nothing. An open still in flight - * is what gets here: `stopRtuServer` 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. - */ - private _reportRtuDown(generation: number, message: string, error?: Error): void { - if (generation !== this._rtuGeneration) return - this._rtuActive = false - this._emitMessage({ message, variant: 'error', error }) - this._windows.send('rtu_server_status', { active: false }, 'serverView') - } - - /** - * Starts an RTU server on a serial port for the given UUID. - * Closes any existing RTU server first. - */ - public startRtuServer = async ({ uuid, serialConfig }: StartRtuServerParams): Promise<void> => { - if (!serialConfig.com.trim()) return - await this.stopRtuServer() - this._broadcastWarningSent = false - const generation = ++this._rtuGeneration - - try { - // No unitID on purpose: passing one makes the library answer for that id - // alone. Its default of 255 means "listen to all addresses", and the - // vector filters, because only the vector knows which ids have data. - this._rtuServer = new (ServerSerial as ServerSerialConstructor)( - this._getVector(uuid, 'rtu'), - { - 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. - openCallback: (err): void => { - if (err) this._reportRtuDown(generation, `RTU server error: ${err.message}`) - } - }, - { - dataBits: serialConfig.options.dataBits, - stopBits: serialConfig.options.stopBits - } - ) - this._rtuUuid = uuid - - const serverPort = (this._rtuServer as ServerSerialWithPort).getPort() - - // A write to a port that is gone fails in `_write`, which disconnects the - // stream and calls back with the error, and a Writable given an error - // emits it. A failed open arrives in `openCallback` instead. - serverPort.on('error', (err) => { - this._reportRtuDown(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(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 - // whose port is gone. - serverPort.on('close', (err) => { - // A close this process caused is already reported. `stopRtuServer` - // clears `_rtuActive` before it closes the port, and the `error` - // listener above clears it for the write path, where one unplug emits - // both events. A replaced server is refused on its generation instead. - if (!this._rtuActive) return - this._reportRtuDown(generation, `RTU server disconnected from ${serialConfig.com}`, err) - }) - - this._rtuServer.on('initialized', () => { - // A server that has already been replaced does not get to say it is up. - if (generation !== this._rtuGeneration) return - this._rtuActive = true - this._emitMessage({ - message: `RTU server started on ${serialConfig.com}`, - variant: 'success' - }) - this._windows.send('rtu_server_status', { active: true }, 'serverView') - this._warnBroadcastUnit(uuid) - }) - - // `socketError`, not `error`. `serverserial.js` 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. - this._rtuServer.on('socketError', (err) => { - this._reportRtuDown(generation, `RTU server error: ${err?.message ?? err}`) - }) - } catch (err) { - this._emitMessage({ - message: `Failed to start RTU server: ${(err as Error)?.message ?? err}`, - variant: 'error' - }) - } - } - - /** - * Stops the active RTU server if one is running. - */ - public stopRtuServer = async (): Promise<void> => { - if (!this._rtuServer) return - const server = this._rtuServer - const wasActive = this._rtuActive - this._rtuServer = null - this._rtuUuid = null - this._rtuActive = false - this._rtuGeneration++ - this._broadcastWarningSent = false - this._windows.send('rtu_server_status', { active: false }, 'serverView') - if (wasActive) { - this._emitMessage({ message: 'RTU server stopped', variant: 'warning' }) - } - try { - await new Promise<void>((resolve, reject) => { - server.close((err) => { - if (err) reject(err) - else resolve() - }) - }) - } catch (err) { - const error = err as Error - // "Port is not open" is expected when the serial port never connected — ignore silently - if (error?.message?.includes('Port is not open')) return - console.error('Error closing RTU server:', error?.message, error?.stack) - this._emitMessage({ - message: `Error closing RTU server: ${error?.message ?? err}`, - variant: 'error', - error - }) - } - } - - /** - * Stops all running TCP servers. Does NOT clear server data or generators - * so registers are preserved for restore when switching back to TCP. - */ - public stopAllTcpServers = async (): Promise<void> => { - for (const [, server] of this._servers) { - await new Promise<void>((resolve) => { - server.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - } - this._servers.clear() - this._port.clear() + public resetServer = async (uuid: string): Promise<void> => { + this._registry.clearData(uuid) + const port = this._tcp.portOf(uuid) + if (port) await this._tcp.create({ uuid, port }) } - /** - * Sets the port for a given server UUID. Strict: only tries the exact port, - * no auto-increment. Emits error message on failure and returns the current port. - */ - public setPort = async ({ uuid, port }: CreateServerParams): Promise<number> => { - const requestedPort = port ?? DEFAULT_MOBUS_PORT - const currentPort = this._port.get(uuid) ?? requestedPort - - // Port 0 is not a port, it is a request for whichever one is free, and - // listening on it succeeds. The server would move somewhere nobody can - // 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 - } - - const result = await this._isPortAvailable(requestedPort) - if (!result.available) { - const message = - result.errorCode === 'EACCES' - ? `Port ${requestedPort} requires elevated privileges` - : `Port ${requestedPort} is already in use` - this._emitMessage({ message, variant: 'error' }) - return currentPort - } - - // Port is confirmed available — now close the existing server - await this._closeAndForget(uuid) - - const bind = await this._bindServer(uuid, requestedPort) - if (bind.ok) return requestedPort - - // The probe above passed and the bind still failed, so something took the - // port in between. The old listener is already gone, so put it back rather - // than leave the uuid with none. - this._emitMessage({ message: `Port ${requestedPort} is already in use`, variant: 'error' }) - const restored = await this._bindServer(uuid, currentPort) - if (!restored.ok) { - this._emitMessage({ - message: `The server could not be restarted on port ${currentPort}`, - variant: 'error' - }) - } - return currentPort - } + /** Moves a uuid to the exact port asked for, or says why it did not. */ + public setPort = (params: CreateServerParams): Promise<number> => this._tcp.setPort(params) - // ------------------------------------------------------------------------- - // Vector methods for Modbus register access (used by modbus-serial) - // ------------------------------------------------------------------------- + /** Closes every TCP listener and keeps the registers for a restore. */ + public stopAllTcpServers = (): Promise<void> => this._tcp.stopAll() - /** - * Returns the value of a register type for a given address and unitId. - * Calls the callback with the value or a Modbus error. - */ - private _get: <K extends keyof ServerData>( - registerType: K, - uuid: string, - transport: ServerTransport, - fallback: ServerData[K][number] - ) => IServiceVectorGet<ServerData[K][number]> = - (registerType, uuid, transport, fallback) => async (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. - if (this._isBroadcast(transport, unitId.data)) return - if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, fallback) + /** Sets the byte order this server encodes its registers in. */ + public setEndianness = (params: ServerEndianness): void => this._registry.setEndianness(params) - const value = this._serverData.get(uuid)?.get(unitId.data)?.[registerType][address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, fallback) + /** Adds a register or value generator, and answers the words it now holds. */ + public addRegister = (params: AddRegisterParams): number[] | undefined => + this._registry.addRegister(params) - cb(null, value) - } + /** Removes a register or value generator. */ + public removeRegister = (params: RemoveRegisterParams): void => + this._registry.removeRegister(params) - /** - * Writes a coil or holding register into a unit this server hosts and tells the view. - */ - private _write<K extends RegisterType>( - registerType: K, - uuid: string, - unitId: UnitIdString, - address: number, - value: ServerData[K][number] - ): void { - const serverData = this._serverData.get(uuid)?.get(unitId) - if (!serverData) return - serverData[registerType][address] = value + /** Replaces every register of a unit with the ones given. */ + public syncServerRegisters = (params: SyncRegisterValueParams): void => + this._registry.syncServerRegisters(params) - this._windows.send( - 'register_value', - { - uuid, - unitId, - registerType, - address, - value - } as RegisterValue, - 'serverView' - ) - } + /** Clears one register type of a unit, generators and all. */ + public resetRegisters = (params: ResetRegistersParams): void => + this._registry.resetRegisters(params) - /** - * Sets the value of a coil or holding register for a given address and unitId. - * Updates the server data and emits a value change event. - */ - private _set: <K extends RegisterType>( - registerType: K, - uuid: string, - transport: ServerTransport - ) => IServiceVectorSet<ServerData[K][number]> = - (registerType, uuid, transport) => async (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 + /** Sets a coil or a discrete input. */ + public setBool = (params: SetBooleanParameters): void => this._registry.setBool(params) - // A broadcast write reaches every unit on the bus and is never answered. - if (this._isBroadcast(transport, unitId)) { - for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) - this._write(registerType, uuid, hostedUnitId, address, value) - return - } + /** Clears every coil or every discrete input of a unit. */ + public resetBools = (params: ResetBoolsParams): void => this._registry.resetBools(params) - if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) + /** Replaces both bool maps of a unit with what the renderer holds. */ + public syncBools = (params: SyncBoolsParameters): void => this._registry.syncBools(params) - this._write(registerType, uuid, unitId, address, value) - cb(null) - } + /** Starts an RTU server on a serial port for the given UUID. */ + public startRtuServer = (params: StartRtuServerParams): Promise<void> => this._rtu.start(params) - /** - * Helper for returning a Modbus error via callback and emitting a backend message. - */ - private _mbError<T>(code: number, cb: FCallbackVal<T>, value: T): void { - const err = new Error() - err['modbusErrorCode'] = code - cb(err, value) - } + /** Stops the active RTU server if one is running. */ + public stopRtuServer = (): Promise<void> => this._rtu.stop() } diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/modbusServer/__tests__/valueGenerator.test.ts similarity index 75% rename from src/main/modules/__tests__/valueGenerator.test.ts rename to src/main/modules/modbusServer/__tests__/valueGenerator.test.ts index 876a55be..abec181a 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/modbusServer/__tests__/valueGenerator.test.ts @@ -1,17 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { ValueGenerator } from '../modbusServer/valueGenerator' +import { ValueGenerator } from '../valueGenerator' import type { ServerData, UnitIdString } from '@shared' -import type { Windows } from '../../windows' +import type { Windows } from '../../../windows' const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows const createServerData = (): ServerData => ({ - coils: new Array(65536).fill(false), - discrete_inputs: new Array(65536).fill(false), - input_registers: new Array(65536).fill(0), - holding_registers: new Array(65536).fill(0) + coils: new Map(), + discrete_inputs: new Map(), + input_registers: new Map(), + holding_registers: new Map() }) +/** + * The word the server answers for an address, which is 0 where it holds none. + * + * The vector's `get` reads the map the same way. A register that was removed + * and one that was never written are one answer, and that is the answer the + * arrays gave before the maps. + */ +const wordAt = (serverData: ServerData, address: number): number => + serverData.holding_registers.get(address) ?? 0 + describe('ValueGenerator', () => { beforeEach(() => { vi.useFakeTimers() @@ -41,7 +51,7 @@ describe('ValueGenerator', () => { }) // With min === max === 100, the value should be 100 - expect(serverData.holding_registers[0]).toBe(100) + expect(wordAt(serverData, 0)).toBe(100) expect(windows.send).toHaveBeenCalledWith( 'register_value', expect.objectContaining({ @@ -110,8 +120,8 @@ describe('ValueGenerator', () => { // int32 takes 2 registers // 70000 = 0x00011170 → [1, 4464] - expect(serverData.holding_registers[0]).toBe(1) - expect(serverData.holding_registers[1]).toBe(4464) + expect(wordAt(serverData, 0)).toBe(1) + expect(wordAt(serverData, 1)).toBe(4464) // Should have sent 2 register_value events (one per register) expect(windows.send).toHaveBeenCalledTimes(2) @@ -166,7 +176,7 @@ describe('ValueGenerator', () => { }) // Math.random() = 0.5 → round(0.5 * (100 - 0) + 0, 0) = 50 - expect(serverData.holding_registers[0]).toBe(50) + expect(wordAt(serverData, 0)).toBe(50) gen.dispose() vi.spyOn(Math, 'random').mockRestore() @@ -197,9 +207,9 @@ describe('ValueGenerator', () => { // float 33.33 is written as 2 registers // Verify the value by reading it back from the registers const buf = Buffer.alloc(4) - serverData.holding_registers - .slice(0, 2) - .forEach((register, i) => buf.writeUInt16BE(register, i * 2)) + ;[wordAt(serverData, 0), wordAt(serverData, 1)].forEach((register, i) => + buf.writeUInt16BE(register, i * 2) + ) const readBack = buf.readFloatBE(0) expect(readBack).toBeCloseTo(33.33, 1) @@ -255,9 +265,9 @@ describe('ValueGenerator', () => { comment: '' }) - expect(serverData.holding_registers[5]).toBe(100) + expect(wordAt(serverData, 5)).toBe(100) gen.dispose() - expect(serverData.holding_registers[5]).toBe(0) + expect(wordAt(serverData, 5)).toBe(0) }) it('resets 2 registers for 32-bit types', () => { @@ -279,12 +289,12 @@ describe('ValueGenerator', () => { comment: '' }) - expect(serverData.holding_registers[10]).not.toBe(0) - expect(serverData.holding_registers[11]).not.toBe(0) + expect(wordAt(serverData, 10)).not.toBe(0) + expect(wordAt(serverData, 11)).not.toBe(0) gen.dispose() - expect(serverData.holding_registers[10]).toBe(0) - expect(serverData.holding_registers[11]).toBe(0) + expect(wordAt(serverData, 10)).toBe(0) + expect(wordAt(serverData, 11)).toBe(0) }) it('resets 4 registers for 64-bit types', () => { @@ -307,10 +317,10 @@ describe('ValueGenerator', () => { }) gen.dispose() - expect(serverData.holding_registers[20]).toBe(0) - expect(serverData.holding_registers[21]).toBe(0) - expect(serverData.holding_registers[22]).toBe(0) - expect(serverData.holding_registers[23]).toBe(0) + expect(wordAt(serverData, 20)).toBe(0) + expect(wordAt(serverData, 21)).toBe(0) + expect(wordAt(serverData, 22)).toBe(0) + expect(wordAt(serverData, 23)).toBe(0) }) }) @@ -347,4 +357,34 @@ describe('ValueGenerator', () => { 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 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', () => { + type Params = ConstructorParameters<typeof ValueGenerator>[0] + + const unencodable = (): Params => ({ + uuid: 'test-uuid', + unitId: '1' as UnitIdString, + windows: createMockWindows(), + serverData: createServerData(), + registerType: 'holding_registers' as const, + address: 0, + dataType: 'uint16' as const, + min: 70000, + max: 70000, + littleEndian: false, + interval: 1000, + comment: '' + }) + + it('leaves the constructor by throwing', () => { + expect(() => new ValueGenerator(unencodable())).toThrow('out of range') + }) + }) }) diff --git a/src/main/modules/modbusServer/messages.ts b/src/main/modules/modbusServer/messages.ts new file mode 100644 index 00000000..37a9661d --- /dev/null +++ b/src/main/modules/modbusServer/messages.ts @@ -0,0 +1,21 @@ +import { Windows } from '../../windows' + +/** + * A server message goes to the window showing the server, which in split view + * is the popped out one. Broadcasting put "A server needs a port between 1 and + * 65535" in the window on the client view and in the one that asked. + */ +export const emitServerMessage = ( + windows: Windows, + { + message, + variant, + error + }: { + message: string + variant: 'default' | 'error' | 'success' | 'warning' | 'info' + error?: Error + } +): void => { + windows.send('backend_message', { message, variant, error }, 'serverView') +} diff --git a/src/main/modules/modbusServer/registers.ts b/src/main/modules/modbusServer/registers.ts new file mode 100644 index 00000000..f128c871 --- /dev/null +++ b/src/main/modules/modbusServer/registers.ts @@ -0,0 +1,75 @@ +import { + createRegisters, + createStringRegisters, + DEFAULT_UTF8_LENGTH, + NumberRegisters, + ServerData, + UnitIdString, + ValuedDataType +} from '@shared' +import { Windows } from '../../windows' + +/** + * The words a value encodes to, which is not the same question as how many. + * `registerWidth` answers that one. + * + * Two writers reach it: `ServerRegistry.addRegister` for a fixed register, and + * `ValueGenerator` on every tick. One function rather than the pair of encoders + * spelled out at each, because what two copies do with a throw drifts apart. + * `ServerRegistry._encode` is the `try` around it; 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].set(registerAddress, register) + windows.send( + 'register_value', + { uuid, unitId, registerType, address: registerAddress, value: register }, + 'serverView' + ) + }) +} diff --git a/src/main/modules/modbusServer/registry.ts b/src/main/modules/modbusServer/registry.ts new file mode 100644 index 00000000..f001c4a9 --- /dev/null +++ b/src/main/modules/modbusServer/registry.ts @@ -0,0 +1,465 @@ +import { + AddRegisterParams, + RegisterType, + RegisterValue, + RemoveRegisterParams, + ResetBoolsParams, + ResetRegistersParams, + ServerData, + ServerDataValue, + ServerEndianness, + SetBooleanParameters, + SyncBoolsParameters, + SyncRegisterValueParams, + UnitIdString, + ValueGenerators, + registerWidth +} from '@shared' +import { Windows } from '../../windows' +import { emitServerMessage } from './messages' +import { encodeRegisters, writeRegisters } from './registers' +import { ValueGenerator } from './valueGenerator' + +const getDefaultGenerators = (): ValueGenerators => ({ + input_registers: new Map(), + holding_registers: new Map() +}) + +const getDefaultServerData = (): ServerData => ({ + coils: new Map(), + discrete_inputs: new Map(), + input_registers: new Map(), + holding_registers: new Map() +}) + +/** + * Keeps the addresses the array holds true and drops the rest. + * + * `syncBools` is the caller, and it replaces what the unit held rather than + * adding to it. + */ +const setBoolsFromArray = (bools: Map<number, boolean>, states: boolean[]): void => { + bools.clear() + for (const [address, state] of states.entries()) if (state) bools.set(address, state) +} + +type ServerDataUnitMap = Map<UnitIdString, ServerData> +type ValueGeneratorsUnitMap = Map<UnitIdString, ValueGenerators> + +type ServerDataMap = Map<string, ServerDataUnitMap> +type ValueGeneratorsMap = Map<string, ValueGeneratorsUnitMap> + +interface ServerRegistryParams { + windows: Windows + /** + * Called with the uuid whose data was just written. + * + * `ModbusServer` owns what that means, because the answer reads the RTU + * server's state as well as this one's. + */ + onUnitData: (uuid: string) => void +} + +/** + * What every server holds, keyed by uuid and then by unit id. + * + * The three maps are the server's state and nothing else here is. A transport + * decides who may ask; the vector decides what an address answers; this decides + * what is there. `ModbusServer` is what holds all four. + */ +export class ServerRegistry { + private _windows: Windows + private _onUnitData: (uuid: string) => void + + /** + * The byte order each server encodes its registers in, big-endian until told. + * + * It is set once here and read where a register is encoded, rather than + * riding along on every add and every sync, which is the same field of the + * same server read again each time. + */ + private _littleEndian: Map<string, boolean> = new Map() + private _serverData: ServerDataMap = new Map() + private _generatorMap: ValueGeneratorsMap = new Map() + + constructor({ windows, onUnitData }: ServerRegistryParams) { + this._windows = windows + this._onUnitData = onUnitData + } + + /** + * Ensures an inner map exists for a given UUID in the outer map, creating it if necessary. + */ + private _ensureInnerMap<K extends UnitIdString, V>( + outerMap: Map<string, Map<K, V>>, + uuid: string + ): Map<K, V> { + let inner = outerMap.get(uuid) + if (!inner) { + inner = new Map() + outerMap.set(uuid, inner) + } + return inner + } + + /** + * Helper to set server data for a unitId in the server data map. + */ + private _setServerData(uuid: string, unitId: UnitIdString, serverData: ServerData): void { + const perUnitMap = this._ensureInnerMap(this._serverData, uuid) + perUnitMap.set(unitId, serverData) + this._onUnitData(uuid) + } + + /** + * Helper to dispose all value generators in a ValueGeneratorsUnitMap. + * This stops all intervals and clears the generator maps. + */ + private _disposeAllGenerators(unitMap: ValueGeneratorsUnitMap): void { + for (const registerTypeGenerators of unitMap.values()) { + registerTypeGenerators.holding_registers.forEach((g) => g.dispose()) + registerTypeGenerators.input_registers.forEach((g) => g.dispose()) + } + } + + /** + * The words a fixed register holds, or nothing at all when it cannot be + * encoded. + * + * `RegisterParamsSchema` bounds the pair of `dataType` and `value` at the IPC + * boundary, and `getValueRangeError` there reads the same table this asks + * 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 = (params: Parameters<typeof encodeRegisters>[0]): number[] | undefined => { + try { + return encodeRegisters(params) + } catch { + return undefined + } + } + + private _unitData = (uuid: string, unitId: UnitIdString): ServerData => { + const perUnitMap = this._ensureInnerMap(this._serverData, uuid) + const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() + if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + return serverData + } + + /** + * A unit id is one of ours when it has data under this uuid. The Select + * offers all 256, and nothing but a register makes one of them exist. + */ + public hostsUnit(uuid: string, unitId: UnitIdString): boolean { + return this._serverData.get(uuid)?.has(unitId) ?? false + } + + /** The unit ids this uuid hosts, which is what a broadcast write reaches. */ + public hostedUnitIds(uuid: string): Iterable<UnitIdString> { + return this._serverData.get(uuid)?.keys() ?? [] + } + + /** What an address holds, or `undefined` where nothing was written to it. */ + public read<K extends RegisterType>( + uuid: string, + unitId: UnitIdString, + registerType: K, + address: number + ): ServerDataValue<K> | undefined { + return this._serverData.get(uuid)?.get(unitId)?.[registerType].get(address) + } + + /** + * Writes a coil or holding register into a unit this server hosts and tells the view. + */ + public write<K extends RegisterType>( + registerType: K, + uuid: string, + unitId: UnitIdString, + address: number, + value: ServerDataValue<K> + ): void { + const serverData = this._serverData.get(uuid)?.get(unitId) + if (!serverData) return + serverData[registerType].set(address, value) + + this._windows.send( + 'register_value', + { + uuid, + unitId, + registerType, + address, + value + } as RegisterValue, + 'serverView' + ) + } + + /** Frees everything a uuid holds, which is what deleting a server does. */ + public deleteUuid(uuid: string): void { + this.clearData(uuid) + this._littleEndian.delete(uuid) + } + + /** + * Frees the registers and the generators a uuid holds, and keeps its byte + * order, which is a setting of the server rather than of its data. + */ + public clearData(uuid: string): void { + const unitIdGenerators = this._generatorMap.get(uuid) + if (unitIdGenerators) { + this._disposeAllGenerators(unitIdGenerators) + } + this._generatorMap.delete(uuid) + this._serverData.delete(uuid) + } + + /** Sets the byte order this server encodes its registers in. */ + public setEndianness = ({ uuid, littleEndian }: ServerEndianness): void => { + this._littleEndian.set(uuid, littleEndian) + } + + /** + * Adds a register or value generator for a given server and unitId. + * If a generator already exists at the address, it is disposed and replaced. + * If a fixed value is provided, sets the register directly. + * + * Answers the words now held from `address` on, which is an empty list for + * `none` because that writes none. The renderer's store waits for this answer + * before it writes, and every `register_value` below goes out before the + * answer does, so those words would otherwise reach a store with no entry to + * put them in and be dropped. The store folds what comes back through the same + * merge the event feeds, which is where a word becomes a value. + * + * `undefined` is the refusal, which is what the store already reads as + * nothing changed. An encoder that cannot take the register answers that + * rather than throwing: `createIpcHandle` puts no try around a listener, so + * a throw would reject the invoke rather than answer it, and + * `syncServerRegisters` adds in a bare loop, so it would take every register + * after it in that unit with it. One register is refused and the rest of the + * unit stands. + * + * 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 + const { + address, + registerType, + dataType, + min, + max, + interval, + value, + comment, + stringValue, + length + } = params + + // Ensure generator map for this server and unitId + const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( + this._generatorMap, + uuid + ) + const serverGenerators = perUnitGeneratorMap.get(unitId) ?? getDefaultGenerators() + + if (!perUnitGeneratorMap.has(unitId)) { + perUnitGeneratorMap.set(unitId, serverGenerators) + } + + const generators = serverGenerators[registerType] + + /** Frees the register this call is replacing, and answers its data map. */ + const takeTheAddress = (): ServerData => { + generators.get(address)?.dispose() + generators.delete(address) + const serverData = this._unitData(uuid, unitId) + this._setServerData(uuid, unitId, serverData) + return serverData + } + + // `none` is an address held open with nothing in it, so there is nothing to + // write and nothing to generate. The generator is disposed either way, + // which is what editing a register to `none` has to do. + if (dataType === 'none') { + takeTheAddress() + return [] + } + + // If a fixed value is provided, set the register directly + const fixedValue = !interval && value !== undefined + if (fixedValue) { + // Encoded before the address is taken, because a refusal answers + // `undefined` and the store reads that as nothing changed. Disposing + // first would zero the words of the generator being replaced and drop it, + // leaving the grid drawing a generator that does not run. + const registers = this._encode({ dataType, value, littleEndian, stringValue, length }) + if (!registers) { + emitServerMessage(this._windows, { + message: `The ${dataType} register at ${address} was not added: main cannot encode that value`, + variant: 'error' + }) + return undefined + } + + const serverData = takeTheAddress() + writeRegisters({ + windows: this._windows, + serverData, + uuid, + unitId, + registerType, + address, + registers + }) + this._setServerData(uuid, unitId, serverData) + return registers + } + + // Otherwise, add a value generator for this register + const serverData = takeTheAddress() + generators.set( + address, + new ValueGenerator({ + uuid, + unitId, + windows: this._windows, + serverData, + address, + dataType, + min, + max, + interval, + littleEndian, + registerType, + comment, + stringValue, + length + }) + ) + + // `ValueGenerator` writes its first value from its own constructor, so this + // reads what it just put there rather than answering nothing for a minute. + const width = registerWidth(dataType, length) + return Array.from({ length: width }, (_, i) => serverData[registerType].get(address + i) ?? 0) + } + + /** + * Removes a register or value generator for a given server and unitId. + * Disposes the generator if it exists and resets the register value. + */ + public removeRegister = ({ + uuid, + unitId, + registerType, + address, + dataType, + length + }: RemoveRegisterParams): void => { + const serverData = this._unitData(uuid, unitId) + + // Reset all registers occupied by this data type + // The words go rather than turn zero. A read answers 0 for an address with + // no entry, so the two are the same answer and only one of them is paid for. + const registerCount = registerWidth(dataType, length) + for (let i = 0; i < registerCount; i++) { + serverData[registerType].delete(address + i) + } + + const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( + this._generatorMap, + uuid + ) + const serverGenerators = perUnitGeneratorMap.get(unitId) + if (!serverGenerators) return + const generator = serverGenerators[registerType].get(address) + if (!generator) return + generator.dispose() + serverGenerators[registerType].delete(address) + } + + /** + * 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 => { + this.resetRegisters({ uuid, unitId, registerType: 'holding_registers' }) + this.resetRegisters({ uuid, unitId, registerType: 'input_registers' }) + for (const params of registerValues) this.addRegister({ uuid, unitId, params }) + } + + /** + * Resets all registers of a given type for a server and unitId. + * Disposes all generators for that register type and clears the register data. + */ + public resetRegisters = ({ uuid, unitId, registerType }: ResetRegistersParams): void => { + // Dispose and clear only generators for this unitId and registerType + const perUnitGeneratorMap = this._ensureInnerMap<UnitIdString, ValueGenerators>( + this._generatorMap, + uuid + ) + const serverGenerators = perUnitGeneratorMap.get(unitId) + if (serverGenerators) { + const generators = serverGenerators[registerType] + generators.forEach((generator) => generator.dispose()) + generators.clear() + } + + const serverData = this._unitData(uuid, unitId) + serverData[registerType].clear() + this._setServerData(uuid, unitId, serverData) + } + + /** + * Sets a boolean value (coil or discrete input) for a given server and unitId. + * Updates the server data and emits a value change event. + */ + public setBool = ({ uuid, unitId, registerType, address, state }: SetBooleanParameters): void => { + const serverData = this._unitData(uuid, unitId) + serverData[registerType].set(address, state) + this._setServerData(uuid, unitId, serverData) + this._windows.send( + 'register_value', + { uuid, unitId, registerType, address, value: state }, + 'serverView' + ) + } + + /** + * Resets all boolean values (coils or discrete inputs) for a given server and unitId. + */ + public resetBools = ({ uuid, unitId, registerType }: ResetBoolsParams): void => { + const serverData = this._unitData(uuid, unitId) + serverData[registerType].clear() + this._setServerData(uuid, unitId, serverData) + } + + /** + * Synchronizes all boolean values (coils and discrete inputs) for a given server and unitId. + */ + public syncBools = (params: SyncBoolsParameters): void => { + const { uuid, unitId } = params + const serverData = this._unitData(uuid, unitId) + // The renderer sends both arrays whole, 65536 entries of which the ones it + // holds are true. Only those are kept: a false is what an address with no + // entry already reads as. + setBoolsFromArray(serverData['coils'], params['coils']) + setBoolsFromArray(serverData['discrete_inputs'], params['discrete_inputs']) + this._setServerData(uuid, unitId, serverData) + } +} diff --git a/src/main/modules/modbusServer/rtu.ts b/src/main/modules/modbusServer/rtu.ts new file mode 100644 index 00000000..b4a09b16 --- /dev/null +++ b/src/main/modules/modbusServer/rtu.ts @@ -0,0 +1,238 @@ +import { DataBits, StartRtuServerParams, StopBits } from '@shared' +import { ServerSerial } from 'modbus-serial' +import { Windows } from '../../windows' +import { emitServerMessage } from './messages' +import { ServerRegistry } from './registry' +import { BROADCAST_UNIT_ID, createVector } from './vector' + +/** + * The serial port under a `ServerSerial`, with the events this file listens for. + * + * `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 +} + +interface ServerSerialWithPort extends ServerSerial { + getPort(): RtuSerialPort +} + +/** + * `ServerSerial`'s third constructor argument, which its typings leave out. + * + * `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 + stopBits: StopBits +} + +type ServerSerialConstructor = new ( + ...args: [...ConstructorParameters<typeof ServerSerial>, ServerSerialPortOptions] +) => ServerSerial + +interface RtuServerParams { + windows: Windows + registry: ServerRegistry +} + +/** + * The one RTU server, which is a serial port and the uuid whose registers it + * serves. + * + * There is one because RS-485 is one bus: a second `ServerSerial` on the same + * adapter would answer over the first. `start` replaces what is running. + */ +export class RtuServer { + private _windows: Windows + private _registry: ServerRegistry + + private _server: ServerSerial | null = null + private _uuid: string | null = null + private _active: boolean = false + private _generation: number = 0 + private _broadcastWarningSent: boolean = false + + constructor({ windows, registry }: RtuServerParams) { + this._windows = windows + this._registry = registry + } + + /** Whether the RTU server is running, for a window that has to ask. */ + public get active(): boolean { + return this._active + } + + /** The uuid this server answers for, or null when none is running. */ + public get uuid(): string | null { + return this._uuid + } + + /** + * Says once per RTU session that registers on unit 0 are unreachable. + * + * The renderer opens the port before it syncs registers, so on a fresh start + * the data arrives after `initialized` and on a config load it is already + * there. Hence the two call sites, and the flag that keeps them to one + * message. + */ + public warnBroadcastUnit(uuid: string): void { + if (!this._active || this._uuid !== uuid) return + if (this._broadcastWarningSent) return + if (!this._registry.hostsUnit(uuid, BROADCAST_UNIT_ID)) return + + this._broadcastWarningSent = true + emitServerMessage(this._windows, { + message: 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.', + variant: 'warning' + }) + } + + /** + * Reports the RTU server down: the message, and the status the view reads. + * + * 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` (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 + this._active = false + emitServerMessage(this._windows, { message, variant: 'error', error }) + this._windows.send('rtu_server_status', false, 'serverView') + } + + /** + * Starts an RTU server on a serial port for the given UUID. + * Closes any existing RTU server first. + */ + public start = async ({ uuid, serialConfig }: StartRtuServerParams): Promise<void> => { + if (!serialConfig.com.trim()) return + await this.stop() + this._broadcastWarningSent = false + const generation = ++this._generation + + try { + // No unitID on purpose: passing one makes the library answer for that id + // alone. Its default of 255 means "listen to all addresses", and the + // vector filters, because only the vector knows which ids have data. + this._server = new (ServerSerial as ServerSerialConstructor)( + createVector(this._registry, uuid, 'rtu'), + { + path: serialConfig.com, + baudRate: Number(serialConfig.options.baudRate), + parity: serialConfig.options.parity ?? 'none', + // `@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}`) + } + }, + { + dataBits: serialConfig.options.dataBits, + stopBits: serialConfig.options.stopBits + } + ) + this._uuid = uuid + + const serverPort = (this._server as ServerSerialWithPort).getPort() + + // A write to a port that is gone fails in `_write`, which disconnects the + // stream and calls back with the error, and a Writable given an error + // emits it. A failed open arrives in `openCallback` instead. + serverPort.on('error', (err) => { + this._reportDown(generation, `RTU server error: ${err?.message ?? err}`) + }) + + // `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 + // whose port is gone. + serverPort.on('close', (err) => { + // A close this process caused is already reported. `stop` clears + // `_active` before it closes the port, and the `error` listener above + // clears it for the write path, where one unplug emits both events. A + // replaced server is refused on its generation instead. + if (!this._active) return + this._reportDown(generation, `RTU server disconnected from ${serialConfig.com}`, err) + }) + + this._server.on('initialized', () => { + // A server that has already been replaced does not get to say it is up. + if (generation !== this._generation) return + this._active = true + emitServerMessage(this._windows, { + message: `RTU server started on ${serialConfig.com}`, + variant: 'success' + }) + this._windows.send('rtu_server_status', true, 'serverView') + this.warnBroadcastUnit(uuid) + }) + + // `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. + this._server.on('socketError', (err) => { + this._reportDown(generation, `RTU server error: ${err?.message ?? err}`) + }) + } catch (err) { + emitServerMessage(this._windows, { + message: `Failed to start RTU server: ${(err as Error)?.message ?? err}`, + variant: 'error' + }) + } + } + + /** + * Stops the active RTU server if one is running. + */ + public stop = async (): Promise<void> => { + if (!this._server) return + const server = this._server + const wasActive = this._active + this._server = null + this._uuid = null + this._active = false + this._generation++ + this._broadcastWarningSent = false + this._windows.send('rtu_server_status', false, 'serverView') + if (wasActive) { + emitServerMessage(this._windows, { message: 'RTU server stopped', variant: 'warning' }) + } + try { + await new Promise<void>((resolve, reject) => { + server.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + } catch (err) { + const error = err as Error + // "Port is not open" is expected when the serial port never connected — ignore silently + if (error?.message?.includes('Port is not open')) return + console.error('Error closing RTU server:', error?.message, error?.stack) + emitServerMessage(this._windows, { + message: `Error closing RTU server: ${error?.message ?? err}`, + variant: 'error', + error + }) + } + } +} diff --git a/src/main/modules/modbusServer/tcp.ts b/src/main/modules/modbusServer/tcp.ts new file mode 100644 index 00000000..c979f975 --- /dev/null +++ b/src/main/modules/modbusServer/tcp.ts @@ -0,0 +1,256 @@ +import { CreateServerParams, DEFAULT_MODBUS_PORT } from '@shared' +import { ServerTCP } from 'modbus-serial' +import net from 'net' +import { Windows } from '../../windows' +import { emitServerMessage } from './messages' +import { ServerRegistry } from './registry' +import { createVector } from './vector' + +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. + * + * `ServerTCP` answers a bind with `initialized` or `serverError`, so nobody + * sits through this. It is here because a promise that neither event resolves + * would hang `create` and every caller behind it. + */ +export const BIND_TIMEOUT_MS = 5000 + +interface TcpServersParams { + windows: Windows + registry: ServerRegistry +} + +/** + * The TCP listeners, one per uuid, and the port each one is on. + * + * The two maps have the same key set: `_bindServer` writes both on consecutive + * lines and `closeAndForget` deletes both, so a stored port means a listener on + * that port. + */ +export class TcpServers { + private _windows: Windows + private _registry: ServerRegistry + + private _port: Map<string, number> = new Map() + private _servers: Map<string, ServerTCP> = new Map() + + constructor({ windows, registry }: TcpServersParams) { + this._windows = windows + this._registry = registry + } + + /** The port a uuid is listening on, or nothing where it has no listener. */ + public portOf(uuid: string): number | undefined { + return this._port.get(uuid) + } + + /** + * Checks if a TCP port is available for binding. + * Returns an object with availability and optional error code (e.g. EACCES, EADDRINUSE). + */ + private async _isPortAvailable( + port: number + ): Promise<{ available: boolean; errorCode?: string }> { + return new Promise((resolve) => { + const tester = net.createServer() + tester.once('error', (err: NodeJS.ErrnoException) => { + resolve({ available: false, errorCode: err.code }) + }) + tester.once('listening', () => { + tester.close(() => resolve({ available: true })) + }) + tester.listen(port, '0.0.0.0') + }) + } + + /** + * Closes the listener registered for a UUID and forgets it, if there is one. + * + * `ServerTCP.close` destroys every socket in `modbus.socks`, so whoever was + * connected gets a FIN. + */ + public async closeAndForget(uuid: string): Promise<void> { + const existingServer = this._servers.get(uuid) + if (!existingServer) return + await new Promise<void>((resolve) => { + existingServer.close((err) => { + if (err) + emitServerMessage(this._windows, { + message: 'Error closing server', + variant: 'error', + error: err + }) + resolve() + }) + }) + this._servers.delete(uuid) + this._port.delete(uuid) + } + + /** + * Binds a TCP listener for a UUID and answers what the socket did. + * + * The constructor returns before `listen` has finished, and a refused bind + * arrives as a `serverError` carrying `EADDRINUSE` rather than as a throw. A + * constructor that returned is therefore no evidence of a listener, so the + * maps are written only once one of the two events has said so. + */ + private async _bindServer( + uuid: string, + port: number + ): Promise<{ ok: boolean; errorCode?: string }> { + const server = new ServerTCP(createVector(this._registry, uuid, 'tcp'), { + host: '0.0.0.0', + port + }) + + const result = await new Promise<{ ok: boolean; errorCode?: string }>((resolve) => { + const timer = setTimeout( + () => resolve({ ok: false, errorCode: 'ETIMEDOUT' }), + BIND_TIMEOUT_MS + ) + server.on('initialized', () => { + clearTimeout(timer) + resolve({ ok: true }) + }) + server.on('serverError', (err) => { + clearTimeout(timer) + resolve({ ok: false, errorCode: (err as NodeJS.ErrnoException | null)?.code }) + }) + }) + + if (!result.ok) { + server.close(() => {}) + return result + } + + this._servers.set(uuid, server) + this._port.set(uuid, port) + return result + } + + /** + * Creates a Modbus TCP server for the given UUID and port. + * Returns the actual port used (may differ from requested if taken). + * + * A listener already on the requested port is the answer to this call. The + * vectors read the registry when a request arrives rather than when they are + * 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 create = async ({ uuid, port }: CreateServerParams): Promise<number | undefined> => { + // 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_MODBUS_PORT + const maxAttempts = 10000 + + 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++) { + // 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) + if (bind.ok) return actualPort + } + actualPort++ + } + emitServerMessage(this._windows, { + message: 'No available port found', + 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 + + emitServerMessage(this._windows, { + message: `The server could not be restarted on port ${previousPort}`, + variant: 'error' + }) + return undefined + } + + /** + * 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 stopAll = async (): Promise<void> => { + for (const uuid of [...this._servers.keys()]) await this.closeAndForget(uuid) + } + + /** + * Sets the port for a given server UUID. Strict: only tries the exact port, + * no auto-increment. Emits error message on failure and returns the current port. + */ + public setPort = async ({ uuid, port }: CreateServerParams): Promise<number> => { + 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 + // listening on it succeeds. The server would move somewhere nobody can + // name, and the number sent back to the view would be the 0 it asked for. + if (!isPort(requestedPort)) { + emitServerMessage(this._windows, { + message: 'A server needs a port between 1 and 65535', + variant: 'error' + }) + return this._port.get(uuid) ?? DEFAULT_MODBUS_PORT + } + + const result = await this._isPortAvailable(requestedPort) + if (!result.available) { + const message = + result.errorCode === 'EACCES' + ? `Port ${requestedPort} requires elevated privileges` + : `Port ${requestedPort} is already in use` + emitServerMessage(this._windows, { message, variant: 'error' }) + return currentPort + } + + // Port is confirmed available — now close the existing server + await this.closeAndForget(uuid) + + const bind = await this._bindServer(uuid, requestedPort) + if (bind.ok) return requestedPort + + // The probe above passed and the bind still failed, so something took the + // port in between. The old listener is already gone, so put it back rather + // than leave the uuid with none. + emitServerMessage(this._windows, { + message: `Port ${requestedPort} is already in use`, + variant: 'error' + }) + const restored = await this._bindServer(uuid, currentPort) + if (!restored.ok) { + emitServerMessage(this._windows, { + message: `The server could not be restarted on port ${currentPort}`, + variant: 'error' + }) + } + return currentPort + } +} diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 395eea68..5a2990f4 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 = { @@ -20,7 +20,7 @@ type ValueGeneratorParams = { windows: Windows serverData: ServerData littleEndian: boolean - // `modbusServer.addRegister` answers `none` before it gets here, because an + // `ServerRegistry.addRegister` answers `none` before it gets here, because an // address held open with nothing in it has nothing to generate. dataType: ValuedDataType } & RegisterParamsGeneratorPart & @@ -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() @@ -93,10 +93,12 @@ export class ValueGenerator implements RegisterValueGenerator { public dispose = (): void => { clearInterval(this._intervalTimer) + // The words go rather than turn zero. A read answers 0 for an address with + // no entry, so the two are the same answer and only one of them is stored. const size = registerWidth(this._dataType, this._length) for (let i = 0; i < size; i++) { - this._serverData[this._registerType][this._address + i] = 0 + this._serverData[this._registerType].delete(this._address + i) } } @@ -104,32 +106,31 @@ 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 + }) }) } /** * 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<void> => { + private _updateValue = (): void => { switch (this._dataType) { case 'unix': { // Generator: current system time (seconds since epoch) diff --git a/src/main/modules/modbusServer/vector.ts b/src/main/modules/modbusServer/vector.ts new file mode 100644 index 00000000..0a3d87c6 --- /dev/null +++ b/src/main/modules/modbusServer/vector.ts @@ -0,0 +1,159 @@ +import { + MAX_REGISTER_ADDRESS, + RegisterType, + ServerDataValue, + UnitIdString, + UnitIdStringSchema +} from '@shared' +import type { FCallback, FCallbackVal, IServiceVector } from 'modbus-serial' +import { ServerRegistry } from './registry' + +/** + * The three Modbus exception codes this server sends. + * + * The other seven of the protocol's table are not here: an exported constant + * nothing names costs a lint disable, which is a worse comment than none. + * `modbusServer.test.ts` asserts on all three of these. + */ +export const ILLEGAL_DATA_ADDRESS = 2 +export const SERVER_DEVICE_FAILURE = 4 +export const GATEWAY_TARGET_FAILED = 11 + +/** + * The transport a vector answers on. RS-485 is shared and a socket is not, so a + * request for a unit id this server does not host cannot get the same answer on + * both. + */ +export type ServerTransport = 'tcp' | 'rtu' + +/** Unit 0 is the broadcast address on RTU. */ +export const BROADCAST_UNIT_ID: UnitIdString = '0' + +/** + * One accessor shape per direction, because `IServiceVector`'s four getters and + * two setters differ only in the value they carry. + * + * These are written out rather than derived from `IServiceVector`, so what + * checks them is the assignment in `createVector`. That catches a signature + * modbus-serial changes incompatibly and not one it widens, because a version + * that adds an optional parameter stays assignable to these. + */ +type IServiceVectorGet<T> = (addr: number, unitID: number, cb: FCallbackVal<T>) => void +type IServiceVectorSet<T> = (addr: number, value: T, unitID: number, cb: FCallback) => void + +/** + * Helper for returning a Modbus error via callback. + */ +const mbError = <T>(code: number, cb: FCallbackVal<T>, value: T): void => { + const err = new Error() + err['modbusErrorCode'] = code + cb(err, value) +} + +/** + * Unit 0 is broadcast on RTU. On TCP there is no broadcast at all and the + * unit identifier routes through a gateway, so 0 is an address like any other. + */ +const isBroadcast = (transport: ServerTransport, unitId: UnitIdString): boolean => + transport === 'rtu' && unitId === BROADCAST_UNIT_ID + +/** + * Answers a request for a unit id this server does not host. + * + * modbus-serial writes a frame when the vector calls `cb` and writes nothing + * when it does not, so returning without calling it is silence on the wire. + * On RS-485 silence is the only safe answer: the id belongs to a real device + * answering at that moment, and a second frame collides with it. A socket + * carries one device, so silence there is a client timeout instead, and the + * gateway code says what happened. + */ +const refuseUnit = <T>(transport: ServerTransport, cb: FCallbackVal<T>, value: T): void => { + if (transport === 'rtu') return + mbError(GATEWAY_TARGET_FAILED, cb, value) +} + +/** + * 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. + */ +const get = + <K extends RegisterType>( + registry: ServerRegistry, + registerType: K, + uuid: string, + transport: ServerTransport, + fallback: ServerDataValue<K> + ): IServiceVectorGet<ServerDataValue<K>> => + (address, unitIdNumber, cb) => { + const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) + if (!unitId.success) return mbError(SERVER_DEVICE_FAILURE, cb, fallback) + // A broadcast is never acknowledged, so there is nothing to read from one. + if (isBroadcast(transport, unitId.data)) return + if (!registry.hostsUnit(uuid, unitId.data)) return refuseUnit(transport, cb, fallback) + + // A multi-word read at the top of the range asks for addresses the + // protocol cannot express. The arrays answered `undefined` past their + // last index and a map answers it for every address it has no entry for, + // so the range is asked here and the entry only after. + if (address > MAX_REGISTER_ADDRESS) return mbError(ILLEGAL_DATA_ADDRESS, cb, fallback) + + const value = registry.read(uuid, unitId.data, registerType, address) + + // An address inside the range with no entry is a register nobody + // configured, and the arrays answered 0 or false for it. + cb(null, value ?? fallback) + } + +/** + * Sets the value of a coil or holding register for a given address and unitId. + * Updates the server data and emits a value change event. + */ +const set = + <K extends RegisterType>( + registry: ServerRegistry, + registerType: K, + uuid: string, + transport: ServerTransport + ): IServiceVectorSet<ServerDataValue<K>> => + (address, value, unitIdNumber, cb) => { + const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) + if (!unitIdSafe.success) return mbError(SERVER_DEVICE_FAILURE, cb, 0) + const unitId = unitIdSafe.data + + // A broadcast write reaches every unit on the bus and is never answered. + if (isBroadcast(transport, unitId)) { + for (const hostedUnitId of registry.hostedUnitIds(uuid)) + registry.write(registerType, uuid, hostedUnitId, address, value) + return + } + + if (!registry.hostsUnit(uuid, unitId)) return refuseUnit(transport, cb, 0) + + registry.write(registerType, uuid, unitId, address, value) + cb(null) + } + +/** + * The six accessors modbus-serial calls when a frame arrives. + * + * Built once per listener and bound to a uuid, and each accessor reads the + * registry at the moment it is called. That is why a register added after a + * server is up needs no rebind. + */ +export const createVector = ( + registry: ServerRegistry, + uuid: string, + transport: ServerTransport +): IServiceVector => ({ + getCoil: get(registry, 'coils', uuid, transport, false), + getDiscreteInput: get(registry, 'discrete_inputs', uuid, transport, false), + getInputRegister: get(registry, 'input_registers', uuid, transport, 0), + getHoldingRegister: get(registry, 'holding_registers', uuid, transport, 0), + setCoil: set(registry, 'coils', uuid, transport), + setRegister: set(registry, 'holding_registers', uuid, transport) +}) diff --git a/src/main/modules/privilegedPort.ts b/src/main/modules/privilegedPort.ts index fe8a113d..6edfe500 100644 --- a/src/main/modules/privilegedPort.ts +++ b/src/main/modules/privilegedPort.ts @@ -154,9 +154,9 @@ export const applyPrivilegedPortFix = async ( } } - // Trust the kernel over the exit code: re-read what is actually in effect. - // A pkexec that exits 0 without lowering the floor used to produce a green - // snackbar and a server that still could not bind 502. + // Trust the kernel over the exit code: re-read what is actually in effect. A + // pkexec that exits 0 without lowering the floor would otherwise produce a + // green snackbar and a server that still cannot bind 502. const unprivilegedPortStart = await readUnprivilegedPortStart() if (unprivilegedPortStart === undefined) { diff --git a/src/main/state.ts b/src/main/state.ts index 015f7193..e13d20eb 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -39,12 +39,28 @@ export class AppState { private _registerConfig = structuredClone(defaultRegisterConfig) private _registerMapping?: RegisterMapping private _readConfiguration = false + private _readGeneration = 0 - constructor() { - /** No Construction */ + /** + * How often what a read is addressed to has changed. + * + * A read puts its requests on the wire under the unit id, register type, + * address and length this state held when it started, and read configuration + * decides whether the mapping or the toolbar's block is what it asks for. + * Change one of those while the read is in flight and the reply describes the + * old one, so `_read` takes this number before its first request and drops + * what comes back once it has moved. + * + * The rest of both configs is not counted. A poll rate, a timeout or an + * address base changes what the grid does with a read or when the next one + * goes out, not what this one asked the device. + */ + get readGeneration(): number { + return this._readGeneration } public updateConnectionConfig(config: DeepPartial<ConnectionConfig>): void { + if (config.unitId !== undefined) this._readGeneration++ this._connectionConfig = merge<ConnectionConfig, DeepPartial<ConnectionConfig>>( this._connectionConfig, withoutUndefined(config) @@ -52,6 +68,8 @@ export class AppState { } public updateRegisterConfig(config: DeepPartial<RegisterConfig>): void { + if (config.type !== undefined || config.address !== undefined || config.length !== undefined) + this._readGeneration++ this._registerConfig = merge<RegisterConfig, DeepPartial<RegisterConfig>>( this._registerConfig, withoutUndefined(config) @@ -59,6 +77,7 @@ export class AppState { } public setRegisterMapping(mapping: RegisterMapping): void { + this._readGeneration++ this._registerMapping = mapping } @@ -75,6 +94,7 @@ export class AppState { } public setReadConfiguration(value: boolean): void { + this._readGeneration++ this._readConfiguration = value } diff --git a/src/main/windows.ts b/src/main/windows.ts index 06dc31e2..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 { @@ -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,23 +45,15 @@ 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` * first, so without it a stale main handle is what the server window's * `window_update` goes missing behind. */ - public send = <E extends IpcEvent>( + public send = <E extends EventToRenderer>( event: E, payload: IpcEventPayloadMap[E][0], to?: IpcEventTarget 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' }} > + <UndoKeys /> <UpdateBanner /> <Box sx={{ flex: 1, overflow: 'hidden' }}> {appType === 'client' ? <Client /> : appType === 'server' ? <Server /> : <Home />} diff --git a/src/renderer/src/components/UndoKeys.tsx b/src/renderer/src/components/UndoKeys.tsx new file mode 100644 index 00000000..56a48296 --- /dev/null +++ b/src/renderer/src/components/UndoKeys.tsx @@ -0,0 +1,103 @@ +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 { 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. */ +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) +} + +/** + * 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<UndoOutcome>>> = { + 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 => { + const { enqueueSnackbar } = useSnackbar() + + 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]().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 +}) + +export default UndoKeys diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 6b9bf8ee..ae10a367 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<void> => { try { + // 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 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..ad8e7d36 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<typeof vi.fn> @@ -20,6 +21,7 @@ describe('UpdateBanner', () => { beforeEach(() => { vi.clearAllMocks() sessionStorage.clear() + asServerWindow(false) }) afterEach(() => { @@ -211,6 +213,25 @@ describe('UpdateBanner', () => { consoleSpy.mockRestore() }) + // 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') + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + tag_name: 'v1.5.0', + html_url: 'https://github.com/ploxc/modbux/releases/tag/v1.5.0' + }) + }) + + render(<UpdateBanner />) + + 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({ 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..726f9d4d --- /dev/null +++ b/src/renderer/src/components/__tests__/undoKeys.test.tsx @@ -0,0 +1,194 @@ +// @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 (): Promise<string> => 'done'), + redoClient: vi.fn(async (): Promise<string> => 'done'), + undoServer: vi.fn(async (): Promise<string> => 'done'), + redoServer: vi.fn(async (): Promise<string> => 'done') +})) +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ + useSnackbar: (): { enqueueSnackbar: typeof enqueueSnackbar } => ({ enqueueSnackbar }) +})) +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.mockReset().mockResolvedValue('done') + enqueueSnackbar.mockClear() +}) + +const mount = async (appType: 'client' | 'server' | undefined): Promise<void> => { + const { useLayoutZustand } = await import('@renderer/context/layout.zustand') + useLayoutZustand.getState().setAppType(appType) + const { default: UndoKeys } = await import('../UndoKeys') + render(<UndoKeys />) +} + +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('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) + + press({ key: 'z', metaKey: true }) + + expect(ran()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx index 875d6717..2aa7bd99 100644 --- a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx +++ b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx @@ -2,21 +2,20 @@ import Box from '@mui/material/Box' import TransactionGrid from '@renderer/components/client/ClientGrids/TransactionGrid/TransactionGrid' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import RegisterGrid from './RegisterGrid/RegisterGrid' /** - * The grid stays up while a scan runs. It used to be unmounted, because the - * rows arriving one chunk at a time re-rendered the whole list each time and - * the window stopped answering. The rows are written in batches now, and a - * scan with the grid on screen costs about as much as one without. The eye in - * the scan dialog puts it back the old way for anyone who would rather not - * watch. + * The grid stays up while a scan runs, because the rows are written in + * batches: a chunk at a time re-renders the whole list, which is what taking + * the grid down bought. A scan with the grid on screen costs about as much as + * one without. The eye in the scan dialog takes it down for anyone who would + * rather not watch. */ const ClientGrids = meme((): JSX.Element | null => { const showLog = useLayoutZustand((z) => z.showLog) const showWhileScanning = useLayoutZustand((z) => z.showGridWhileScanning) - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) if (scanning && !showWhileScanning) return null diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx index 0a9cc87e..1ad20ae6 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx @@ -17,10 +17,17 @@ interface BitIndicatorProps { color: BitColor | undefined invert: boolean | undefined writable: boolean - onToggle: () => void - onCommentChange: (comment: string | undefined) => void - onColorChange: (color: BitColor | undefined) => void - onInvertChange: (invert: boolean) => void + /** + * Each of these takes the bit it is about, so the panel hands over one + * `useCallback` rather than sixteen fresh arrows. `meme` is `memo` with + * `deepEqual`, which compares functions by identity, so an arrow built in the + * panel's JSX re-rendered all sixteen indicators on every poll that changed a + * word. `ServerBoolRow` is the same shape. + */ + onToggle: (bitIndex: number, value: boolean) => void + onCommentChange: (bitIndex: number, comment: string | undefined) => void + onColorChange: (bitIndex: number, color: BitColor | undefined) => void + onInvertChange: (bitIndex: number, invert: boolean) => void } const resolveColor = (c: BitColor | undefined, theme: Theme): string => { @@ -45,7 +52,7 @@ const BitIndicator = meme( const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(comment ?? '') const hasMapped = !!comment?.length - const [settingsAnchor, setSettingsAnchor] = useState<HTMLElement | null>(null) + const [settingsAnchor, setSettingsAnchor] = useState<Element | null>(null) const displayValue = invert ? !value : value @@ -57,9 +64,24 @@ const BitIndicator = meme( const commit = useCallback( (text: string) => { setEditing(false) - onCommentChange(text.trim() || undefined) + onCommentChange(bitIndex, text.trim() || undefined) }, - [onCommentChange] + [bitIndex, onCommentChange] + ) + + // What the panel hands over is about a bit; what goes to the circle and to + // the popover is about this one. Both ends are stable, so a poll that + // changes one word renders the indicator it belongs to. + const handleToggle = useCallback(() => onToggle(bitIndex, value), [bitIndex, onToggle, value]) + + const handleColorChange = useCallback( + (color: BitColor | undefined) => onColorChange(bitIndex, color), + [bitIndex, onColorChange] + ) + + const handleInvertChange = useCallback( + (invert: boolean) => onInvertChange(bitIndex, invert), + [bitIndex, onInvertChange] ) return ( @@ -89,7 +111,7 @@ const BitIndicator = meme( {/* Circle toggle — shows RAW value; uses primary when inverted so it's independent of signal color */} <Box data-testid={`bit-circle-${bitIndex}`} - onClick={writable ? onToggle : undefined} + onClick={writable ? handleToggle : undefined} sx={(theme) => { const circleColor = invert ? theme.palette.primary.main : resolveColor(color, theme) return { @@ -174,7 +196,7 @@ const BitIndicator = meme( '&:hover': { opacity: 1 }, transition: 'opacity 0.15s' }} - onClick={(e) => setSettingsAnchor(e.currentTarget as unknown as HTMLElement)} + onClick={(e) => setSettingsAnchor(e.currentTarget)} /> <BitSettingsPopover @@ -182,8 +204,8 @@ const BitIndicator = meme( onClose={() => setSettingsAnchor(null)} color={color} invert={!!invert} - onColorChange={onColorChange} - onInvertChange={onInvertChange} + onColorChange={handleColorChange} + onInvertChange={handleInvertChange} /> </Paper> ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx index 318be035..ff6cc544 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitMapDetailPanel.tsx @@ -27,9 +27,14 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen const registerType = useClientZustand((z) => z.registerConfig.type) const writable = registerType === 'holding_registers' - const connectState = useClientZustand((z) => z.clientState.connectState) - const polling = useClientZustand((z) => z.clientState.polling) - const canWrite = writable && connectState === 'connected' && !polling + const connectState = useDataZustand((z) => z.clientState.connectState) + const polling = useDataZustand((z) => z.clientState.polling) + + // Main refuses a write while one is in flight, and holds it until the read + // back is in. Sixteen toggles one click apart are what that refusal is for, + // so the bits go inert for that stretch instead. + const writing = useDataZustand((z) => z.clientState.writing) + const canWrite = writable && connectState === 'connected' && !polling && !writing const handleToggle = useCallback( (bitIndex: number, currentValue: boolean) => { @@ -50,6 +55,11 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen [address, canWrite] ) + // Every one of these takes the bit it is about, because `BitIndicator` is + // `meme`'d and `deepEqual` compares a function by identity: sixteen arrows + // built in this file's JSX are sixteen new ones per render. Measured in + // `__tests__/BitMapDetailPanel.test.tsx`: a poll that flipped one bit redrew + // all sixteen indicators, and redraws the one now. const updateBitMap = useCallback( (bitIndex: number, patch: Record<string, unknown>) => { const clientZustand = useClientZustand.getState() @@ -136,10 +146,10 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen color={entry?.color} invert={entry?.invert} writable={canWrite} - onToggle={() => handleToggle(bitIndex, value)} - onCommentChange={(c) => handleCommentChange(bitIndex, c)} - onColorChange={(c) => handleColorChange(bitIndex, c)} - onInvertChange={(inv) => handleInvertChange(bitIndex, inv)} + onToggle={handleToggle} + onCommentChange={handleCommentChange} + onColorChange={handleColorChange} + onInvertChange={handleInvertChange} /> ) })} diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx index 5621d722..a226b5c0 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx @@ -8,7 +8,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { BitColor } from '@shared' interface BitSettingsPopoverProps { - anchorEl: HTMLElement | null + anchorEl: Element | null onClose: () => void color: BitColor | undefined invert: boolean diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/BitMapDetailPanel.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/BitMapDetailPanel.test.tsx new file mode 100644 index 00000000..9782b83a --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/BitMapDetailPanel.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Both root stores register IPC listeners and run init() at import time. +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) }) +}) + +// The indicator itself draws a Paper, a TextField and a Popover per bit. What +// this file measures is how many of them React renders, so the real `meme` +// wraps a body that counts and draws nothing. +const { rendered } = vi.hoisted(() => ({ rendered: [] as number[] })) +vi.mock('../BitIndicator', async () => { + const { meme } = await import('@renderer/components/shared/inputs/meme') + return { + default: meme(({ bitIndex }: { bitIndex: number }) => { + rendered.push(bitIndex) + return null + }) + } +}) + +import { act, render } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { defaultClientState, RegisterData } from '@shared' +import BitMapDetailPanel from '../BitMapDetailPanel' + +const row = (uint16: number): RegisterData => + ({ + id: 0, + buffer: new Uint8Array(2), + hex: uint16.toString(16).padStart(4, '0'), + words: { uint16 }, + bit: false, + isScanned: false + }) as unknown as RegisterData + +const poll = (uint16: number): void => { + act(() => { + useDataZustand.setState({ registerData: [row(uint16)] }) + }) +} + +beforeEach(() => { + rendered.length = 0 + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'connected' } }) + useClientZustand.setState({ + ready: true, + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'holding_registers' } + } as never) + useDataZustand.setState({ registerData: [row(0)] }) +}) + +// `meme` is `memo` with `deepEqual`, which compares a function by identity. An +// arrow built in the panel's JSX is a new one every render, so all sixteen +// indicators redrew on every poll. +describe('BitMapDetailPanel and what a poll redraws', () => { + it('draws every bit once on mount', () => { + render(<BitMapDetailPanel address={0} />) + + expect(rendered.length).toBe(16) + }) + + it('redraws nothing when the word comes back the same', () => { + render(<BitMapDetailPanel address={0} />) + rendered.length = 0 + + poll(0) + + expect(rendered).toEqual([]) + }) + + it('redraws the one bit that changed', () => { + render(<BitMapDetailPanel address={0} />) + rendered.length = 0 + + poll(0b100) + + expect(rendered).toEqual([2]) + }) + + it('redraws every bit that changed and no other', () => { + render(<BitMapDetailPanel address={0} />) + rendered.length = 0 + + poll(0b1000000000000101) + + expect(rendered.sort((a, b) => a - b)).toEqual([0, 2, 15]) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapEntries.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapEntries.test.tsx new file mode 100644 index 00000000..78aae801 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapEntries.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// What `updateBitMap` deletes is what a saved config file holds: an empty +// comment, the default colour and a false invert are all absences rather than +// values, and an entry that has lost all three is not an entry. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Both root stores register IPC listeners and run init() at import time. +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) }) +}) + +// The panel hands each indicator the three handlers this file drives. The real +// one draws a Paper, a TextField and a Popover, and answers none of them +// without a click path through all three. +const { props } = vi.hoisted(() => ({ + props: new Map<number, Record<string, (...args: never[]) => void>>() +})) +vi.mock('../BitIndicator', async () => { + const { meme } = await import('@renderer/components/shared/inputs/meme') + return { + default: meme((given: { bitIndex: number }) => { + props.set(given.bitIndex, given as unknown as Record<string, (...args: never[]) => void>) + return null + }) + } +}) + +import { act, render } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { BitMapConfig, defaultClientState, emptyRegisterMapping, RegisterData } from '@shared' +import BitMapDetailPanel from '../BitMapDetailPanel' + +const row = (uint16: number): RegisterData => + ({ + id: 0, + buffer: new Uint8Array(2), + hex: uint16.toString(16).padStart(4, '0'), + words: { uint16 }, + bit: false, + isScanned: false + }) as unknown as RegisterData + +/** The panel, drawn over a mapping that holds this bitmap at address 0. */ +const panelOver = (bitMap: BitMapConfig): void => { + const mapping = emptyRegisterMapping() + mapping.holding_registers[0] = { bitMap } + useClientZustand.setState({ registerMapping: mapping } as never) + render(<BitMapDetailPanel address={0} />) +} + +const bitMapNow = (): BitMapConfig | undefined => + useClientZustand.getState().registerMapping.holding_registers[0]?.bitMap + +/** The whole mapping entry, which is what `SaveButton` writes to the file. */ +const mappingNow = (): unknown => useClientZustand.getState().registerMapping.holding_registers + +const call = (bitIndex: number, handler: string, ...args: unknown[]): void => { + const handlers = props.get(bitIndex) + if (!handlers) throw new Error(`bit ${bitIndex} was not drawn`) + const fn = handlers[handler] + if (!fn) throw new Error(`bit ${bitIndex} has no ${handler}`) + act(() => fn(...(args as never[]))) +} + +beforeEach(() => { + props.clear() + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'connected' } }) + useClientZustand.setState({ + ready: true, + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'holding_registers' } + } as never) + useDataZustand.setState({ registerData: [row(0)] }) +}) + +describe('what a bit keeps in the mapping', () => { + it('keeps a comment that was typed', () => { + panelOver({}) + + call(3, 'onCommentChange', 3, 'the run signal') + + expect(bitMapNow()).toEqual({ '3': { comment: 'the run signal' } }) + }) + + // The register keeps an entry holding nothing: `setRegisterMapping` writes + // `bitMap: undefined` rather than deleting the register, and `SaveButton` + // deletes only an entry whose `dataType` is `none`, so the file carries + // `"0": {}`. Every field of `RegisterMapValueSchema` is optional, so it loads + // again, and `showMapping` skips an entry with no data type. + it('drops a comment that was emptied, and the bit with it', () => { + panelOver({ '3': { comment: 'the run signal' } }) + + call(3, 'onCommentChange', 3, '') + + expect(bitMapNow()).toBe(undefined) + expect(mappingNow()).toEqual({ 0: { bitMap: undefined } }) + }) + + it('drops the default colour and keeps the bit', () => { + panelOver({ '3': { comment: 'the run signal' } }) + + call(3, 'onColorChange', 3, 'default') + + expect(bitMapNow()).toEqual({ '3': { comment: 'the run signal' } }) + }) + + it('keeps a colour that is not the default', () => { + panelOver({ '3': { comment: 'the run signal' } }) + + call(3, 'onColorChange', 3, 'red') + + expect(bitMapNow()).toEqual({ '3': { comment: 'the run signal', color: 'red' } }) + }) + + it('keeps an invert that was switched on', () => { + panelOver({ '3': { comment: 'the run signal' } }) + + call(3, 'onInvertChange', 3, true) + + expect(bitMapNow()).toEqual({ '3': { comment: 'the run signal', invert: true } }) + }) + + // The handler sends `undefined` rather than `false`, so the key is there and + // holds nothing. An entry counting that key is one the empty check keeps, and + // `SaveButton` would write a bit holding `{}` into the file. + it('drops an invert that was switched off, and the bit it was the whole of', () => { + panelOver({ '3': { invert: true } }) + + call(3, 'onInvertChange', 3, false) + + expect(bitMapNow()).toBe(undefined) + }) + + it('keeps the other bits when one empties', () => { + panelOver({ '3': { comment: 'the run signal' }, '9': { comment: 'the fault' } }) + + call(3, 'onCommentChange', 3, '') + + expect(bitMapNow()).toEqual({ '9': { comment: 'the fault' } }) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapWrite.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapWrite.test.tsx new file mode 100644 index 00000000..b18b3801 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/__tests__/bitMapWrite.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// A toggle writes the whole word, and sixteen circles a click apart are sixteen +// writes. Main refuses a write while one is in flight and says so in a +// snackbar, so the circles stop taking a click for that stretch instead. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { fireEvent, render, screen } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { ApiCall, recordApiCalls } from '@renderer/context/__tests__/stubRenderer' +import { ClientState, defaultClientState, RegisterData } from '@shared' +import BitMapDetailPanel from '../BitMapDetailPanel' + +const calls: ApiCall[] = [] + +const row = { + id: 0, + buffer: new Uint8Array(2), + hex: '0000', + words: { uint16: 0 }, + bit: false, + isScanned: false +} as unknown as RegisterData + +const renderPanel = (clientState: Partial<ClientState>): void => { + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', ...clientState } + }) + useClientZustand.setState({ + ready: true, + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'holding_registers' } + } as never) + useDataZustand.setState({ registerData: [row] }) + render(<BitMapDetailPanel address={0} />) +} + +beforeEach(() => { + calls.length = 0 + recordApiCalls(calls) +}) + +describe('the bit a toggle writes', () => { + it('goes to main while the client is idle', () => { + renderPanel({}) + + fireEvent.click(screen.getByTestId('bit-circle-3')) + + expect(calls).toEqual([ + { + method: 'write', + payload: { + address: 0, + dataType: 'uint16', + type: 'holding_registers', + value: 8, + single: true + } + } + ]) + }) + + it('goes nowhere while a write is in flight', () => { + renderPanel({ writing: true }) + + fireEvent.click(screen.getByTestId('bit-circle-3')) + + expect(calls).toEqual([]) + }) + + it('goes nowhere while a poll runs', () => { + renderPanel({ polling: true }) + + fireEvent.click(screen.getByTestId('bit-circle-3')) + + expect(calls).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index c5ec2341..31ca2efa 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -28,7 +28,7 @@ import { COMPACT_ROW_HEIGHT, ROW_HEIGHT } from './rowHeight' // // Footer const Footer = meme(() => { - const time = useClientZustand((z) => z.lastSuccessfulTransactionMillis) + const time = useDataZustand((z) => z.lastSuccessfulTransactionMillis) return ( <GridFooterContainer sx={{ px: 1.5, justifyContent: 'space-between' }}> <Typography variant="caption" sx={{ opacity: 0.5 }}> @@ -61,7 +61,7 @@ const RegisterGridContent = meme((): JSX.Element => { // While a scan fills the grid, the rows are there to watch, not to work on: // a cell put into edit mode or a column menu opened over data that is still // arriving is a fight nobody wins. Scrolling and paging stay. - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) // An expanded bitmap row is taller by whatever its detail panel measures, and // the grid places every row below it from this answer. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx index 6932f2dd..95a32b8c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton.tsx @@ -1,12 +1,11 @@ import Button from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' const ClearButton = meme((): JSX.Element => { const noData = useDataZustand((z) => z.registerData.length === 0) - const polling = useClientZustand((z) => z.clientState.polling) + const polling = useDataZustand((z) => z.clientState.polling) const disabled = noData || polling const handleClear = useCallback((): void => { 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..80f3ddfc 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton.tsx @@ -1,31 +1,138 @@ import Delete from '@mui/icons-material/Delete' +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogContentText from '@mui/material/DialogContentText' +import DialogTitle from '@mui/material/DialogTitle' 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' +/** + * Whether the entry holds anything a user would miss. + * + * An entry outlives what was in it: the grid writes `comment: ''` when a + * comment is emptied and `groupEnd: false` when a group end is switched off, + * and `setRegisterMapping` deletes the register only for a `dataType` of + * `none`. `RegisterConfig` reads such an entry as nothing configured, so + * counting keys would raise a dialog over three registers carrying nothing. + */ +const carriesSomething = (value: RegisterMapValue | undefined): boolean => + value !== undefined && + ((value.dataType !== undefined && value.dataType !== 'none') || + value.scalingFactor !== undefined || + !!value.comment || + value.groupEnd === true || + value.bitMap !== undefined) + +/** How many addresses the mapping holds something for, over all four types. */ +const mappedRegisterCount = (registerMapping: RegisterMapping): number => + RegisterTypeSchema.options.reduce( + (total, type) => total + Object.values(registerMapping[type]).filter(carriesSomething).length, + 0 + ) + +const clearConfiguration = (): void => { + const clientZustand = useClientZustand.getState() + void asOneClientStep(async () => { + clientZustand.setName('') + await clientZustand.clearRegisterMapping() + }) +} + +interface ConfirmProps { + mapped: number + named: boolean + onCancel: () => void +} + +/** + * What one click was throwing away, before it does. + * + * The button sits beside Save and drops the data type, scaling factor, comment, + * group end and bitmap of every mapped address, which is the work a config file + * is made of. The two dialogs Modbux already has ask about a system setting; + * this is the first that guards what the user made. + */ +const ConfirmClear = meme(({ mapped, named, onCancel }: ConfirmProps): JSX.Element => { + const handleConfirm = useCallback(() => { + clearConfiguration() + onCancel() + }, [onCancel]) + + return ( + <Dialog open onClose={onCancel} maxWidth="xs" fullWidth> + <DialogTitle>Clear the register configuration?</DialogTitle> + <DialogContent> + <DialogContentText> + {mapped > 0 && ( + <> + {mapped} {mapped === 1 ? 'register carries' : 'registers carry'} a data type, a + scaling factor, a comment, a group end or a bitmap.{' '} + </> + )} + {named && <>The configuration name goes too. </>} + Clearing drops all of it, and turns read configuration off. + </DialogContentText> + </DialogContent> + <DialogActions sx={{ px: 3, pb: 2 }}> + <Button data-testid="clear-config-cancel-btn" onClick={onCancel}> + Keep it + </Button> + <Button data-testid="clear-config-confirm-btn" color="error" onClick={handleConfirm}> + Clear + </Button> + </DialogActions> + </Dialog> + ) +}) + const ClearConfigButton = meme((): JSX.Element => { const [warn, setWarn] = useState(false) + const [asking, setAsking] = useState<{ mapped: number; named: boolean } | undefined>(undefined) + // Read on the click rather than through a selector: the mapping is four + // records of up to 65536 addresses, and a selector reading them runs on every + // flush of a store that takes a transaction per request. const handleClick = useCallback(() => { - useClientZustand.getState().setName('') - useClientZustand.getState().clearRegisterMapping() + const clientZustand = useClientZustand.getState() + const mapped = mappedRegisterCount(clientZustand.registerMapping) + const named = (clientZustand.name ?? '') !== '' + + // The name is typed by hand and goes with the mapping, so a configuration + // holding only a name is one to ask about too. + if (mapped === 0 && !named) { + clearConfiguration() + return + } + setAsking({ mapped, named }) }, []) + const handleCancel = useCallback(() => setAsking(undefined), []) + return ( - <IconButton - data-testid="clear-config-btn" - aria-label="Clear configuration" - size="small" - onClick={handleClick} - color={warn ? 'error' : 'primary'} - title="clear datatype, scaling and comment configuration" - onMouseEnter={() => setWarn(true)} - onMouseLeave={() => setWarn(false)} - > - <Delete fontSize="small" /> - </IconButton> + <> + <IconButton + data-testid="clear-config-btn" + aria-label="Clear configuration" + size="small" + onClick={handleClick} + color={warn ? 'error' : 'primary'} + title="clear datatype, scaling and comment configuration" + onMouseEnter={() => setWarn(true)} + onMouseLeave={() => setWarn(false)} + > + <Delete fontSize="small" /> + </IconButton> + {asking && ( + <ConfirmClear mapped={asking.mapped} named={asking.named} onCancel={handleCancel} /> + )} + </> ) }) 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..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 => { @@ -23,17 +24,19 @@ const LoadButton = meme((): JSX.Element => { const clientZustand = useClientZustand.getState() - const content = await file.text() - try { + const content = await file.text() + // Use migration framework to handle all config versions 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) - 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) { @@ -63,15 +66,20 @@ const LoadButton = meme((): JSX.Element => { autoHideDuration: 8000 }) } + // Inside the `try`, because it draws the grid from the mapping: on a + // file that was refused the mapping is the one already there, and the + // rows it builds would drop the values a read loop had put in them. + showMapping() } catch (error) { const tError = error as Error enqueueSnackbar({ variant: 'error', message: `Failed to load config: ${tError.message}` }) console.error('Config load error:', error) + } finally { + // Whatever the read did, the button takes another file. `opening` + // renders the input behind `{!opening && ...}` and disables the button. + openingRef.current = false + setOpening(false) } - - openingRef.current = false - setOpening(false) - showMapping() }, [enqueueSnackbar] ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx index 58cf996e..baa5ffca 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton.tsx @@ -7,7 +7,7 @@ import type { SetAnchorProps } from './MenuButton' import Button from '@mui/material/Button' const LoadDummyDataButton = meme(({ setAnchor }: SetAnchorProps) => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') // Load dummy data for the configured register range so columns can be edited // without having to connect to the device or read registers diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions.tsx index 7bd2e06f..b1a95299 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions.tsx @@ -3,6 +3,7 @@ import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { ChangeEvent, useCallback } from 'react' // RTU over TCP (encapsulated RTU) is a niche, TCP-family transport, so it lives @@ -10,7 +11,7 @@ import { ChangeEvent, useCallback } from 'react' // when TCP is selected; serial RTU has no use for it. const MenuConnectionOptions = meme((): JSX.Element | null => { const protocol = useClientZustand((z) => z.connectionConfig.protocol) - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>): void => { const clientZustand = useClientZustand.getState() 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..04f79734 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 @@ -2,13 +2,15 @@ import Button from '@mui/material/Button' import { useScanRegistersZustand } from '@renderer/components/client/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.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 disabled = useDataZustand((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/MenuButton/ScanUnitIdsButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx index 64ad3237..3607aa68 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIdsButton.tsx @@ -1,12 +1,12 @@ import Button from '@mui/material/Button' import { useScanUnitIdZustand } from '@renderer/components/client/ScanUnitIds/scanUnitIds.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { useCallback } from 'react' import type { SetAnchorProps } from './MenuButton' const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'connected') // Close the menu behind it, the way scanning registers does. Otherwise it is // still hanging there when you close the dialog again. diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx index 41a51819..59cc950e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/__tests__/MenuOptions.test.tsx @@ -15,6 +15,7 @@ vi.hoisted(() => { import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import MenuRegisterOptions from '../MenuRegisterOptions' import MenuConnectionOptions from '../MenuConnectionOptions' @@ -31,8 +32,8 @@ beforeEach(() => { // The setter writes what main accepted, so a stub answering `undefined` // refuses every payload and the store never moves. window.api = { updateConnectionConfig: vi.fn(() => Promise.resolve(true)) } as never - useClientZustand.setState({ - ready: true, + useClientZustand.setState({ ready: true } as never) + useDataZustand.setState({ clientState: { connectState: 'disconnected', polling: false, @@ -122,7 +123,9 @@ describe('MenuConnectionOptions', () => { it('disables the checkbox while not disconnected', () => { seed({ - connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' }, + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } + }) + useDataZustand.setState({ clientState: { connectState: 'connected', polling: false, diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx index 8f612b29..97bd5d41 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton.tsx @@ -1,12 +1,19 @@ import Button, { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { clientOwner } from '@shared' import { useCallback } from 'react' const PollButton = meme((): JSX.Element => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') + const notConnected = useDataZustand((z) => z.clientState.connectState !== 'connected') - const polling = useClientZustand((z) => z.clientState.polling) + // What main would refuse a start for. `exceptPolling` is what keeps the + // press that stops one: during a poll nothing else can own the client, so + // this is undefined and the button is the Stop button. + const owner = useDataZustand((z) => clientOwner(z.clientState, { exceptPolling: true })) + const disabled = notConnected || owner !== undefined + + const polling = useDataZustand((z) => z.clientState.polling) const togglePolling = useCallback(() => { polling ? window.api.stopPolling() : window.api.startPolling() }, [polling]) 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/ReadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx index 2491022a..3519fc71 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton.tsx @@ -1,22 +1,28 @@ import Button, { ButtonProps } from '@mui/material/Button' import { meme } from '@renderer/components/shared/inputs/meme' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { clientOwner } from '@shared' import { useCallback } from 'react' const ReadButton = meme((): JSX.Element => { - const connected = useClientZustand((z) => z.clientState.connectState === 'connected') - const polling = useClientZustand((z) => z.clientState.polling) + const connected = useDataZustand((z) => z.clientState.connectState === 'connected') - // Main says a read is running, and refuses a second one while it is. This - // used to be a ref here, which held for this button and for nothing else. - const reading = useClientZustand((z) => z.clientState.reading) + // What main would refuse this press for, which is the same question it asks + // of every caller that puts a request on the wire. Naming a subset of it + // left the button pressable during both scans, where it was reachable only + // by what the two dialogs happen to draw over. + const owner = useDataZustand((z) => clientOwner(z.clientState)) + + // A read in flight is the one state this says something about rather than + // just refusing. + const reading = useDataZustand((z) => z.clientState.reading) const handleRead = useCallback(() => { window.api.read() }, []) const color: ButtonProps['color'] = reading ? 'warning' : 'primary' - const disabled = !connected || polling || reading + const disabled = !connected || owner !== undefined return ( <Button diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx index bb3b77bc..f78c0fd5 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -13,6 +13,7 @@ import MenuButton from './MenuButton/MenuButton' import RawButton from './RawButton' import ClearFiltersButton from './ClearFiltersButton' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import TextField from '@mui/material/TextField' import { ChangeEvent, useCallback } from 'react' @@ -42,7 +43,7 @@ const ClientConfigName = meme(() => { const RegisterGridToolbar = meme(() => { // Read, Poll, Clear and the config buttons would each undo a scan that is // still running, so the strip goes quiet with the rows underneath it. - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) return ( <Box diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx index 137100d9..8487ecd4 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings.tsx @@ -6,6 +6,7 @@ import Popover from '@mui/material/Popover' import { meme } from '@renderer/components/shared/inputs/meme' import SliderComponent from '@renderer/components/shared/SliderComponent' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { useCallback, useState } from 'react' // Polling interval slider @@ -47,7 +48,7 @@ const Timeout = meme((): JSX.Element => { }) const TimeSettings = meme(() => { - const polling = useClientZustand((z) => z.clientState.polling) + const polling = useDataZustand((z) => z.clientState.polling) const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null) const handleOpenMenu = useCallback( 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/RegisterGridToolbar/__tests__/clearConfigButton.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/clearConfigButton.test.tsx new file mode 100644 index 00000000..110b7c1c --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/clearConfigButton.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// One click dropped every data type, scaling factor, comment, group end and +// bitmap, and the button sits beside Save. It asks first now, and only when +// there is something to lose. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time, and +// `isServerWindow` is a boolean the preload exposes rather than a channel. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { fireEvent, render, screen } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { emptyRegisterMapping } from '@shared' +import ClearConfigButton from '../ClearConfigButton' + +const clearRegisterMapping = vi.fn() + +/** The store holding a mapping over these holding registers, and a name. */ +const mapped = (addresses: number[], name = 'the plant'): void => { + const registerMapping = emptyRegisterMapping() + for (const address of addresses) + registerMapping.holding_registers[address] = { dataType: 'int16' } + useClientZustand.setState({ registerMapping, name, clearRegisterMapping } as never) +} + +/** What the grid leaves behind when a comment is typed and then emptied. */ +const emptied = (addresses: number[]): void => { + const registerMapping = emptyRegisterMapping() + for (const address of addresses) { + registerMapping.holding_registers[address] = { comment: '', groupEnd: false } + } + useClientZustand.setState({ registerMapping, name: '', clearRegisterMapping } as never) +} + +const clickClear = (): void => { + fireEvent.click(screen.getByTestId('clear-config-btn')) +} + +beforeEach(() => { + clearRegisterMapping.mockClear() +}) + +describe('a configuration with mapped registers', () => { + it('is not cleared until the dialog says so', () => { + mapped([0, 5]) + render(<ClearConfigButton />) + + clickClear() + + expect(screen.getByText(/2 registers carry/)).toBeInTheDocument() + expect(clearRegisterMapping).not.toHaveBeenCalled() + expect(useClientZustand.getState().name).toBe('the plant') + }) + + it('is kept when the dialog is dismissed', () => { + mapped([0, 5]) + render(<ClearConfigButton />) + clickClear() + + fireEvent.click(screen.getByTestId('clear-config-cancel-btn')) + + expect(screen.queryByTestId('clear-config-confirm-btn')).toBe(null) + expect(clearRegisterMapping).not.toHaveBeenCalled() + }) + + it('is cleared, name included, once the dialog says so', () => { + mapped([0, 5]) + render(<ClearConfigButton />) + clickClear() + + fireEvent.click(screen.getByTestId('clear-config-confirm-btn')) + + expect(clearRegisterMapping).toHaveBeenCalled() + expect(useClientZustand.getState().name).toBe('') + expect(screen.queryByTestId('clear-config-confirm-btn')).toBe(null) + }) + + // The count is what the dialog is worth saying, and one register is not two. + it('counts one register as one', () => { + mapped([7]) + render(<ClearConfigButton />) + + clickClear() + + expect(screen.getByText(/1 register carries/)).toBeInTheDocument() + }) +}) + +describe('a configuration with nothing mapped', () => { + it('is cleared without asking', () => { + mapped([], '') + render(<ClearConfigButton />) + + clickClear() + + expect(screen.queryByTestId('clear-config-confirm-btn')).toBe(null) + expect(clearRegisterMapping).toHaveBeenCalled() + }) + + // The name is typed by hand and the button takes it too, so it is worth a + // question of its own. + it('is asked about when it still holds a name', () => { + mapped([], 'the plant') + render(<ClearConfigButton />) + + clickClear() + + expect(screen.getByText(/The configuration name goes too/)).toBeInTheDocument() + expect(clearRegisterMapping).not.toHaveBeenCalled() + }) +}) + +// An entry outlives what was in it: the grid writes `comment: ''` when a +// comment is emptied and `groupEnd: false` when a group end is switched off, +// and neither is something to lose. +describe('registers whose entries hold nothing', () => { + it('are not counted, and the configuration clears without asking', () => { + emptied([0, 1, 2]) + render(<ClearConfigButton />) + + clickClear() + + expect(screen.queryByTestId('clear-config-confirm-btn')).toBe(null) + expect(clearRegisterMapping).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/loadButton.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/loadButton.test.tsx new file mode 100644 index 00000000..5bc501b4 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/loadButton.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// The file input renders behind `{!opening && ...}`, so a load that leaves +// `opening` true takes the only way to pick a file out of the DOM. Playwright +// cannot make `File.text()` reject, which is why this is a unit test. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time, so +// the boundary has to answer before this file's own imports are evaluated. +// `stubRenderer` answers each channel with the schema `main/ipc.ts` guards it +// with, and `isServerWindow` as the boolean the preload exposes. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar }) })) + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import LoadButton from '../LoadButton' + +const CONFIG = JSON.stringify({ + version: 2, + modbuxVersion: '2.0.0', + name: 'Test Client', + littleEndian: false, + registerMapping: { + coils: {}, + discrete_inputs: {}, + holding_registers: { '0': { dataType: 'int16' } }, + input_registers: {} + } +}) + +/** A file that answers what it was given, or refuses to be read. */ +const fileAnswering = (text: () => Promise<string>): File => ({ text }) as unknown as File + +const pick = async (file: File): Promise<void> => { + const input = screen.getByTestId('load-config-file-input') + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }) + }) +} + +const messages = (): unknown[] => + enqueueSnackbar.mock.calls.map((call) => (call[0] as { message: unknown }).message) + +beforeEach(() => { + enqueueSnackbar.mockClear() + vi.spyOn(console, 'error').mockImplementation(() => {}) + useClientZustand.setState({ name: '' } as never) +}) + +describe('a file that cannot be read', () => { + it('leaves the button able to open another one', async () => { + render(<LoadButton />) + + await pick(fileAnswering(() => Promise.reject(new Error('it is gone')))) + + await waitFor(() => expect(messages()).toContain('Failed to load config: it is gone')) + expect(screen.getByTestId('load-config-file-input')).toBeInTheDocument() + expect(screen.getByTestId('load-config-btn')).not.toBeDisabled() + }) +}) + +describe('a file that reads', () => { + it('is opened, and the button takes another one', async () => { + render(<LoadButton />) + + await pick(fileAnswering(() => Promise.resolve(CONFIG))) + + await waitFor(() => expect(messages()).toContain('Configuration opened successfully')) + // The mapping is what the file was for, and `replaceRegisterMapping` writes + // it only where main took it. `stubRenderer` answers that channel with the + // schema `main/ipc.ts` guards it with. + expect(useClientZustand.getState().registerMapping.holding_registers[0]).toEqual({ + dataType: 'int16' + }) + expect(useClientZustand.getState().name).toBe('Test Client') + expect(screen.getByTestId('load-config-file-input')).toBeInTheDocument() + expect(screen.getByTestId('load-config-btn')).not.toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/pollButton.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/pollButton.test.tsx new file mode 100644 index 00000000..0e6281c9 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/pollButton.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// Pressing it starts a poll or stops the one that runs. Main refuses a start +// while anything else owns the client, and this button disabled on the connect +// state alone, so every refusal reached the user as a warning for a press the +// button had taken. +import { describe, it, expect, vi } from 'vitest' +// The client store registers IPC listeners and calls main at import time. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { render, screen } from '@testing-library/react' +import { useDataZustand } from '@renderer/context/data.zustand' +import { ClientState, defaultClientState } from '@shared' +import PollButton from '../PollButton' + +const renderButton = (clientState: Partial<ClientState>): HTMLElement => { + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', ...clientState } + }) + render(<PollButton />) + return screen.getByTestId('poll-btn') +} + +describe('the Poll button', () => { + it('takes a press while the client is idle', () => { + expect(renderButton({})).toBeEnabled() + }) + + // The press that stops one. `exceptPolling` is what keeps it: nothing else + // can own the client during a poll, so the button stays live to be pressed + // again. + it('takes a press while a poll runs', () => { + expect(renderButton({ polling: true })).toBeEnabled() + }) + + it('takes none while a read is in flight', () => { + expect(renderButton({ reading: true })).toBeDisabled() + }) + + it('takes none while a write is in flight', () => { + expect(renderButton({ writing: true })).toBeDisabled() + }) + + it('takes none while a register scan runs', () => { + expect(renderButton({ scanningRegisters: true })).toBeDisabled() + }) + + it('takes none while a unit id scan runs', () => { + expect(renderButton({ scanningUnitIds: true })).toBeDisabled() + }) + + it('takes none while nothing is connected', () => { + expect(renderButton({ connectState: 'disconnected' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/readButton.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/readButton.test.tsx new file mode 100644 index 00000000..9bfd9e15 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/__tests__/readButton.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// Pressing it asks main for a read, and main refuses one while anything else +// owns the client. The button says so by going dead, rather than taking a press +// that answers with a warning. +import { describe, it, expect, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { render, screen } from '@testing-library/react' +import { useDataZustand } from '@renderer/context/data.zustand' +import { ClientState, defaultClientState } from '@shared' +import ReadButton from '../ReadButton' + +const renderButton = (clientState: Partial<ClientState>): HTMLElement => { + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', ...clientState } + }) + render(<ReadButton />) + return screen.getByTestId('read-btn') +} + +describe('the Read button', () => { + it('takes a press while the client is idle', () => { + expect(renderButton({})).toBeEnabled() + }) + + it('takes none while a read is in flight', () => { + expect(renderButton({ reading: true })).toBeDisabled() + }) + + // A write holds the client from its own request to the end of the read back, + // and `reading` covers the second half of that stretch alone. + it('takes none while a write is in flight', () => { + expect(renderButton({ writing: true })).toBeDisabled() + }) + + it('takes none while a poll runs', () => { + expect(renderButton({ polling: true })).toBeDisabled() + }) + + // Both scans were missing from the question this button asked, so the press + // was refused by main and the button took it anyway. What kept it out of + // reach was the strip a register scan draws over the toolbar and the + // backdrop a unit id scan draws over the window. + it('takes none while a register scan runs', () => { + expect(renderButton({ scanningRegisters: true })).toBeDisabled() + }) + + it('takes none while a unit id scan runs', () => { + expect(renderButton({ scanningUnitIds: true })).toBeDisabled() + }) + + it('takes none while nothing is connected', () => { + expect(renderButton({ connectState: 'disconnected' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/ExpandCell.tsx similarity index 91% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/ExpandCell.tsx index fe44c475..5e8eab2a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapExpand.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/ExpandCell.tsx @@ -7,10 +7,9 @@ import { useCallback } from 'react' interface ExpandCellProps { address: number - isBitmap: boolean } -export const ExpandCell = meme(({ address, isBitmap }: ExpandCellProps): JSX.Element => { +export const ExpandCell = meme(({ address }: ExpandCellProps): JSX.Element => { const expandedAddress = useBitMapZustand((z) => z.expandedAddress) const isExpanded = expandedAddress === address @@ -19,8 +18,6 @@ export const ExpandCell = meme(({ address, isBitmap }: ExpandCellProps): JSX.Ele bitMapZustand.toggleExpanded(address) }, [address]) - if (!isBitmap) return <></> - return ( <Box data-testid={`bitmap-expand-${address}`} 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..20aeb362 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,9 +14,11 @@ 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 { IMaskInput, IMask } from 'react-imask' +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<HTMLInputElement, MaskInputProps>((props, ref) => { @@ -27,16 +29,10 @@ const ValueInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, r return ( <IMaskInput {...other} - mask={IMask.MaskedNumber} + {...decimalMask(integer)} min={min} max={max} autofix - {...{ - scale: integer ? 0 : 7, - thousandsSeparator: '', - radix: '.', // fractional delimiter - mapToRadix: ['.', ','] // symbols to process as radix - }} inputRef={ref} onAccept={(value) => { set(value, notEmpty(value)) @@ -145,7 +141,8 @@ export const WriteRegistersButton = meme(() => { ) }) -const CoilFunctionSelect = 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) @@ -157,14 +154,32 @@ 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. 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 + // 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' + }) + } window.api.write({ address, type: 'coils', - value: coils.slice(address - registerConfigAddress), + value, single: coilFunction === 5 }) - }, [address, coilFunction, coils, registerConfigAddress]) + }, [address, coilFunction, coils, registerConfigAddress, enqueueSnackbar]) return ( <Box sx={{ display: 'flex' }}> @@ -234,7 +249,7 @@ const CoilButton = meme(({ address, index }: CoilButtonProps) => { ) }) -const Coils = meme(() => { +export const Coils = meme(() => { const length = useClientZustand((z) => z.registerConfig.length) const registerConfigAddress = useClientZustand((z) => z.registerConfig.address) const address = useValueInputZustand((z) => z.address) @@ -247,10 +262,17 @@ const Coils = meme(() => { valueInputZustand.initCoils(seedCoils(registerData, registerConfigAddress, length)) }, [length, registerConfigAddress]) - const rows = useMemo(() => { - const amount = Math.ceil(length / 8) - return new Array(amount).fill(null) - }, [length]) + // What the picker draws is what one FC15 can write: from the coil pressed to + // the end of the read window, and at most `MAX_WRITE_BITS` of them. A window + // of 2000 coils drew 2000 buttons, each an MUI Button with a store + // subscription of its own, and the 32 past the request could be pressed and + // went nowhere. + const drawn = useMemo( + () => Math.min(registerConfigAddress + length - address, MAX_WRITE_BITS), + [address, length, registerConfigAddress] + ) + + const rows = useMemo(() => new Array(Math.ceil(drawn / 8)).fill(null), [drawn]) return ( <Box sx={{ display: 'flex', flexDirection: 'column' }}> @@ -267,7 +289,7 @@ const Coils = meme(() => { const index = ci + ri * 8 const coilAddress = address + index - return coilAddress < registerConfigAddress + length ? ( + return index < drawn ? ( <CoilButton key={`coil_${coilAddress}`} address={coilAddress} 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..b34bdee8 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/CoilFunctionSelect.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +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: [] }) }) +})) + +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ + useSnackbar: (): { enqueueSnackbar: typeof enqueueSnackbar } => ({ enqueueSnackbar }) +})) + +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() + enqueueSnackbar.mockClear() + useValueInputZustand.setState({ + address: 0, + coilFunction: 15, + coils: new Array<boolean>(2000).fill(true) + }) + }) + + it('sends what one FC15 carries', async () => { + const user = userEvent.setup() + render(<CoilFunctionSelect />) + + await user.click(screen.getByTestId('write-submit-btn')) + + 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(<CoilFunctionSelect />) + + 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' + }) + ) + }) + + // 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(<CoilFunctionSelect />) + + 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<boolean>(125).fill(true) }) + const user = userEvent.setup() + render(<CoilFunctionSelect />) + + 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 + // shorter the further in it sits. + it('starts at the coil you pressed', async () => { + useValueInputZustand.setState({ address: 40 }) + const user = userEvent.setup() + render(<CoilFunctionSelect />) + + await user.click(screen.getByTestId('write-submit-btn')) + + expect(written().length).toBe(1960) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/Coils.test.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/Coils.test.tsx new file mode 100644 index 00000000..1bf19c47 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/WriteModal/__tests__/Coils.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_WRITE_BITS } from '@shared' + +// The picker draws over the window the toolbar read, which is this config. +const { registerConfig } = vi.hoisted(() => ({ + registerConfig: { address: 0, length: 0 } +})) +vi.mock('@renderer/context/client.zustand', () => ({ + useClientZustand: Object.assign( + (selector: (state: { registerConfig: { address: number; length: number } }) => unknown) => + selector({ registerConfig }), + { getState: () => ({ registerConfig }) } + ) +})) +vi.mock('@renderer/context/data.zustand', () => ({ + useDataZustand: Object.assign(() => undefined, { getState: () => ({ registerData: [] }) }) +})) + +import { Coils } from '../WriteModal' +import { useValueInputZustand } from '../writeModal.zustand' + +const drawn = (): number => screen.getAllByTestId(/^write-coil-\d+-select-btn$/).length + +beforeEach(() => { + registerConfig.address = 0 + registerConfig.length = 2000 + useValueInputZustand.setState({ + address: 0, + coilFunction: 15, + coils: new Array<boolean>(2000).fill(false) + }) +}) + +// The Length field took the 2000 bits FC01 answers, and every coil drawn is an +// MUI Button with a store subscription of its own. One FC15 writes 1968 of +// them, so the ones past that could be pressed and went nowhere. +describe('the coil picker', () => { + it('draws no more coils than one request writes', () => { + render(<Coils />) + + expect(drawn()).toBe(MAX_WRITE_BITS) + }) + + it('draws a window that fits whole', () => { + registerConfig.length = 125 + useValueInputZustand.setState({ coils: new Array<boolean>(125).fill(false) }) + + render(<Coils />) + + expect(drawn()).toBe(125) + }) + + // The picker starts at the coil the action cell was on, so what is left of + // the window is what it draws. + it('draws from the coil the dialog opened on', () => { + useValueInputZustand.setState({ address: 1500 }) + + render(<Coils />) + + expect(drawn()).toBe(500) + }) +}) 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/__tests__/convertedValue.test.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/__tests__/convertedValue.test.ts index 06e0e3ca..6e091862 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/__tests__/convertedValue.test.ts +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/__tests__/convertedValue.test.ts @@ -38,6 +38,24 @@ const rowAt = (address: number, utf8: string): RegisterData => ({ isScanned: false }) +/** A row carrying one number, under every type that reads it as one. */ +const numberRowAt = (address: number, value: number): RegisterData => ({ + ...rowAt(address, ''), + words: { + int16: value, + uint16: value, + int32: 0, + uint32: 0, + float: value, + unix: '', + int64: BigInt(0), + uint64: BigInt(0), + double: value, + datetime: '', + utf8: '' + } +}) + /** * What the value column shows, or a failure saying the column has no getter. * @@ -45,8 +63,12 @@ const rowAt = (address: number, utf8: string): RegisterData => ({ * first argument `never` and the getter reads the row instead. The api ref is * the same: the getter takes it and never touches it. */ -const shownValue = (registerMap: RegisterMapObject, row: RegisterData): unknown => { - const column = convertedValueColumn(registerMap, false) +const shownValue = ( + registerMap: RegisterMapObject, + row: RegisterData, + showRaw = false +): unknown => { + const column = convertedValueColumn(registerMap, showRaw) const { valueGetter } = column if (!valueGetter) throw new Error('the value column has no valueGetter') @@ -76,3 +98,73 @@ describe('how much of a string the value column shows', () => { expect(shownValue({ 2: { dataType: 'utf8' } }, rowAt(2, 'CDEFGHIJKL'))).toBe('CDEFGHIJ') }) }) + +// `String(undefined)` is the word "undefined" and the guard below it asked +// whether the string was empty, so a row carrying no words drew that word. The +// UTF-8 column cut it to the group, which is `"undefine"` over four registers. +describe('a row that carries no words', () => { + // What `convertBitData` writes for a coil or a discrete input. + const wordless = (address: number): RegisterData => ({ + ...rowAt(address, ''), + words: undefined + }) + + it.each(['datetime', 'unix', 'uint16'] as const)('shows nothing for %s', (dataType) => { + expect(shownValue({ 0: { dataType } }, wordless(0))).toBe(undefined) + }) + + it('shows nothing for utf8', () => { + dataState.addressGroups = [[0, 4]] + + expect(shownValue({ 0: { dataType: 'utf8' } }, wordless(0))).toBe(undefined) + }) +}) + +describe('the number the value column shows', () => { + // The rounding takes its precision from the factor, so a factor with one + // decimal that rounded to none would show 12 for a register holding 123. + it('keeps the decimals the scaling factor introduces', () => { + expect(shownValue({ 0: { dataType: 'uint16', scalingFactor: 0.1 } }, numberRowAt(0, 123))).toBe( + 12.3 + ) + }) + + // A float carries decimals of its own, and the factor's count alone would + // round them away. + it('keeps the decimals of a float beside those of the factor', () => { + expect(shownValue({ 0: { dataType: 'float', scalingFactor: 0.1 } }, numberRowAt(0, 1.25))).toBe( + 0.125 + ) + }) + + it('shows the word itself when the toolbar asks for raw', () => { + expect( + shownValue({ 0: { dataType: 'uint16', scalingFactor: 0.1 } }, numberRowAt(0, 123), true) + ).toBe(123) + }) + + // The endpoints are read against the scaled number. Both lines run through + // the same two points, so a `y1` away from zero is what separates the order + // they run in: interpolating the word itself answers 10.1 here. + it('interpolates what the scaling factor answered, not the raw word', () => { + const map = { + 0: { + dataType: 'uint16' as const, + scalingFactor: 0.1, + interpolate: { x1: '0', x2: '100', y1: '1', y2: '11' } + } + } + + expect(shownValue(map, numberRowAt(0, 1000))).toBe(11) + }) + + // Two endpoints on the same x have no slope to read, and the division would + // answer an Infinity the column would draw. + it('answers y1 when both endpoints sit on the same x', () => { + const map = { + 0: { dataType: 'uint16' as const, interpolate: { x1: '1', x2: '1', y1: '7', y2: '99' } } + } + + expect(shownValue(map, numberRowAt(0, 50))).toBe(7) + }) +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapValueColumn.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapValueColumn.tsx index 9e271de0..e39cd781 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapValueColumn.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/bitmapValueColumn.tsx @@ -1,46 +1,31 @@ import { GridColDef } from '@mui/x-data-grid/models' import { BITMAP_DATATYPE, RegisterData, RegisterMapObject } from '@shared' -import { convertedValueColumn } from './convertedValue' -import { ExpandCell } from './bitmapExpand' +import { convertedValueColumn, getConvertedValue, renderConvertedValue } from './convertedValue' +import { ExpandCell } from './ExpandCell' // ───────────────────────────────────────────────────────────────────────────── // bitmapValueColumn // // Replaces convertedValueColumn in columns/index.tsx so that bitmap rows show // an expand/collapse toggle in the value cell instead of a numeric value. -// All non-bitmap rows render exactly as convertedValueColumn does. +// Every other row is the value column's own cell, which is why both halves are +// called here rather than copied. // ───────────────────────────────────────────────────────────────────────────── export const bitmapValueColumn = ( registerMap: RegisterMapObject, showRaw: boolean -): GridColDef<RegisterData> => { - const base = convertedValueColumn(registerMap, showRaw) - - const baseValueGetter = base.valueGetter as ( - value: unknown, - row: RegisterData - ) => number | string | undefined - - return { - ...base, - valueGetter: (value: unknown, row: RegisterData): number | string | undefined => { - if (registerMap[row.id]?.dataType === BITMAP_DATATYPE) return undefined - return baseValueGetter(value, row) - }, - renderCell: (params): JSX.Element => { - if (params.row.error) { - return ( - <span style={{ color: 'var(--mui-palette-error-main)' }} title={params.row.error}> - {params.row.error} - </span> - ) - } - const isBitmap = registerMap[params.row.id]?.dataType === BITMAP_DATATYPE - if (isBitmap) { - return <ExpandCell address={params.row.id} isBitmap={true} /> - } - return <>{params.formattedValue ?? params.value ?? ''}</> - } +): GridColDef<RegisterData> => ({ + ...convertedValueColumn(registerMap, showRaw), + valueGetter: (_: unknown, row: RegisterData): number | string | undefined => { + if (registerMap[row.id]?.dataType === BITMAP_DATATYPE) return undefined + return getConvertedValue(row, registerMap, showRaw) + }, + renderCell: (params): JSX.Element | string | number => { + // An error is what the cell says whatever the data type is, so a bitmap row + // that failed to read gets the same red text as any other. + const isBitmap = registerMap[params.row.id]?.dataType === BITMAP_DATATYPE + if (isBitmap && !params.row.error) return <ExpandCell address={params.row.id} /> + return renderConvertedValue(params) } -} +}) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/convertedValue.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/convertedValue.tsx index 3dcd235b..8de6a5d5 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/convertedValue.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/convertedValue.tsx @@ -1,4 +1,4 @@ -import { GridColDef } from '@mui/x-data-grid/models' +import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid/models' import { useDataZustand } from '@renderer/context/data.zustand' import { DataType, RegisterData, RegisterLinearInterpolation, RegisterMapObject } from '@shared' import { round } from 'lodash' @@ -22,6 +22,28 @@ const linearInterpolate = (x: number, { x1, x2, y1, y2 }: RegisterLinearInterpol return ny1 + t * (ny2 - ny1) } +/** + * What a value cell shows, error included. `bitmapValueColumn` renders the same + * cell for every row it does not turn into an expand toggle, and calling this + * is how it says so. + * + * A number is the ordinary answer here: `getConvertedValue` rounds one and + * `valueFormatter` passes it through, and `formattedValue` is `any`, so the + * return type is the only place that says so. + */ +export const renderConvertedValue = ( + params: GridRenderCellParams<RegisterData> +): JSX.Element | string | number => { + if (params.row.error) { + return ( + <span style={{ color: 'var(--mui-palette-error-main)' }} title={params.row.error}> + {params.row.error} + </span> + ) + } + return params.formattedValue +} + export const convertedValueColumn = ( registerMap: RegisterMapObject, showRaw: boolean @@ -31,75 +53,86 @@ export const convertedValueColumn = ( type: 'string', headerName: 'Value', width: 160, - renderCell: (params): JSX.Element | string => { - if (params.row.error) { - return ( - <span style={{ color: 'var(--mui-palette-error-main)' }} title={params.row.error}> - {params.row.error} - </span> - ) - } - return params.formattedValue - }, - valueGetter: (_, row): number | string | undefined => { - if (row.error) return undefined - const address = row.id - - // Get the defined datatype from the register map - const dataType = registerMap[address]?.dataType - - // Get the value for the register datatype, they are all there, the defined datatype - // extracts that value and shows it in the value column - const value = dataType && dataType !== 'none' ? String(row.words?.[dataType]) : undefined - if (!value) return undefined - - // For strings we must calculate the length until the next defined datatype - let count = 1 - if (dataType === 'utf8') { - const groups = useDataZustand.getState().addressGroups - - // Find the current group that contains the address - const currentGroup = groups.find( - ([groupAddress, length]) => address >= groupAddress && address < groupAddress + length - ) - if (!currentGroup) return undefined - - const startAddress = currentGroup[0] - const length = currentGroup[1] - - let register = registerMap[address + count] - - // Continue until we find the next register with a defined datatype, or - // until the end of the group. The group is [startAddress, + length), the - // same half-open range the `find` above reads it as, and `<=` took two - // characters out of whatever was read after it. - while ( - address + count < startAddress + length && - (!register || register.dataType === 'none' || !register.dataType) - ) { - count++ - register = registerMap[address + count] - } - - // Slice the string to the right length - // The utf8 value starts from the current register's offset, - // so we slice from 0 (each register = 2 characters for ASCII) - return value.slice(0, count * 2) + renderCell: renderConvertedValue, + valueGetter: (_, row): number | string | undefined => + getConvertedValue(row, registerMap, showRaw), + valueFormatter: (v) => (v !== undefined ? v : '') +}) + +/** + * The value a cell reads, scaled and interpolated. `bitmapValueColumn` hands + * every row that is not a bitmap back to this, and it takes the map and the + * raw flag rather than reaching into the column it builds on. + */ +export const getConvertedValue = ( + row: RegisterData, + registerMap: RegisterMapObject, + showRaw: boolean +): number | string | undefined => { + if (row.error) return undefined + const address = row.id + + // Get the defined datatype from the register map + const dataType = registerMap[address]?.dataType + + // Get the value for the register datatype, they are all there, the defined datatype + // extracts that value and shows it in the value column. + // + // The word is read before it is stringified, because `String(undefined)` is + // the word "undefined", which is truthy, so a row carrying no words drew that + // in the cell. `convertBitData` writes such a row for every coil and discrete + // input, and those rows outlive a switch of the register type while a read + // loop owns the grid. A UTF-8 register drew the same word cut to the group it + // sits in, which is `"undefine"` over four registers. + const word = dataType && dataType !== 'none' ? row.words?.[dataType] : undefined + if (word === undefined || word === '') return undefined + const value = String(word) + + // For strings we must calculate the length until the next defined datatype + let count = 1 + if (dataType === 'utf8') { + const groups = useDataZustand.getState().addressGroups + + // Find the current group that contains the address + const currentGroup = groups.find( + ([groupAddress, length]) => address >= groupAddress && address < groupAddress + length + ) + if (!currentGroup) return undefined + + const startAddress = currentGroup[0] + const length = currentGroup[1] + + let register = registerMap[address + count] + + // Continue until we find the next register with a defined datatype, or + // until the end of the group. The group is [startAddress, + length), the + // same half-open range the `find` above reads it as, and `<=` took two + // characters out of whatever was read after it. + while ( + address + count < startAddress + length && + (!register || register.dataType === 'none' || !register.dataType) + ) { + count++ + register = registerMap[address + count] } - // Return a string when it's a string :D - if (dataType === 'datetime' || dataType === 'unix') return value + // Slice the string to the right length + // The utf8 value starts from the current register's offset, + // so we slice from 0 (each register = 2 characters for ASCII) + return value.slice(0, count * 2) + } - const isNotANumberValue = isNaN(Number(value)) - if (isNotANumberValue) return undefined + // Return a string when it's a string :D + if (dataType === 'datetime' || dataType === 'unix') return value - if (showRaw) return Number(value) + const isNotANumberValue = isNaN(Number(value)) + if (isNotANumberValue) return undefined - const { scaledValue, precision } = convert(value, dataType, registerMap, address) - return round(scaledValue, precision) - }, - valueFormatter: (v) => (v !== undefined ? v : '') -}) + if (showRaw) return Number(value) + + const { scaledValue, precision } = convert(value, dataType, registerMap, address) + return round(scaledValue, precision) +} type ConvertFn = ( value: string, 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<RegisterData>[] => { const showRaw = useLayoutZustand((z) => z.showClientRawValues) return useMemo(() => { - const registers16Bit = ['input_registers', 'holding_registers'].includes(type) + const registers16Bit = isNumberRegister(type) const columns: GridColDef<RegisterData>[] = [addressColumn(addressBase)] 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..38fbfeac 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<HTMLInputElement, MaskInputProps>((props, r return ( <IMaskInput {...other} - mask={IMask.MaskedNumber} - {...{ - thousandsSeparator: '', - radix: '.', // fractional delimiter - mapToRadix: ['.', ','] // symbols to process as radix - }} + {...decimalMask(false)} inputRef={ref} onAccept={(value) => { set(value, true) @@ -61,10 +57,21 @@ const ValueInput = meme(ValueInputForward) interface InputFieldProps { interpolateKey: keyof RegisterLinearInterpolation value: string - set: MaskSetFn + /** + * Takes the field it is about, so the modal hands over one `useCallback` + * rather than four arrows. `meme` is `memo` with `deepEqual`, which compares + * a function by identity, so an arrow built in the modal's JSX redraws all + * four fields whenever any one of them changes. + */ + set: (interpolateKey: keyof RegisterLinearInterpolation, value: string) => void } const InputField = meme(({ interpolateKey, value, set }: InputFieldProps) => { + const handleSet = useCallback<MaskSetFn>( + (value) => set(interpolateKey, value), + [interpolateKey, set] + ) + return ( <TextField data-testid={`interpolation-${interpolateKey}-field`} @@ -75,7 +82,7 @@ const InputField = meme(({ interpolateKey, value, set }: InputFieldProps) => { slotProps={{ input: { inputComponent: ValueInput as unknown as ElementType<InputBaseComponentProps, 'input'>, - inputProps: maskInputProps({ set }) + inputProps: maskInputProps({ set: handleSet }) } }} /> @@ -126,47 +133,45 @@ const InterpolationModal = meme( }, [address]) return ( - open && ( - <Modal open={open} onClose={onClose} slotProps={{ backdrop: { sx: {} } }}> - <Paper - elevation={5} - sx={{ - position: 'absolute', - left: rect?.left ?? 0, - top: rect?.top ?? 0, - display: 'flex', - flexDirection: 'column', - gap: 2, - p: 2 - }} + <Modal open={open} onClose={onClose} slotProps={{ backdrop: { sx: {} } }}> + <Paper + elevation={5} + sx={{ + position: 'absolute', + left: rect?.left ?? 0, + top: rect?.top ?? 0, + display: 'flex', + flexDirection: 'column', + gap: 2, + p: 2 + }} + > + <Box + sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1 }} > - <Box - sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1 }} + <FormLabel color="primary">Linear Interpolation</FormLabel> + <IconButton + data-testid="interpolation-reset-btn" + color="primary" + size="small" + onClick={handleReset} > - <FormLabel color="primary">Linear Interpolation</FormLabel> - <IconButton - data-testid="interpolation-reset-btn" - color="primary" - size="small" - onClick={handleReset} - > - <Refresh /> - </IconButton> + <Refresh /> + </IconButton> + </Box> + <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> + <InputField interpolateKey="x1" value={x1} set={handleChange} /> + <InputField interpolateKey="x2" value={x2} set={handleChange} /> </Box> - <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> - <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> - <InputField interpolateKey="x1" value={x1} set={(v) => handleChange('x1', v)} /> - <InputField interpolateKey="x2" value={x2} set={(v) => handleChange('x2', v)} /> - </Box> - <ArrowRightAlt /> - <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> - <InputField interpolateKey="y1" value={y1} set={(v) => handleChange('y1', v)} /> - <InputField interpolateKey="y2" value={y2} set={(v) => handleChange('y2', v)} /> - </Box> + <ArrowRightAlt /> + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> + <InputField interpolateKey="y1" value={y1} set={handleChange} /> + <InputField interpolateKey="y2" value={y2} set={handleChange} /> </Box> - </Paper> - </Modal> - ) + </Box> + </Paper> + </Modal> ) } ) @@ -214,13 +219,20 @@ const Action = meme(({ type, address }: ActionProps): JSX.Element => { style={{ opacity: !enabled ? 0 : isDefault ? 0.2 : 1 }} /> - <InterpolationModal - address={address} - open={open} - onClose={() => setOpen(false)} - actionCellRef={actionCellRef} - type={type} - /> + {/* + Mounted only while it is open, the way `write.tsx` mounts its modal in + the same grid. `InterpolationModal` opens four store subscriptions, and + the cell it sits in is drawn once per visible row. + */} + {open && ( + <InterpolationModal + address={address} + open={open} + onClose={() => setOpen(false)} + actionCellRef={actionCellRef} + type={type} + /> + )} </> ) }) 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<RegisterData, RegisterDataWords, RegisterDataWords> => ({ - type: type ?? 'number', + type: 'number', field: `word_${key}`, headerName: key.toUpperCase(), width, diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx index 5f11c5a2..859ce3e8 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/columns/write.tsx @@ -5,7 +5,7 @@ import { GridActionsCellItem } from '@mui/x-data-grid/components' import WriteModal from '@renderer/components/client/ClientGrids/RegisterGrid/columns/WriteModal/WriteModal' import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { RegisterType, RegisterData } from '@shared' import { ReactElement, useEffect, useRef, useState } from 'react' @@ -21,7 +21,7 @@ const Action = meme(({ address, type }: ActionProps): JSX.Element => { const actionCellRef = useRef<HTMLButtonElement>(null) const apiRef = useGridApiContext() - const disabled = useClientZustand((z) => { + const disabled = useDataZustand((z) => { return z.clientState.polling || z.clientState.connectState !== 'connected' }) diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx index 16f3824e..06cd300b 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/TransactionGrid.tsx @@ -4,8 +4,8 @@ import Paper from '@mui/material/Paper' import { useGridApiContext, useGridApiRef } from '@mui/x-data-grid' import { DataGrid } from '@mui/x-data-grid/DataGrid' import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' -import { useClientZustand } from '@renderer/context/client.zustand' -import useTransactionGridColumns from './_columns' +import { useDataZustand } from '@renderer/context/data.zustand' +import useTransactionGridColumns from './columns' import { DateTime } from 'luxon' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' @@ -41,8 +41,8 @@ const ExportButton = meme((): JSX.Element => { // Clears the transaction log const ClearButton = meme((): JSX.Element => { const handleClick = useCallback((): void => { - const clientZustand = useClientZustand.getState() - clientZustand.clearTransactions() + const dataZustand = useDataZustand.getState() + dataZustand.clearTransactions() }, []) return ( @@ -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 ( <GridFooterContainer sx={{ px: 0.5, gap: 0.5 }}> <Box sx={{ flex: 1 }} /> @@ -71,7 +71,7 @@ const CustomFooter = (): JSX.Element => { <ClearButton /> </GridFooterContainer> ) -} +}) // // @@ -81,7 +81,7 @@ const CustomFooter = (): JSX.Element => { const TransactionGridContent = meme(() => { const api = useGridApiRef() - const transactions = useClientZustand((z) => z.transactions) + const transactions = useDataZustand((z) => z.transactions) const columns = useTransactionGridColumns() return ( diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/columns.tsx similarity index 98% rename from src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx rename to src/renderer/src/components/client/ClientGrids/TransactionGrid/columns.tsx index 224eabe3..618d0b9e 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/columns.tsx @@ -61,7 +61,7 @@ const responseColumn: GridColDef<Transaction, string[], string[]> = { // Responses can hold multiple responses, we display them in a formatted way // Adding the response number to the response value <Box sx={{ width: '100%', display: 'flex', flexDirection: 'column' }}> - {(value as string[]).map((v, i) => ( + {value?.map((v, i) => ( <Box key={`response_${row.id}_${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }} diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 92456454..9f63322d 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -21,7 +21,7 @@ import { meme } from '@renderer/components/shared/inputs/meme' // Protocol const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const handleChange = useCallback((_event: unknown, value: Protocol | null): void => { if (value === null) return @@ -75,10 +75,30 @@ const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { }) const ConnectButton = meme(() => { - const connectState = useClientZustand((z) => z.clientState.connectState) + const connectState = useDataZustand((z) => z.clientState.connectState) + + /** + * Whether the field this protocol connects through names somewhere. + * + * Not a schema's answer: `ConnectionConfigSchema` types both `host` and + * `com` as a bare string and takes a blank one, which is why `setHost` and + * `setCom` carry a flag of their own and keep what the field decides without + * sending it. So the field shows what was typed and main holds the value + * before it, and pressing Connect on a half typed host connected to the + * previous one while the app reported connected over a field saying + * otherwise. The field is already drawn in error, and this is the press that + * goes with it. + * + * The flag is not persisted, so `init` reads it back off the value. Main is + * handed that value either way, and a press here is what the blank has left + * to reach. + */ + const addressValid = useClientZustand((z) => + z.connectionConfig.protocol === 'ModbusRtu' ? z.valid.com : z.valid.host + ) const action = useCallback(async (): Promise<void> => { - const currentConnectedState = useClientZustand.getState().clientState.connectState + const currentConnectedState = useDataZustand.getState().clientState.connectState if (['connecting', 'connected'].includes(currentConnectedState)) { window.api.disconnect() if (!useClientZustand.getState().readConfiguration) { @@ -98,7 +118,10 @@ const ConnectButton = meme(() => { } }, []) - const disabled = ['disconnecting'].includes(connectState) + // Only the press that connects. Disconnect and the cancel a connecting state + // draws go through this same button, and neither is refused for a field. + const disabled = + connectState === 'disconnecting' || (connectState === 'disconnected' && !addressValid) const color: ButtonProps['color'] = ['connecting', 'connected'].includes(connectState) ? 'warning' diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx index d46342aa..7186160a 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig.tsx @@ -16,6 +16,8 @@ import { useComInputWidth } from '@renderer/components/shared/inputs/SerialPortInputs' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { isConnectionAddressGiven } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' @@ -23,7 +25,7 @@ import { useCallback, useEffect } from 'react' // // COM Port Input const ComInput = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const com = useClientZustand((z) => z.connectionConfig.rtu.com) const comValid = useClientZustand((z) => z.valid.com) const loading = useClientZustand((z) => z.serialPortsLoading) @@ -33,7 +35,7 @@ const ComInput = meme(() => { // Typing is valid only once it is not blank; picking from the list always is. const handleInputChange = useCallback((_event: unknown, value: string): void => { const clientZustand = useClientZustand.getState() - clientZustand.setCom(value, value.trim().length > 0) + clientZustand.setCom(value, isConnectionAddressGiven(value)) }, []) const handleChange = useCallback((_event: unknown, value: string | null): void => { @@ -67,7 +69,7 @@ const ComInput = meme(() => { // // COM Port Actions const ComActions = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const com = useClientZustand((z) => z.connectionConfig.rtu.com) const loading = useClientZustand((z) => z.serialPortsLoading) const validating = useClientZustand((z) => z.serialPortValidating) @@ -77,10 +79,15 @@ const ComActions = meme(() => { useClientZustand.getState().refreshSerialPorts() } + // A diagnostic, and it says what it found in a snackbar. It wrote `valid.com` + // as well, and that flag means something narrower: whether main was given + // what the field holds. `validateSerialPort` answers no for any path + // `getPorts()` does not enumerate, and a socat or other virtual pty is + // connectable and never enumerated, so pressing this on one greyed Connect + // with nothing but retyping the field to undo it. const onValidate = async (): Promise<void> => { - if (!com || com.trim() === '') return + if (!isConnectionAddressGiven(com)) return const result = await useClientZustand.getState().validateSerialPort(com) - useClientZustand.getState().setCom(com, result.valid) enqueueSnackbar({ message: result.message, variant: result.valid ? 'success' : 'warning' @@ -113,7 +120,7 @@ const ComActions = meme(() => { <ToggleButton value="validate" onClick={onValidate} - disabled={disabled || validating || !com || com.trim() === ''} + disabled={disabled || validating || !isConnectionAddressGiven(com)} data-testid="rtu-validate-btn" aria-label="Validate COM port" title="Validate COM port" @@ -129,7 +136,7 @@ const ComActions = meme(() => { // // COM Port (composite) const Com = meme((): JSX.Element => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') useEffect(() => { if (!disabled) useClientZustand.getState().refreshSerialPorts() @@ -147,7 +154,7 @@ const Com = meme((): JSX.Element => { // // Selects (thin wrappers over shared components) const ClientBaudRateSelect = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const baudRate = useClientZustand((z) => z.connectionConfig.rtu.options.baudRate) const setBaudRate = useClientZustand.getState().setBaudRate @@ -156,7 +163,7 @@ const ClientBaudRateSelect = meme(() => { }) const ClientParitySelect = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const parity = useClientZustand((z) => z.connectionConfig.rtu.options.parity ?? 'none') const setParity = useClientZustand.getState().setParity @@ -165,7 +172,7 @@ const ClientParitySelect = meme(() => { }) const ClientDataBitsSelect = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const dataBits = useClientZustand((z) => z.connectionConfig.rtu.options.dataBits) const setDataBits = useClientZustand.getState().setDataBits @@ -174,7 +181,7 @@ const ClientDataBitsSelect = meme(() => { }) const ClientStopBitsSelect = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const stopBits = useClientZustand((z) => z.connectionConfig.rtu.options.stopBits) const setStopBits = useClientZustand.getState().setStopBits diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx index b78352ba..71b92127 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig.tsx @@ -6,11 +6,12 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { ElementType } from 'react' // Host const Host = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const host = useClientZustand((z) => z.connectionConfig.tcp.host) const hostValid = useClientZustand((z) => z.valid.host) @@ -40,7 +41,7 @@ const Host = meme(() => { // // Port const Port = meme(() => { - const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useDataZustand((z) => z.clientState.connectState !== 'disconnected') const port = useClientZustand((z) => String(z.connectionConfig.tcp.options.port)) const setPort = useClientZustand.getState().setPort diff --git a/src/renderer/src/components/client/ConnectionConfig/__tests__/connectButton.test.tsx b/src/renderer/src/components/client/ConnectionConfig/__tests__/connectButton.test.tsx new file mode 100644 index 00000000..9614ccab --- /dev/null +++ b/src/renderer/src/components/client/ConnectionConfig/__tests__/connectButton.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// `setHost` and `setCom` keep a value the schema refuses in the store without +// sending it, so the field shows what was typed and main holds the value +// before it. The button disabled on `disconnecting` alone, so a press on a +// half typed host connected to the previous one and the app reported connected +// over a field saying otherwise. +import { describe, it, expect, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { render, screen } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { ClientState, defaultClientState, defaultConnectionConfig, Protocol } from '@shared' +import ConnectionConfig from '../ConnectionConfig' + +const renderButton = ({ + connectState = 'disconnected', + protocol = 'ModbusTcp', + host = true, + com = true +}: { + connectState?: ClientState['connectState'] + protocol?: Protocol + host?: boolean + com?: boolean +}): HTMLElement => { + useClientZustand.setState({ + ready: true, + connectionConfig: { ...defaultConnectionConfig, protocol }, + valid: { host, com, length: true } + } as never) + useDataZustand.setState({ clientState: { ...defaultClientState, connectState } }) + render(<ConnectionConfig />) + return screen.getByTestId('connect-btn') +} + +describe('the Connect button', () => { + it('takes a press on a host the boundary took', () => { + expect(renderButton({})).toBeEnabled() + }) + + it('takes none on a host main was never given', () => { + expect(renderButton({ host: false })).toBeDisabled() + }) + + // The COM port is the RTU field, and the host is not asked about there. + it('takes none on a COM port main was never given', () => { + expect(renderButton({ protocol: 'ModbusRtu', com: false })).toBeDisabled() + }) + + it('takes a press on RTU while the host is invalid', () => { + expect(renderButton({ protocol: 'ModbusRtu', host: false })).toBeEnabled() + }) + + it('takes a press on TCP while the COM port is invalid', () => { + expect(renderButton({ com: false })).toBeEnabled() + }) + + // Disconnect and the cancel a connecting state draws go through this same + // button, and a field nobody can edit while connected must not stop either. + it.each(['connected', 'connecting'] as const)( + 'takes a press in %s whatever the field holds', + (connectState) => { + expect(renderButton({ connectState, host: false })).toBeEnabled() + } + ) + + it('takes none while disconnecting', () => { + expect(renderButton({ connectState: 'disconnecting' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/client/ConnectionConfig/__tests__/validateButton.test.tsx b/src/renderer/src/components/client/ConnectionConfig/__tests__/validateButton.test.tsx new file mode 100644 index 00000000..c70d4e68 --- /dev/null +++ b/src/renderer/src/components/client/ConnectionConfig/__tests__/validateButton.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// Validate is a diagnostic and says what it found in a snackbar. It wrote +// `valid.com` as well, and that flag means something narrower: whether main +// was given what the field holds. `validateSerialPort` answers no for any path +// `getPorts()` does not enumerate, and a socat or other virtual pty is +// connectable and never enumerated, so pressing this on one greyed Connect. +import { describe, it, expect, vi } from 'vitest' + +// The client store registers IPC listeners and calls main at import time. +vi.hoisted(async () => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const { stubRenderer } = await import('@renderer/context/__tests__/stubRenderer') + stubRenderer() +}) + +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' +import { defaultClientState, defaultConnectionConfig } from '@shared' +import ConnectionConfig from '../ConnectionConfig' + +const renderRtu = (): void => { + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'disconnected' } }) + useClientZustand.setState({ + ready: true, + connectionConfig: { + ...defaultConnectionConfig, + protocol: 'ModbusRtu', + rtu: { ...defaultConnectionConfig.rtu, com: '/tmp/ttyV1' } + }, + valid: { host: true, com: true, length: true }, + serialPorts: [], + validateSerialPort: async () => ({ valid: false, message: 'Port not found' }) + } as never) + render(<ConnectionConfig />) +} + +describe('the Validate button', () => { + it('leaves Connect alone for a port it could not enumerate', async () => { + renderRtu() + + fireEvent.click(screen.getByTestId('rtu-validate-btn')) + + await waitFor(() => { + expect(useClientZustand.getState().valid.com).toBe(true) + }) + expect(screen.getByTestId('connect-btn')).toBeEnabled() + }) +}) diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index b7dc4735..b87915e7 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -14,15 +14,22 @@ 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 { clientOwner, maxReadQuantity, registersFrom, 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(() => { 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 sits in the top bar, + // and what kept it out of reach was the strip the dialog draws over it. + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) + const handleChange = useCallback((type: RegisterType) => { if (!useClientZustand.getState().readConfiguration) { useDataZustand.getState().setRegisterData([]) @@ -34,6 +41,7 @@ const TypeSelect = meme(() => { <FormControl size="small"> <InputLabel id={labelId}>Type</InputLabel> <Select + disabled={scanning} size="small" labelId={labelId} value={type} @@ -77,10 +85,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 ( <TextField disabled={readConfiguration} @@ -94,7 +109,7 @@ const Length = meme(() => { slotProps={{ input: { inputComponent: LengthInput as unknown as ElementType<InputBaseComponentProps, 'input'>, - inputProps: maskInputProps({ set: setLength, max: 65536 - address }) + inputProps: maskInputProps({ set: setLength, max }) } }} /> @@ -103,14 +118,28 @@ const Length = meme(() => { const ReadConfiguration = meme(() => { const readConfiguration = useClientZustand((z) => !!z.readConfiguration) - const handleChange = useCallback((_: React.MouseEvent, v: boolean | null) => { + + // 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 - // 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() - 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) }, []) @@ -127,11 +156,18 @@ const ReadConfiguration = meme(() => { ) ) - // Turning it on asks main to read, and main refuses a read while one is in - // flight. The toggle goes off for as long as that lasts, rather than taking a - // press that answers with a warning. - const reading = useClientZustand((z) => z.clientState.reading) - const disabled = nothingConfigured || reading + // 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 + // the next `_read` builds its groups out of both, so the rows arrive within + // one poll rate with no ask of ours. Asking the whole question greyed the + // press that turns it off as well, which asks main for nothing at all. + const owner = useDataZustand((z) => clientOwner(z.clientState, { exceptPolling: true })) + const disabled = nothingConfigured || owner !== undefined // A mapping with nothing to read turns it off. A read in flight does not: // that greys the button for a moment, and turning it off would empty the grid 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..2611c666 100644 --- a/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx +++ b/src/renderer/src/components/client/RegisterConfig/__tests__/RegisterConfig.test.tsx @@ -13,8 +13,10 @@ vi.hoisted(() => { w.api = new Proxy({}, { get: () => () => Promise.resolve(undefined) }) }) -import { render, screen } 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 { useDataZustand } from '@renderer/context/data.zustand' import { defaultClientState, RegisterType } from '@shared' import RegisterConfig from '../RegisterConfig' @@ -39,11 +41,8 @@ const seed = (type: RegisterType, mapping: Record<number, object>): void => { // included, and `init` writes that answer into the store. The button reads the // client state, so it gets one here. beforeEach(() => { - useClientZustand.setState({ - ready: true, - readConfiguration: false, - clientState: { ...defaultClientState } - } as never) + useClientZustand.setState({ ready: true, readConfiguration: false } as never) + useDataZustand.setState({ clientState: { ...defaultClientState } }) }) describe('RegisterConfig read configuration', () => { @@ -91,33 +90,224 @@ describe('RegisterConfig read configuration', () => { // flight. The button says so instead of taking the press. it('refuses while a read is in flight, and offers again after it', () => { seed('holding_registers', { 0: { dataType: 'int16' } }) - useClientZustand.setState({ + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'connected', reading: true } - } as never) + }) const { rerender } = render(<RegisterConfig />) expect(screen.getByTestId('reg-read-config-btn')).toBeDisabled() - useClientZustand.setState({ + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'connected', reading: false } - } as never) + }) rerender(<RegisterConfig />) expect(screen.getByTestId('reg-read-config-btn')).toBeEnabled() }) + // A write holds the client until it has read the register back, and main + // refuses a read for that whole stretch. + it('refuses while a write is in flight', () => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', writing: true } + }) + + render(<RegisterConfig />) + + expect(screen.getByTestId('reg-read-config-btn')).toBeDisabled() + }) + + // Both scans were missing from the question this toggle asked, so a press + // there drew the mapping and then dropped the read without a word. + it.each(['scanningRegisters', 'scanningUnitIds'] as const)( + 'refuses while %s owns the client', + (flag) => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', [flag]: true } + }) + + render(<RegisterConfig />) + + expect(screen.getByTestId('reg-read-config-btn')).toBeDisabled() + } + ) + + // A poll is the one owner that fills the grid on its own, and the press that + // turns read configuration off asks main for nothing, so neither direction + // has anything to wait for. + it('offers while a poll runs', () => { + seed('holding_registers', { 0: { dataType: 'int16' } }) + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', polling: true } + }) + + render(<RegisterConfig />) + + expect(screen.getByTestId('reg-read-config-btn')).toBeEnabled() + }) + // A mapping with nothing to read turns read configuration off. A read in // flight greys the same button and must not, or the grid empties while the // read that is about to fill it is still on the wire. it('leaves read configuration on while a read is in flight', () => { seed('holding_registers', { 0: { dataType: 'int16' } }) - useClientZustand.setState({ - readConfiguration: true, + useClientZustand.setState({ readConfiguration: true } as never) + useDataZustand.setState({ clientState: { ...defaultClientState, connectState: 'connected', reading: true } - } as never) + }) render(<RegisterConfig />) expect(useClientZustand.getState().readConfiguration).toBe(true) }) }) + +// A scan reads the register type once for the chunk size one response carries, +// and again for every chunk. Changing it in between asks a device for 2000 +// holding registers. +describe('RegisterConfig type select', () => { + it('is off while a register scan runs', () => { + useDataZustand.setState({ + clientState: { ...defaultClientState, connectState: 'connected', scanningRegisters: true } + }) + + render(<RegisterConfig />) + + expect(within(screen.getByTestId('reg-type-select')).getByRole('combobox')).toHaveAttribute( + 'aria-disabled', + 'true' + ) + }) + + it('is there to press when no scan runs', () => { + render(<RegisterConfig />) + + expect(within(screen.getByTestId('reg-type-select')).getByRole('combobox')).not.toHaveAttribute( + 'aria-disabled' + ) + }) +}) + +// 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<number> => { + render(<RegisterConfig />) + 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', () => { + // 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<string, unknown> + const named: Record<string, unknown> = { + 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(<RegisterConfig />) + fireEvent.click(screen.getByTestId('reg-read-config-btn')) + + 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<true>((resolve) => { + take = resolve + }) + ) + const stubbed = window.api as unknown as Record<string, unknown> + const named: Record<string, unknown> = { + setRegisterMapping, + setReadConfiguration: vi.fn(), + read: vi.fn() + } + window.api = new Proxy( + {}, + { get: (_target, method: string) => named[method] ?? stubbed[method] } + ) as never + + render(<RegisterConfig />) + 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) + + render(<RegisterConfig />) + 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/components/client/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx deleted file mode 100644 index ad87ab6a..00000000 --- a/src/renderer/src/components/client/ScanProgress/ScanProgress.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import Button from '@mui/material/Button' -import IconButton from '@mui/material/IconButton' -import { InputBaseComponentProps } from '@mui/material/InputBase' -import LinearProgress from '@mui/material/LinearProgress' -import TextField from '@mui/material/TextField' -import Tooltip from '@mui/material/Tooltip' -import Typography from '@mui/material/Typography' -import Visibility from '@mui/icons-material/Visibility' -import VisibilityOff from '@mui/icons-material/VisibilityOff' -import { integerMask } from '@renderer/components/shared/inputs/integerMask' -import { meme } from '@renderer/components/shared/inputs/meme' -import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useClientZustand } from '@renderer/context/client.zustand' -import { MaskSetFn } from '@renderer/context/client.zustand.types' -import { ElementType, forwardRef } from 'react' -import { IMaskInput } from 'react-imask' - -// Scan progress -export const ScanProgress = meme(() => { - const scanning = useClientZustand( - (z) => z.clientState.scanningUnitIds || z.clientState.scanningRegisters - ) - const scanProgress = useClientZustand((z) => z.scanProgress) - - return scanning ? ( - <LinearProgress - variant="determinate" - value={scanProgress} - color="primary" - sx={{ - width: '100%', - '& .MuiLinearProgress-bar1Determinate': { transition: 'none', animation: 'none' } - }} - /> - ) : null -}) - -/** - * The timeout a scan gives each request, in milliseconds. Zero switches the - * timer off in modbus-serial and the scan then hangs on the first silent unit; - * ten seconds an address is already a long afternoon. - * - * The bounds stay off the mask, which rewrites what you type: clearing the - * field stores 0, the mask commits that up to its floor, and the digits land - * behind it. Typing 500 into an empty field gave 10000. Leaving the field is - * late enough to correct it. - */ -const SCAN_TIMEOUT_MIN = 100 -const SCAN_TIMEOUT_MAX = 10000 - -export const clampScanTimeout = (timeout: number): number => - Math.min(SCAN_TIMEOUT_MAX, Math.max(SCAN_TIMEOUT_MIN, timeout)) - -const TimeoutInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { - const { set, ...other } = props - return ( - <IMaskInput - {...other} - {...integerMask} - min={0} - inputRef={ref} - onAccept={(value) => set(value, true)} - /> - ) -}) - -TimeoutInputForward.displayName = 'TimeoutInput' -export const TimeoutInput = meme(TimeoutInputForward) - -interface TimeoutFieldProps { - disabled: boolean - timeout: number - setTimeout: MaskSetFn - testId: string -} - -/** Both scan dialogs ask for the same timeout, and ask for it the same way. */ -export const ScanTimeoutField = meme( - ({ disabled, timeout, setTimeout, testId }: TimeoutFieldProps): JSX.Element => ( - <TextField - disabled={disabled} - label="Timeout (ms)" - variant="outlined" - size="small" - sx={{ width: 90 }} - value={String(timeout)} - onBlur={() => setTimeout(String(clampScanTimeout(timeout)))} - data-testid={testId} - slotProps={{ - input: { - inputComponent: TimeoutInput as unknown as ElementType<InputBaseComponentProps, 'input'>, - inputProps: maskInputProps({ set: setTimeout }) - } - }} - /> - ) -) - -interface ScanFoundCountProps { - count: number - testId: string -} - -/** - * A scan that turns up nothing looks exactly like a scan still warming up, and - * the grid behind shows the first rows rather than how many there are. - * - * Plain text rather than a chip. A chip is a badge on something, and this is a - * reading: it belongs beside the buttons the way a number belongs on a gauge. - * The colour carries the same thing the text does, so `data-found` says it - * once for anything reading the page. - */ -export const ScanFoundCount = meme( - ({ count, testId }: ScanFoundCountProps): JSX.Element => ( - <Typography - variant="body2" - sx={(theme) => ({ - fontFamily: 'monospace', - whiteSpace: 'nowrap', - color: count > 0 ? theme.palette.success.main : theme.palette.warning.main - })} - data-testid={testId} - data-found={count > 0} - > - Found: {count} - </Typography> - ) -) - -interface ScanCloseButtonProps { - disabled: boolean - close: () => void - testId: string -} - -/** - * The way out of a scan dialog. - * - * Clicking beside it used to be it, which threw away the scan you were setting - * up on the way to anything else on screen. A button rather than a cross, so - * it carries the same weight as the one beside it, and off while a scan runs - * for the same reason the backdrop click was ignored then. - */ -export const ScanCloseButton = meme( - ({ disabled, close, testId }: ScanCloseButtonProps): JSX.Element => ( - <Button color="primary" disabled={disabled} onClick={close} data-testid={testId}> - Close - </Button> - ) -) - -interface ScanGridToggleProps { - shown: boolean - toggle: () => void -} - -/** - * Whether the grid keeps filling while the scan runs. - * - * Watching the rows arrive is the point, and it costs almost nothing now that - * they are written in batches. It is still a choice: a slow machine, or simply - * not wanting the movement. - */ -export const ScanGridToggle = meme( - ({ shown, toggle }: ScanGridToggleProps): JSX.Element => ( - <Tooltip title={shown ? 'Hide the grid while scanning' : 'Show the grid while scanning'}> - <IconButton - size="small" - color="primary" - onClick={toggle} - aria-label={shown ? 'Hide the grid while scanning' : 'Show the grid while scanning'} - data-testid="scan-grid-toggle-btn" - > - {shown ? <Visibility /> : <VisibilityOff />} - </IconButton> - </Tooltip> - ) -) diff --git a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx index d9e112f2..01962665 100644 --- a/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ScanRegisters/ScanRegisters.tsx @@ -1,33 +1,31 @@ import Box from '@mui/material/Box' -import Button from '@mui/material/Button' import { InputBaseComponentProps } from '@mui/material/InputBase' import Modal from '@mui/material/Modal' import Paper from '@mui/material/Paper' import TextField from '@mui/material/TextField' import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useClientZustand } from '@renderer/context/client.zustand' -import { ElementType, useCallback, useMemo } from 'react' +import { ElementType, useCallback } from 'react' import { maskInputProps } from '@renderer/components/shared/inputs/types' import UIntInput from '@renderer/components/shared/inputs/UintInput' import UnitIdInput from '@renderer/components/shared/inputs/UnitIdInput' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' import { dropPendingScanRows, useDataZustand } from '@renderer/context/data.zustand' -import { - ScanCloseButton, - ScanFoundCount, - ScanGridToggle, - ScanProgress, - ScanTimeoutField -} from '../ScanProgress/ScanProgress' +import ScanCloseButton from '../scan/ScanCloseButton' +import ScanFoundCount from '../scan/ScanFoundCount' +import ScanGridToggle from '../scan/ScanGridToggle' +import ScanProgress from '../scan/ScanProgress' +import ScanStartStopButton from '../scan/ScanStartStopButton' +import ScanTimeoutField from '../scan/ScanTimeoutField' import { meme } from '@renderer/components/shared/inputs/meme' -import { maxReadQuantity } from '@shared' +import { MAX_REGISTER_ADDRESS, maxReadQuantity } from '@shared' import { useScanRegistersZustand } from './scanRegisters.zustand' // // // Unit ID field (syncs with main connection config) const UnitIdField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) const setUnitId = useClientZustand.getState().setUnitId @@ -55,7 +53,7 @@ const UnitIdField = meme((): JSX.Element => { // // Address field with base toggle const AddressField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const address = useScanRegistersZustand((z) => z.address) const setAddress = useScanRegistersZustand.getState().setAddress @@ -75,11 +73,21 @@ 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 scanning = useDataZustand((z) => z.clientState.scanningRegisters) + const scanLength = useScanRegistersZustand((z) => z.scanLength) const setScanLength = useScanRegistersZustand.getState().setScanLength + const handleBlur = useCallback( + () => setScanLength(String(Math.max(1, scanLength))), + [scanLength, 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 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 ( <TextField disabled={scanning} @@ -87,7 +95,8 @@ const ScanLengthField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 90 }} - value={scanLength} + value={String(scanLength)} + onBlur={handleBlur} data-testid="scan-length-input" slotProps={{ input: { @@ -103,15 +112,22 @@ 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 scanning = useDataZustand((z) => z.clientState.scanningRegisters) + 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 on the blur, the way Length's is: `ScanRegistersParametersSchema` takes + // a positive length. const max = maxReadQuantity([type]) const setChunkSize = useScanRegistersZustand.getState().setChunkSize + const handleBlur = useCallback( + () => setChunkSize(String(Math.max(1, chunkSize))), + [chunkSize, setChunkSize] + ) + return ( <TextField disabled={scanning} @@ -119,7 +135,8 @@ const ChunkSizeField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 90 }} - value={chunkSize} + value={String(chunkSize)} + onBlur={handleBlur} data-testid="scan-chunk-size-input" slotProps={{ input: { @@ -135,7 +152,7 @@ const ChunkSizeField = meme((): JSX.Element => { // // Timeout field const TimeoutField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const timeout = useScanRegistersZustand((z) => z.timeout) const setTimeout = useScanRegistersZustand.getState().setTimeout @@ -160,7 +177,7 @@ const TimeoutField = meme((): JSX.Element => { // up, and it means that while a scan is running, since the same list holds // polled data the rest of the time. const FoundCount = meme((): JSX.Element | null => { - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const count = useDataZustand((z) => z.registerData.length) if (!scanning) return null @@ -179,14 +196,14 @@ const GridToggle = meme((): JSX.Element => { layoutZustand.toggleShowGridWhileScanning() }, []) - return <ScanGridToggle shown={shown} toggle={handleToggle} /> + return <ScanGridToggle shown={shown} toggle={handleToggle} testId="scan-grid-toggle-btn" /> }) // // // Scan button const ScanButton = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const scan = useCallback(async () => { if (scanning) { @@ -201,27 +218,36 @@ const ScanButton = meme((): JSX.Element => { // A scan walks raw addresses, which is what the extra columns are for, and // the rows land in a grid you are now watching fill. if (!clientZustand.registerConfig.advancedMode) clientZustand.setAdvancedMode(true) - clientZustand.setScanProgress(0) + dataZustand.setScanProgress(0) dropPendingScanRows() dataZustand.setRegisterData([]) const { address, scanLength, chunkSize, timeout } = scanRegistersZustand + // 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. + // + // 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, 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]) - const text = useMemo(() => (scanning ? 'Stop Scanning' : 'Start Scanning'), [scanning]) - const color = useMemo(() => (scanning ? 'warning' : 'primary'), [scanning]) - - return ( - <Button variant="contained" color={color} onClick={scan} data-testid="scan-start-stop-btn"> - {text} - </Button> - ) + return <ScanStartStopButton scanning={scanning} scan={scan} testId="scan-start-stop-btn" /> }) // @@ -230,11 +256,10 @@ const ScanButton = meme((): JSX.Element => { const ScanRegisters = meme(() => { const open = useScanRegistersZustand((z) => z.open) - const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const scanning = useDataZustand((z) => z.clientState.scanningRegisters) const handleClose = useCallback(() => { - const clientZustand = useClientZustand.getState() - if (clientZustand.clientState.scanningRegisters) return + if (useDataZustand.getState().clientState.scanningRegisters) return useScanRegistersZustand.getState().setOpen(false) }, []) 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..0d46bfee --- /dev/null +++ b/src/renderer/src/components/client/ScanRegisters/__tests__/ScanRegisters.test.tsx @@ -0,0 +1,117 @@ +// @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 { useDataZustand } from '@renderer/context/data.zustand' +import { defaultClientState, ScanRegistersParametersSchema } from '@shared' +import ScanRegisters from '../ScanRegisters' +import { useScanRegistersZustand } from '../scanRegisters.zustand' + +const payload = async (): Promise<unknown> => { + 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 } as never) + useDataZustand.setState({ clientState: { ...defaultClientState } }) + 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(<ScanRegisters />) + + fireEvent.click(screen.getByTestId('scan-start-stop-btn')) + + expect(await payload()).toMatchObject({ addressRange: [60000, 65535] }) + 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(<ScanRegisters />) + + 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 }) + + render(<ScanRegisters />) + + 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(<ScanRegisters />) + 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(<ScanRegisters />) + 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(<ScanRegisters />) + 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..65e2d273 100644 --- a/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/ScanUnitIds.tsx @@ -1,5 +1,4 @@ import Box from '@mui/material/Box' -import Button from '@mui/material/Button' import { InputBaseComponentProps } from '@mui/material/InputBase' import Modal from '@mui/material/Modal' import Paper from '@mui/material/Paper' @@ -11,19 +10,22 @@ import { DataGrid } from '@mui/x-data-grid/DataGrid' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' 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 { ElementType, useCallback, useMemo } from 'react' -import useScanUnitIdColumns from './_columns' +import { useDataZustand } from '@renderer/context/data.zustand' +import { MAX_UNIT_ID, maxReadQuantity, RegisterType, registersFrom } from '@shared' +import { ElementType, useCallback } from 'react' +import useScanUnitIdColumns from './columns' import { useScanUnitIdZustand } from './scanUnitIds.zustand' -import { ScanCloseButton, ScanProgress, ScanTimeoutField } from '../ScanProgress/ScanProgress' +import ScanCloseButton from '../scan/ScanCloseButton' +import ScanProgress from '../scan/ScanProgress' +import ScanStartStopButton from '../scan/ScanStartStopButton' +import ScanTimeoutField from '../scan/ScanTimeoutField' import { meme } from '@renderer/components/shared/inputs/meme' // // // Start Unit ID field const StartUnitIdField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) const setStartUnitId = useScanUnitIdZustand.getState().setStartUnitId @@ -40,7 +42,7 @@ const StartUnitIdField = meme((): JSX.Element => { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType<InputBaseComponentProps, 'input'>, - inputProps: maskInputProps({ set: setStartUnitId, max: 255 }) + inputProps: maskInputProps({ set: setStartUnitId, max: MAX_UNIT_ID }) } }} /> @@ -51,11 +53,18 @@ 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 scanning = useDataZustand((z) => z.clientState.scanningUnitIds) + 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 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. + const handleBlur = useCallback(() => setCount(String(Math.max(1, count))), [count, setCount]) + return ( <TextField disabled={scanning} @@ -63,12 +72,13 @@ const CountField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 80 }} - value={count} + value={String(count)} + onBlur={handleBlur} data-testid="scan-unitid-count-input" slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType<InputBaseComponentProps, 'input'>, - inputProps: maskInputProps({ set: setCount, max: 256 }) + inputProps: maskInputProps({ set: setCount, max: MAX_UNIT_ID + 1 }) } }} /> @@ -79,7 +89,7 @@ const CountField = meme((): JSX.Element => { // // Address field with base toggle const AddressField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const address = useScanUnitIdZustand((z) => z.address) const setAddress = useScanUnitIdZustand.getState().setAddress @@ -99,18 +109,25 @@ 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 scanning = useDataZustand((z) => z.clientState.scanningUnitIds) + 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 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 + const handleBlur = useCallback(() => setLength(String(Math.max(1, length))), [length, setLength]) + return ( <TextField disabled={scanning} @@ -118,7 +135,8 @@ const LengthField = meme((): JSX.Element => { variant="outlined" size="small" sx={{ width: 60 }} - value={length} + value={String(length)} + onBlur={handleBlur} data-testid="scan-unitid-length-input" slotProps={{ input: { @@ -134,7 +152,7 @@ const LengthField = meme((): JSX.Element => { // // Timeout field const TimeoutField = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const timeout = useScanUnitIdZustand((z) => z.timeout) const setTimeout = useScanUnitIdZustand.getState().setTimeout @@ -153,7 +171,7 @@ const TimeoutField = meme((): JSX.Element => { // // Select register types const SelectRegisterTypes = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const handleChange = useCallback((_event: unknown, value: RegisterType[]): void => { @@ -204,7 +222,7 @@ const SelectRegisterTypes = meme((): JSX.Element => { // // Scan button const ScanButton = meme((): JSX.Element => { - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const disabled = useScanUnitIdZustand((z) => z.registerTypes.length === 0) const scan = useCallback(() => { @@ -214,39 +232,41 @@ const ScanButton = meme((): JSX.Element => { } const scanUnitIdZustand = useScanUnitIdZustand.getState() - const clientZustand = useClientZustand.getState() - clientZustand.clearScanUnitIdResults() - clientZustand.setScanProgress(0) + const dataZustand = useDataZustand.getState() + dataZustand.clearScanUnitIdResults() + dataZustand.setScanProgress(0) 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, + // 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, 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 }) }, [scanning]) - const text = useMemo(() => (scanning ? 'Stop Scanning' : 'Start Scanning'), [scanning]) - const color = useMemo(() => (scanning ? 'warning' : 'primary'), [scanning]) - return ( - <Button + <ScanStartStopButton disabled={disabled} - variant="contained" - color={color} - onClick={scan} - data-testid="scan-unitid-start-stop-btn" - > - {text} - </Button> + scanning={scanning} + scan={scan} + testId="scan-unitid-start-stop-btn" + /> ) }) @@ -254,7 +274,7 @@ const ScanButton = meme((): JSX.Element => { // // Scan result grid const ScanResultGrid = meme(() => { - const scanResults = useClientZustand((z) => z.scanUnitIdResults) + const scanResults = useDataZustand((z) => z.scanUnitIdResults) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const columns = useScanUnitIdColumns() @@ -316,15 +336,15 @@ const ScanUnitIds = meme(() => { const open = useScanUnitIdZustand((z) => z.open) // Don't close while scanning - const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const scanning = useDataZustand((z) => z.clientState.scanningUnitIds) const handleClose = useCallback(() => { - const clientZustand = useClientZustand.getState() + const dataZustand = useDataZustand.getState() const scanUnitIdZustand = useScanUnitIdZustand.getState() - if (clientZustand.clientState.scanningUnitIds) return + if (dataZustand.clientState.scanningUnitIds) return // The results belong to the dialog. Leaving them behind means the next // scan opens on the last one and fills in around it. - clientZustand.clearScanUnitIdResults() + dataZustand.clearScanUnitIdResults() scanUnitIdZustand.setOpen(false) }, []) 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..6c8df922 --- /dev/null +++ b/src/renderer/src/components/client/ScanUnitIds/__tests__/ScanUnitIds.test.tsx @@ -0,0 +1,128 @@ +// @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 { useDataZustand } from '@renderer/context/data.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 } as never) + useDataZustand.setState({ clientState: { ...defaultClientState } }) + 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(<ScanUnitIds />) + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ range: [200, 255] }) + 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(<ScanUnitIds />) + + fireEvent.click(screen.getByTestId('scan-unitid-start-stop-btn')) + + expect(payload()).toMatchObject({ range: [0, 0], length: 1 }) + 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 }) + + render(<ScanUnitIds />) + + 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(<ScanUnitIds />) + 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(<ScanUnitIds />) + 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(<ScanUnitIds />) + fireEvent.blur(input('scan-unitid-count-input')) + + expect(useScanUnitIdZustand.getState().count).toBe(100) + }) +}) diff --git a/src/renderer/src/components/client/ScanUnitIds/__tests__/columns.test.tsx b/src/renderer/src/components/client/ScanUnitIds/__tests__/columns.test.tsx new file mode 100644 index 00000000..e8bf9f7e --- /dev/null +++ b/src/renderer/src/components/client/ScanUnitIds/__tests__/columns.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +// +// A refusal is not a silence, and the cell says which by the colour it carries. +// The e2e spec reaches two of the four answers: it asserts `.scan-silent` has +// no cell, because every unit in it replies. +import { describe, it, expect, vi } from 'vitest' + +// The column set is built from the store the dialog holds, and that store's +// module imports the client store, which calls main at import time. +vi.hoisted(() => { + ;(globalThis as { window?: unknown }).window ??= globalThis + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { + on: (): (() => void) => (): void => {}, + send: (): void => {}, + invoke: (): Promise<undefined> => Promise.resolve(undefined) + } + } + w.api = new Proxy( + {}, + { get: (): (() => Promise<undefined>) => (): Promise<undefined> => Promise.resolve(undefined) } + ) +}) + +import { renderHook } from '@testing-library/react' +import { GridColDef } from '@mui/x-data-grid/models' +import { RegisterType, ScanUnitIDResult } from '@shared' +import useScanUnitIdColumns from '../columns' +import { useScanUnitIdZustand } from '../scanUnitIds.zustand' + +const EVERY_TYPE: RegisterType[] = [ + 'coils', + 'discrete_inputs', + 'holding_registers', + 'input_registers' +] + +/** One unit that answered, refused and said nothing, and was asked once more. */ +const row: ScanUnitIDResult = { + id: 7, + registerTypes: ['coils'], + refusedRegisterTypes: ['discrete_inputs'], + requestedRegisterTypes: ['coils', 'discrete_inputs', 'holding_registers'], + errorMessage: { + coils: '', + discrete_inputs: '', + holding_registers: '', + input_registers: '' + } +} + +const columnOf = (columns: GridColDef<ScanUnitIDResult>[], field: RegisterType): GridColDef => { + const column = columns.find((candidate) => candidate.field === field) + if (!column) throw new Error(`no column for ${field}`) + return column +} + +/** The word the filter reads, and the class the grid paints the cell with. */ +const cell = (field: RegisterType): { value: unknown; className: unknown } => { + useScanUnitIdZustand.setState({ registerTypes: EVERY_TYPE }) + const { result } = renderHook(() => useScanUnitIdColumns()) + const column = columnOf(result.current, field) + const { valueGetter, cellClassName } = column + if (!valueGetter) throw new Error(`the ${field} column has no valueGetter`) + if (typeof cellClassName !== 'function') throw new Error(`the ${field} column has no class`) + + return { + value: valueGetter(undefined as never, row, column, undefined as never), + className: cellClassName({ row } as never) + } +} + +describe('what a unit id did with one request', () => { + it('answered with data', () => { + expect(cell('coils')).toEqual({ value: 'answered', className: 'scan-answered' }) + }) + + it('answered with an exception', () => { + expect(cell('discrete_inputs')).toEqual({ value: 'refused', className: 'scan-refused' }) + }) + + it('was asked and said nothing', () => { + expect(cell('holding_registers')).toEqual({ value: 'silent', className: 'scan-silent' }) + }) + + // A type the scan never asked for is not an outcome. `null` keeps the row out + // of every one of the three filters rather than into one of them. + it('was never asked', () => { + expect(cell('input_registers')).toEqual({ value: null, className: '' }) + }) +}) diff --git a/src/renderer/src/components/client/ScanUnitIds/_columns.tsx b/src/renderer/src/components/client/ScanUnitIds/columns.tsx similarity index 98% rename from src/renderer/src/components/client/ScanUnitIds/_columns.tsx rename to src/renderer/src/components/client/ScanUnitIds/columns.tsx index 205e7ab7..f3d447f9 100644 --- a/src/renderer/src/components/client/ScanUnitIds/_columns.tsx +++ b/src/renderer/src/components/client/ScanUnitIds/columns.tsx @@ -9,7 +9,7 @@ import Box from '@mui/material/Box' * * A refusal is not a silence. An exception reply means the unit is there and * talking, and it answered this particular question with no; nothing coming - * back at all means there is no unit at that address. Both used to be red. + * back at all means there is no unit at that address. */ type ScanOutcome = 'answered' | 'refused' | 'silent' | 'unasked' diff --git a/src/renderer/src/components/client/scan/ScanCloseButton.tsx b/src/renderer/src/components/client/scan/ScanCloseButton.tsx new file mode 100644 index 00000000..2b43e542 --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanCloseButton.tsx @@ -0,0 +1,27 @@ +import Button from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' + +interface ScanCloseButtonProps { + disabled: boolean + close: () => void + testId: string +} + +/** + * The way out of a scan dialog. + * + * A button rather than a cross, so it carries the same weight as the one + * beside it. A click beside the dialog is ignored, because reaching for + * anything else on screen would throw away the scan you were setting up; + * Escape still closes, which both dialogs say where they take it. Off while a + * scan runs. + */ +const ScanCloseButton = meme( + ({ disabled, close, testId }: ScanCloseButtonProps): JSX.Element => ( + <Button color="primary" disabled={disabled} onClick={close} data-testid={testId}> + Close + </Button> + ) +) + +export default ScanCloseButton diff --git a/src/renderer/src/components/client/scan/ScanFoundCount.tsx b/src/renderer/src/components/client/scan/ScanFoundCount.tsx new file mode 100644 index 00000000..74fd6fad --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanFoundCount.tsx @@ -0,0 +1,35 @@ +import Typography from '@mui/material/Typography' +import { meme } from '@renderer/components/shared/inputs/meme' + +interface ScanFoundCountProps { + count: number + testId: string +} + +/** + * A scan that turns up nothing looks exactly like a scan still warming up, and + * the grid behind shows the first rows rather than how many there are. + * + * Plain text rather than a chip. A chip is a badge on something, and this is a + * reading: it belongs beside the buttons the way a number belongs on a gauge. + * The colour carries the same thing the text does, so `data-found` says it + * once for anything reading the page. + */ +const ScanFoundCount = meme( + ({ count, testId }: ScanFoundCountProps): JSX.Element => ( + <Typography + variant="body2" + sx={(theme) => ({ + fontFamily: 'monospace', + whiteSpace: 'nowrap', + color: count > 0 ? theme.palette.success.main : theme.palette.warning.main + })} + data-testid={testId} + data-found={count > 0} + > + Found: {count} + </Typography> + ) +) + +export default ScanFoundCount diff --git a/src/renderer/src/components/client/scan/ScanGridToggle.tsx b/src/renderer/src/components/client/scan/ScanGridToggle.tsx new file mode 100644 index 00000000..06c8b138 --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanGridToggle.tsx @@ -0,0 +1,36 @@ +import IconButton from '@mui/material/IconButton' +import Tooltip from '@mui/material/Tooltip' +import Visibility from '@mui/icons-material/Visibility' +import VisibilityOff from '@mui/icons-material/VisibilityOff' +import { meme } from '@renderer/components/shared/inputs/meme' + +interface ScanGridToggleProps { + shown: boolean + toggle: () => void + testId: string +} + +/** + * Whether the grid keeps filling while the scan runs. + * + * Watching the rows arrive is the point, and it costs almost nothing now that + * they are written in batches. It is still a choice: a slow machine, or simply + * not wanting the movement. + */ +const ScanGridToggle = meme( + ({ shown, toggle, testId }: ScanGridToggleProps): JSX.Element => ( + <Tooltip title={shown ? 'Hide the grid while scanning' : 'Show the grid while scanning'}> + <IconButton + size="small" + color="primary" + onClick={toggle} + aria-label={shown ? 'Hide the grid while scanning' : 'Show the grid while scanning'} + data-testid={testId} + > + {shown ? <Visibility /> : <VisibilityOff />} + </IconButton> + </Tooltip> + ) +) + +export default ScanGridToggle diff --git a/src/renderer/src/components/client/scan/ScanProgress.tsx b/src/renderer/src/components/client/scan/ScanProgress.tsx new file mode 100644 index 00000000..d1e57439 --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanProgress.tsx @@ -0,0 +1,25 @@ +import LinearProgress from '@mui/material/LinearProgress' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useDataZustand } from '@renderer/context/data.zustand' + +/** The bar both scan dialogs put under their controls. */ +const ScanProgress = meme((): JSX.Element | null => { + const scanning = useDataZustand( + (z) => z.clientState.scanningUnitIds || z.clientState.scanningRegisters + ) + const scanProgress = useDataZustand((z) => z.scanProgress) + + return scanning ? ( + <LinearProgress + variant="determinate" + value={scanProgress} + color="primary" + sx={{ + width: '100%', + '& .MuiLinearProgress-bar1Determinate': { transition: 'none', animation: 'none' } + }} + /> + ) : null +}) + +export default ScanProgress diff --git a/src/renderer/src/components/client/scan/ScanStartStopButton.tsx b/src/renderer/src/components/client/scan/ScanStartStopButton.tsx new file mode 100644 index 00000000..ef6c8c8f --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanStartStopButton.tsx @@ -0,0 +1,32 @@ +import Button from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' + +interface ScanStartStopButtonProps { + // Optional because only the unit id scan has a condition for it: it refuses + // to start with no register type selected. + disabled?: boolean + scanning: boolean + scan: () => void + testId: string +} + +/** + * One button starts the scan and stops it, so what it says and what colour it + * says it in are the scan's state rather than the dialog's. Both dialogs read + * that the same way; what differs is the request behind the click. + */ +const ScanStartStopButton = meme( + ({ disabled, scanning, scan, testId }: ScanStartStopButtonProps): JSX.Element => ( + <Button + disabled={disabled} + variant="contained" + color={scanning ? 'warning' : 'primary'} + onClick={scan} + data-testid={testId} + > + {scanning ? 'Stop Scanning' : 'Start Scanning'} + </Button> + ) +) + +export default ScanStartStopButton diff --git a/src/renderer/src/components/client/scan/ScanTimeoutField.tsx b/src/renderer/src/components/client/scan/ScanTimeoutField.tsx new file mode 100644 index 00000000..8a42a6f4 --- /dev/null +++ b/src/renderer/src/components/client/scan/ScanTimeoutField.tsx @@ -0,0 +1,79 @@ +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import { integerMask } from '@renderer/components/shared/inputs/integerMask' +import { meme } from '@renderer/components/shared/inputs/meme' +import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' +import { MaskSetFn } from '@renderer/context/client.zustand.types' +import { ElementType, forwardRef } from 'react' +import { IMaskInput } from 'react-imask' + +/** + * The timeout a scan gives each request, in milliseconds. Zero switches the + * timer off in modbus-serial and the scan then hangs on the first silent unit; + * ten seconds an address is already a long afternoon. + * + * The bounds stay off the mask, which rewrites what you type: clearing the + * field stores 0, the mask commits that up to its floor, and the digits land + * behind it. Typing 500 into an empty field gave 10000. Leaving the field is + * late enough to correct it. + */ +const SCAN_TIMEOUT_MIN = 100 +const SCAN_TIMEOUT_MAX = 10000 + +export const clampScanTimeout = (timeout: number): number => + Math.min(SCAN_TIMEOUT_MAX, Math.max(SCAN_TIMEOUT_MIN, timeout)) + +const TimeoutInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { + const { set, ...other } = props + return ( + <IMaskInput + {...other} + {...integerMask} + min={0} + inputRef={ref} + onAccept={(value) => set(value, true)} + /> + ) +}) + +TimeoutInputForward.displayName = 'TimeoutInput' +const TimeoutInput = meme(TimeoutInputForward) + +interface ScanTimeoutFieldProps { + disabled: boolean + timeout: number + setTimeout: MaskSetFn + testId: string +} + +/** + * Both scan dialogs ask for the same timeout, and ask for it the same way. + * + * Each dialog wraps this in a component of its own, which reads its own store + * and passes its own test id. The wrapper stays because the store has to be + * named where it is read: `conformance.test.ts` matches a selector by its + * callee, `/^use[A-Z].*Zustand$/`, so a store hook arriving as a prop is a + * subscription the meter does not see. + */ +const ScanTimeoutField = meme( + ({ disabled, timeout, setTimeout, testId }: ScanTimeoutFieldProps): JSX.Element => ( + <TextField + disabled={disabled} + label="Timeout (ms)" + variant="outlined" + size="small" + sx={{ width: 90 }} + value={String(timeout)} + onBlur={() => setTimeout(String(clampScanTimeout(timeout)))} + data-testid={testId} + slotProps={{ + input: { + inputComponent: TimeoutInput as unknown as ElementType<InputBaseComponentProps, 'input'>, + inputProps: maskInputProps({ set: setTimeout }) + } + }} + /> + ) +) + +export default ScanTimeoutField diff --git a/src/renderer/src/components/client/ScanProgress/__tests__/TimeoutInput.test.tsx b/src/renderer/src/components/client/scan/__tests__/ScanTimeoutField.test.tsx similarity index 82% rename from src/renderer/src/components/client/ScanProgress/__tests__/TimeoutInput.test.tsx rename to src/renderer/src/components/client/scan/__tests__/ScanTimeoutField.test.tsx index c2ff3846..c2b7932a 100644 --- a/src/renderer/src/components/client/ScanProgress/__tests__/TimeoutInput.test.tsx +++ b/src/renderer/src/components/client/scan/__tests__/ScanTimeoutField.test.tsx @@ -3,18 +3,12 @@ import { render, screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { useState } from 'react' -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' -// ScanProgress reads the root store, which opens IPC listeners on import. -vi.mock('@renderer/context/client.zustand', () => ({ - useClientZustand: (selector: (state: Record<string, unknown>) => unknown): unknown => - selector({ clientState: {}, scanProgress: 0 }) -})) - -import { ScanTimeoutField, clampScanTimeout } from '../ScanProgress' +import ScanTimeoutField, { clampScanTimeout } from '../ScanTimeoutField' // Both dialogs keep the timeout as a number, so the harness does too: that -// round trip is where the field used to rewrite what you typed. +// round trip is where the field would rewrite what you typed. const Harness = (): JSX.Element => { const [timeout, setTimeout] = useState(500) diff --git a/src/renderer/src/components/server/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear.tsx index 35fd816a..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) } @@ -142,14 +150,13 @@ type UseSaveHook = () => { const useSave: UseSaveHook = () => { const save = useCallback(() => { - const serverZustand = useServerZustand.getState() - const { serverRegisters, selectedUuid, littleEndian } = serverZustand - const name = serverZustand.name[selectedUuid] ?? '' + const { servers, selectedUuid } = useServerZustand.getState() + const server = servers[selectedUuid] + if (!server) return + const name = server.name ?? '' const serverRegistersPerUnit: ServerRegistersPerUnit = {} - - const registersPerUnit = serverRegisters[selectedUuid] - if (!registersPerUnit) return + const registersPerUnit = server.registers Object.entries(registersPerUnit).forEach(([unitId, registers]) => { if (!checkHasConfig(registers)) return @@ -163,7 +170,7 @@ const useSave: UseSaveHook = () => { version: CURRENT_SERVER_CONFIG_VERSION, modbuxVersion, name, - littleEndian: littleEndian[selectedUuid] ?? false, + littleEndian: server.littleEndian, serverRegistersPerUnit } downloadJson(`modbux_server_${snakeCase(name)}.json`, JSON.stringify(config, null, 2)) @@ -181,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/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index d40b61b7..d93558da 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -57,7 +57,7 @@ const Explanation = meme((): JSX.Element => { // Where the server actually landed. When 502 is blocked the backend has // already walked up to the first bindable port, so this is the fallback the // user is looking at — not the port they asked for. - const actualPort = useServerZustand((z) => Number(z.port[z.selectedUuid] ?? 0)) + const actualPort = useServerZustand((z) => Number(z.servers[z.selectedUuid]?.port ?? 0)) return ( <Typography variant="body2" sx={{ mb: 2 }}> diff --git a/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx b/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx index 3206ae23..b6153a2a 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx @@ -12,7 +12,7 @@ import type { PrivilegedPortStatus, PrivilegedPortFixResult } from '@shared' const mockSetPort = vi.fn() const storeState = { selectedUuid: 'main', - port: { main: '1024' } as Record<string, string>, + servers: { main: { port: '1024' } } as Record<string, { port: string }>, ready: { main: true } as Record<string, boolean>, setPort: mockSetPort } @@ -65,7 +65,7 @@ describe('PrivilegedPortModal', () => { vi.clearAllMocks() localStorage.clear() window.api.isServerWindow = false - storeState.port = { main: '1024' } + storeState.servers = { main: { port: '1024' } } storeState.ready = { main: true } // The modal's own store is module scope, so it outlives a render the way it // outlives a remount in the app. Each test starts from a closed modal. diff --git a/src/renderer/src/components/server/SelectServer.tsx b/src/renderer/src/components/server/SelectServer.tsx index 1024ba9e..8e449e80 100644 --- a/src/renderer/src/components/server/SelectServer.tsx +++ b/src/renderer/src/components/server/SelectServer.tsx @@ -3,7 +3,7 @@ import Delete from '@mui/icons-material/Delete' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' import { findAvailablePort, MAIN_SERVER_UUID } from '@shared' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useSnackbar } from 'notistack' import { v4 } from 'uuid' import ButtonGroup from '@mui/material/ButtonGroup' @@ -13,7 +13,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import ToggleButton from '@mui/material/ToggleButton' const SelectServerToggle = meme(({ uuid }: { uuid: string }) => { - const port = useServerZustand((z) => z.port[uuid]) + const port = useServerZustand((z) => z.servers[uuid]?.port) return ( <ToggleButton data-testid={`select-server-${port}`} value={uuid} sx={{ px: 1.5 }}> {port} @@ -23,14 +23,29 @@ const SelectServerToggle = meme(({ uuid }: { uuid: string }) => { const SelectServer = meme(() => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') - const serverUuids = useServerZustand((z) => z.uuids) + // A string rather than the array, because zustand compares a selector's + // answer with `Object.is` and `Object.keys` allocates a new one every time + // it runs. React reads the selector again on the render that follows, finds + // another new array and renders again: measured as "Maximum update depth + // exceeded" with the array here. A uuid carries no space, so this is the + // same value until a server is added or removed. + const serverUuidKey = useServerZustand((z) => Object.keys(z.servers).join(' ')) + const serverUuids = useMemo( + // An empty record is a blob that carries one, which `init` answers by + // making the main server. `''.split(' ')` is `['']`, a toggle for a uuid + // nothing holds. + () => (serverUuidKey === '' ? [] : serverUuidKey.split(' ')), + [serverUuidKey] + ) const selectedUuid = useServerZustand((z) => z.selectedUuid) - const addDisabled = useServerZustand((z) => z.uuids.length >= 10) + const addDisabled = useServerZustand((z) => Object.keys(z.servers).length >= 10) const { enqueueSnackbar } = useSnackbar() const addServer = useCallback(() => { const serverZustand = useServerZustand.getState() - const newPort = findAvailablePort(Object.values(serverZustand.port).map((v) => Number(v))) + const newPort = findAvailablePort( + Object.values(serverZustand.servers).map((server) => Number(server.port)) + ) // `findAvailablePort` walks 502 to 10502 and the button is off at ten // servers, so this answers for a range that cannot run out today. A throw diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index ee622610..61b0ae64 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -51,7 +51,7 @@ const ModeToggle = meme(() => { const EndianToggle = meme(() => { const selectedUuid = useServerZustand((z) => z.selectedUuid) - const littleEndian = useServerZustand((z) => z.littleEndian[selectedUuid] ?? false) + const littleEndian = useServerZustand((z) => z.servers[selectedUuid]?.littleEndian ?? false) const ready = useServerZustand((z) => z.ready[selectedUuid]) const handleChange = useCallback((_event: unknown, value: boolean | null): void => { @@ -97,7 +97,7 @@ interface UnitIdMenuItemProps { const UnitIdMenuItem = meme(({ unitId }: UnitIdMenuItemProps) => { const hasConfig = useServerZustand((z) => { - const reg = z.serverRegisters[z.selectedUuid]?.[unitId] + const reg = z.servers[z.selectedUuid]?.registers[unitId] return checkHasConfig(reg) }) return ( @@ -154,9 +154,9 @@ const UnitId = meme(() => { ) }) -const PortInput = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { +const PortInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { const { set, ...other } = props - const portFromStore = useServerZustand((z) => z.port[z.selectedUuid] ?? '') + const portFromStore = useServerZustand((z) => z.servers[z.selectedUuid]?.port ?? '') const [localPort, setLocalPort] = useState(portFromStore) // Sync localPort with store if store changes (e.g. after backend update) @@ -187,13 +187,15 @@ const PortInput = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { ) }) -PortInput.displayName = 'PortInput' +PortInputForward.displayName = 'PortInput' + +const PortInput = meme(PortInputForward) // // // Port const Port = meme(() => { - const port = useServerZustand((z) => z.port[z.selectedUuid]) + const port = useServerZustand((z) => z.servers[z.selectedUuid]?.port) const setPort = useServerZustand.getState().setPort diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx index 8dec2b86..234651d9 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans.tsx @@ -2,19 +2,17 @@ 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' +import { useSnackbar } from 'notistack' interface ServerBooleanProps { name: string @@ -28,34 +26,28 @@ interface ServerBoolRowProps { type: BooleanRegisters } -/** - * One coil or discrete input, with its comment and its remove button. - * - * It read `collapse` and passed it on as `readOnly`, and `ServerBooleans` - * renders this list only under `{!collapse && ...}`. Both read the same store - * and React renders the parent first, so no committed render carried - * `readOnly: true`, and the hover styles and the remove button were guarded on - * the same unreachable half. Two tests in `ServerBit.test.tsx` asserted - * behaviour nothing in the app reached. - */ +/** One coil or discrete input, with its comment and its remove button. */ const ServerBoolRow = meme(({ address, type }: ServerBoolRowProps) => { const entry = useServerZustand((z) => { const uuid = z.selectedUuid const unitId = z.getUnitId(uuid) - return z.serverRegisters[uuid]?.[unitId]?.[type]?.[address] as ServerBoolEntry | undefined + return z.servers[uuid]?.registers[unitId]?.[type]?.[address] as ServerBoolEntry | undefined }) - const handleToggle = useCallback(() => { - useServerZustand - .getState() - .setBool({ registerType: type, address, boolState: !(entry?.value ?? false) }) - }, [type, address, entry?.value]) + // `ServerBit` hands back the index it was given, and the index this row gives + // it is the coil's address. + const handleToggle = useCallback( + (bitAddress: number) => { + useServerZustand.getState().toggleBool(type, bitAddress) + }, + [type] + ) const handleCommentChange = useCallback( - (comment: string | undefined) => { - useServerZustand.getState().setBoolComment(type, address, comment) + (bitAddress: number, comment: string | undefined) => { + useServerZustand.getState().setBoolComment(type, bitAddress, comment) }, - [type, address] + [type] ) const handleRemove = useCallback(() => { @@ -112,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 the ref this used to cache the sorted keys in was written - // outside React's render phase. `ServerRegisterRows` next door selects the - // map and derives from it in a `useMemo`, which runs when React says so. - // Mutative gives the map a new identity on every value written into it, so - // this list re-renders per toggle where the ref stopped that. 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.serverRegisters[uuid]?.[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) => ( @@ -140,46 +132,65 @@ const ServerBoolList = meme(({ type }: Omit<ServerBooleanProps, 'name'>) => { // ─── Inline Add bar ────────────────────────────────────────────────────────── -const getRegs = (type: BooleanRegisters): Record<string, unknown> => { +const getBoolMap = (type: BooleanRegisters): Record<string, unknown> => { const serverZustand = useServerZustand.getState() return ( - serverZustand.serverRegisters[serverZustand.selectedUuid]?.[ + serverZustand.servers[serverZustand.selectedUuid]?.registers[ serverZustand.getUnitId(serverZustand.selectedUuid) ]?.[type] ?? {} ) } -const nextFree = (from: number, regs: Record<string, unknown>): number => { - let a = from - while (a <= 65535 && a in regs) a++ - return a +const nextFree = (from: number, boolMap: Record<string, unknown>): number => { + let address = from + while (address <= 65535 && address in boolMap) address++ + return address } const AddBoolInline = meme(({ type }: Omit<ServerBooleanProps, 'name'>) => { - const [address, setAddress] = useState(() => String(nextFree(0, getRegs(type)))) + const [address, setAddress] = useState(() => String(nextFree(0, getBoolMap(type)))) + const { enqueueSnackbar } = useSnackbar() // Reset to 0 when all bools are cleared const empty = useServerZustand((z) => { const uuid = z.selectedUuid const unitId = z.getUnitId(uuid) - return Object.keys(z.serverRegisters[uuid]?.[unitId]?.[type] ?? {}).length === 0 + return Object.keys(z.servers[uuid]?.registers[unitId]?.[type] ?? {}).length === 0 }) useEffect(() => { if (empty) setAddress('0') }, [empty]) + // The field is seeded from what one unit holds, and the reset above was the + // only write that read the store again, so switching to a unit holding fewer + // addresses left the field on the one the last unit ended at. A server is the + // other half of which map that is, and two servers can be on the same unit + // id, so both are in the list. + const uuid = useServerZustand((z) => z.selectedUuid) + const unitId = useServerZustand((z) => z.getUnitId(z.selectedUuid)) + useEffect(() => { + setAddress(String(nextFree(0, getBoolMap(type)))) + }, [type, uuid, unitId]) + const handleAdd = useCallback(() => { 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) - if (nextAddress > 65535) return + const boolMap = getBoolMap(type) + const typedAddress = nextAddress + if (nextAddress in boolMap) nextAddress = nextFree(nextAddress, boolMap) + if (nextAddress > 65535) { + enqueueSnackbar({ + message: `Every address from ${typedAddress} up is taken`, + variant: 'error' + }) + 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]) + }, [type, address, enqueueSnackbar]) return ( <Box @@ -204,7 +215,7 @@ const AddBoolInline = meme(({ type }: Omit<ServerBooleanProps, 'name'>) => { input: { inputComponent: UIntInput as unknown as ElementType<InputBaseComponentProps, 'input'>, inputProps: maskInputProps({ - set: (v) => setAddress(String(v)) + set: (value) => setAddress(String(value)) }) } }} @@ -224,64 +235,17 @@ const AddBoolInline = meme(({ type }: Omit<ServerBooleanProps, 'name'>) => { // ─── 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 ( - <Box - sx={{ - flex: 0, - minWidth: collapse ? 160 : 280, - minHeight: collapse ? undefined : allOtherCollapsed ? '80%' : { xs: '30%', md: '48%' } - }} + <ServerPanel + name={name} + type={type} + openFlex={0} + openMinWidth={280} + contentSx={{ gap: 0, p: 0.5 }} > - <Paper - variant="outlined" - sx={{ - flex: 1, - width: '100%', - height: '100%', - backgroundColor: gridSurface, - fontSize: '0.95em', - position: 'relative' - }} - > - <ServerPartTitle name={name} registerType={type} /> - {/* - 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. - */} - <Box - sx={{ - position: 'absolute', - top: 38, - left: 0, - right: 0, - bottom: 0, - overflow: 'auto', - display: 'flex', - gap: 0, - flexDirection: 'column', - p: 0.5 - }} - > - {!collapse && ( - <> - <ServerBoolList type={type} /> - <AddBoolInline type={type} /> - </> - )} - </Box> - </Paper> - </Box> + <ServerBoolList type={type} /> + <AddBoolInline type={type} /> + </ServerPanel> ) }) 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<Theme> + 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 ( + <Box + sx={{ + flex: collapse ? 0 : openFlex, + minWidth: collapse ? 160 : openMinWidth, + minHeight: collapse ? undefined : allOtherCollapsed ? '80%' : { xs: '30%', md: '48%' } + }} + > + <Paper + variant="outlined" + sx={{ + flex: 1, + width: '100%', + height: '100%', + backgroundColor: gridSurface, + fontSize: '0.95em', + position: 'relative' + }} + > + <ServerPartTitle name={name} registerType={type} /> + <Box + sx={[ + { + position: 'absolute', + top: 38, + left: 0, + right: 0, + bottom: 0, + overflow: 'auto', + display: 'flex', + flexDirection: 'column' + }, + ...(Array.isArray(contentSx) ? contentSx : [contentSx]) + ]} + > + {!collapse && children} + </Box> + </Paper> + </Box> + ) + } +) + +export default ServerPanel diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx index 4e4c6ebe..12604f1d 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle.tsx @@ -70,7 +70,7 @@ const ServerPartTitleName = meme( const uuid = z.selectedUuid const unitId = z.getUnitId(uuid) - return Object.keys(z.serverRegisters[uuid]?.[unitId]?.[registerType] ?? {}).length + return Object.keys(z.servers[uuid]?.registers[unitId]?.[registerType] ?? {}).length }) const handleClick = useCallback((): void => { 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.test.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/AddRegister.test.tsx index 299f7080..78ab2c4e 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/AddRegister.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/AddRegister.test.tsx @@ -13,9 +13,13 @@ const mockRemoveRegister = vi.fn() const serverState = { selectedUuid: 'main', getUnitId: (): string => '0', - usedAddresses: { main: { '0': { holding_registers: [100, 101] } } }, - serverRegisters: {}, - littleEndian: {}, + servers: { + main: { + usedAddresses: { '0': { holding_registers: [100, 101] } }, + registers: {}, + littleEndian: false + } + }, removeRegister: mockRemoveRegister, // The real action answers whether main took the register, and everything the // buttons do after a submit is behind that answer. 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..81eef85a 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,14 @@ import { describe, it, expect } from 'vitest' +import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' import { + addressValidation, + FIELD_DEFAULTS, isAddressInUse, isFormDirty, toFormSnapshot, toRegisterParams, + utf8MaxBytes, + utf8RegisterLength, type RegisterFormSnapshot, type RegisterFormValues } from '../addRegister.zustand.helpers' @@ -253,3 +258,102 @@ 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)) + }) +}) + +// ─── addressValidation ────────────────────────────────────────────── + +describe('addressValidation', () => { + const validate = ( + overrides: Partial<Parameters<typeof addressValidation>[0]> = {} + ): ReturnType<typeof addressValidation> => + addressValidation({ + address: '10', + dataType: 'uint16', + registerType: 'holding_registers', + registerLength: '', + usedAddresses: [], + editRegister: undefined, + ...overrides + }) + + it('marks an empty registerLength on a utf8 register invalid', () => { + const result = validate({ dataType: 'utf8', registerLength: '' }) + expect(result.registerLengthValid).toBe(false) + }) + + it('leaves the address valid when the registerLength is the invalid one', () => { + const result = validate({ dataType: 'utf8', registerLength: '' }) + expect(result.registerLengthValid).toBe(false) + expect(result.addressValid).toBe(true) + }) + + it('leaves the registerLength valid when the address is the invalid one', () => { + const result = validate({ address: '', dataType: 'uint16' }) + expect(result.addressValid).toBe(false) + expect(result.registerLengthValid).toBe(true) + }) + + it('reddens the address for one already in use', () => { + const result = validate({ address: '10', usedAddresses: [10] }) + expect(result.addressInUse).toBe(true) + expect(result.addressValid).toBe(false) + }) + + it('leaves the register being edited out of the addresses in use', () => { + const result = validate({ + address: '10', + usedAddresses: [10], + editRegister: { dataType: 'uint16', address: 10 } + }) + expect(result.addressInUse).toBe(false) + expect(result.addressValid).toBe(true) + }) + + it('reddens an address whose register runs past 65535', () => { + const result = validate({ address: '65535', dataType: 'uint32' }) + expect(result.addressFitError).toBe(true) + expect(result.addressValid).toBe(false) + }) + + it('answers on the field alone while no register type is chosen', () => { + const result = validate({ registerType: undefined, address: '65535', usedAddresses: [65535] }) + expect(result.addressInUse).toBe(false) + expect(result.addressFitError).toBe(false) + expect(result.addressValid).toBe(true) + }) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.test.ts index 5d5a5187..e0118b42 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.test.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.test.ts @@ -9,7 +9,7 @@ const mockRemoveRegister = vi.fn() const serverState = { selectedUuid: 'main', getUnitId: (): string => '0', - usedAddresses: {} as Record<string, unknown>, + servers: {} as Record<string, { usedAddresses: Record<string, unknown> }>, removeRegister: mockRemoveRegister } @@ -115,12 +115,12 @@ describe('what the dialog opened with', () => { describe('initNextUnusedAddress', () => { beforeEach(() => { - serverState.usedAddresses = {} + serverState.servers = {} useAddRegisterZustand.getState().resetToDefaults() }) it('moves to the first free address above the one just used', () => { - serverState.usedAddresses = { main: { '0': { holding_registers: [10] } } } + serverState.servers = { main: { usedAddresses: { '0': { holding_registers: [10] } } } } const addRegisterZustand = useAddRegisterZustand.getState() addRegisterZustand.setRegisterType('holding_registers') addRegisterZustand.setAddress('10', true) @@ -139,7 +139,7 @@ describe('initNextUnusedAddress', () => { addRegisterZustand.setAddress('65535', true) // What Add & Next does before it asks for the next address: the register // is written, so the address the field still shows is now taken. - serverState.usedAddresses = { main: { '0': { holding_registers: [65535] } } } + serverState.servers = { main: { usedAddresses: { '0': { holding_registers: [65535] } } } } addRegisterZustand.initNextUnusedAddress(65536) 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..10ceff88 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,8 @@ import { BaseDataType, DataType, + DEFAULT_UTF8_LENGTH, + getAddressFitError, getMinMaxValues, NumberRegisters, RegisterParams, @@ -62,6 +64,66 @@ export const isAddressInUse = ( return addressesNeeded.some((a) => usedAddresses.includes(Number(a))) } +interface AddressValidationResult { + addressInUse: boolean + addressFitError: boolean + /** Whether the address field itself should be marked valid */ + addressValid: boolean + /** Whether the registerLength field should be marked valid (only relevant for utf8) */ + registerLengthValid: boolean +} + +/** The span of the register being edited, which its own address may overlap. */ +type EditRegisterSpan = { dataType: DataType; address: number; length?: number } + +/** + * Derives the full address + registerLength validity from raw field strings. + * Each field is only responsible for its own errors: a bad registerLength + * never makes the address red and vice versa. + * + * The two store reads stay at the call site, so what decides the answer is + * every argument here and nothing else. + */ +export const addressValidation = ({ + address, + dataType, + registerType, + registerLength, + usedAddresses, + editRegister +}: { + address: string + dataType: DataType + registerType: NumberRegisters | undefined + registerLength: string + usedAddresses: number[] + editRegister: EditRegisterSpan | undefined +}): AddressValidationResult => { + const registerLengthValid = dataType !== 'utf8' || registerLength.length > 0 + + if (!registerType) { + return { + addressInUse: false, + addressFitError: false, + addressValid: address.length > 0, + registerLengthValid + } + } + + const addressNumber = Number(address) + const length = dataType === 'utf8' ? utf8RegisterLength(registerLength) : undefined + + const addressInUse = isAddressInUse(usedAddresses, dataType, addressNumber, length, editRegister) + const addressFitError = getAddressFitError(dataType, addressNumber, length) + + return { + addressInUse, + addressFitError, + addressValid: address.length > 0 && !addressInUse && !addressFitError, + registerLengthValid + } +} + /** What the add-register dialog holds, before any of it means anything. */ export interface RegisterFormValues { fixed: boolean @@ -82,15 +144,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 +239,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 b731409f..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,113 +1,48 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { MaskSetFn } from '@renderer/context/client.zustand.types' import { useServerZustand } from '@renderer/context/server.zustand' -import { - BaseDataType, - DataType, - DEFAULT_UTF8_LENGTH, - getAddressFitError, - NumberRegisters, - registerWidth, - ServerRegister, - UnitIdString -} from '@shared' +import { asOneServerUnitStep } from '@renderer/context/serverUndo' +import { BaseDataType, DataType, NumberRegisters, registerWidth, ServerRegister } from '@shared' import { create } from 'zustand' import { mutative } from 'zustand-mutative' import { + addressValidation, FIELD_DEFAULTS, - isAddressInUse, RegisterFormSnapshot, toFormSnapshot, - toRegisterParams + toRegisterParams, + utf8MaxBytes, + utf8RegisterLength } from './addRegister.zustand.helpers' -// ─── Helpers ──────────────────────────────────────────────────────────────── - -type GetAddressInUseFn = ( - uuid: string, - unitId: UnitIdString, - registerType: NumberRegisters, - dataType: DataType, - address: number, - length?: number -) => boolean - -export const getAddressInUse: GetAddressInUseFn = ( - uuid, - unitId, - registerType, - dataType, - address, - length -) => { - const editRegister = useAddRegisterZustand.getState().serverRegisterEdit - const usedAddresses = registerType - ? (useServerZustand.getState().usedAddresses[uuid]?.[unitId]?.[registerType] ?? []) - : [] - - return isAddressInUse( - usedAddresses, - dataType, - address, - length, - editRegister - ? { - dataType: editRegister.params.dataType, - address: editRegister.params.address, - length: editRegister.params.length - } - : undefined - ) -} - // ─── Address validation ────────────────────────────────────────────────────── -interface AddressValidationResult { - addressInUse: boolean - addressFitError: boolean - /** Whether the address field itself should be marked valid */ - addressValid: boolean - /** Whether the registerLength field should be marked valid (only relevant for utf8) */ - registerLengthValid: boolean -} - /** - * Derives the full address + registerLength validity from raw field strings. - * Each field is only responsible for its own errors — a bad registerLength - * never makes the address red and vice versa. + * What the dialog's address rule needs of the two stores, read where it runs. + * + * `addressValidation` takes the used addresses and the register being edited + * as arguments, so this is the one place either is looked up. */ const validateAddress = ( address: string, dataType: DataType, registerType: NumberRegisters | undefined, registerLength: string -): AddressValidationResult => { - const registerLengthValid = dataType !== 'utf8' || registerLength.length > 0 - - if (!registerType) { - return { - addressInUse: false, - addressFitError: false, - addressValid: address.length > 0, - registerLengthValid - } - } - +): ReturnType<typeof addressValidation> => { const serverZustand = useServerZustand.getState() const uuid = serverZustand.selectedUuid const unitId = serverZustand.getUnitId(uuid) - const addressNum = Number(address) - const length = dataType === 'utf8' ? Number(registerLength) || DEFAULT_UTF8_LENGTH : undefined - const addressInUse = getAddressInUse(uuid, unitId, registerType, dataType, addressNum, length) - const addressFitError = getAddressFitError(dataType, addressNum, length) - - return { - addressInUse, - addressFitError, - addressValid: address.length > 0 && !addressInUse && !addressFitError, - registerLengthValid - } + return addressValidation({ + address, + dataType, + registerType, + registerLength, + usedAddresses: registerType + ? (serverZustand.servers[uuid]?.usedAddresses[unitId]?.[registerType] ?? []) + : [], + editRegister: useAddRegisterZustand.getState().serverRegisterEdit?.params + }) } // ─── Store types ───────────────────────────────────────────────────────────── @@ -284,7 +219,7 @@ export const useAddRegisterZustand = create<AddRegisterZustand, [['zustand/mutat stringValue: '', setStringValue: (value) => { 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 +227,7 @@ export const useAddRegisterZustand = create<AddRegisterZustand, [['zustand/mutat }) }, - registerLength: '10', + registerLength: FIELD_DEFAULTS.registerLength, setRegisterLength: (registerLength, valid) => set((state) => { state.registerLength = registerLength @@ -319,10 +254,11 @@ export const useAddRegisterZustand = create<AddRegisterZustand, [['zustand/mutat const serverZustand = useServerZustand.getState() const uuid = serverZustand.selectedUuid const unitId = serverZustand.getUnitId(uuid) - const usedAddresses = serverZustand.usedAddresses[uuid]?.[unitId]?.[registerType] ?? [] + const usedAddresses = + serverZustand.servers[uuid]?.usedAddresses[unitId]?.[registerType] ?? [] const size = registerWidth( dataType, - dataType === 'utf8' ? Number(registerLength) || 10 : undefined + dataType === 'utf8' ? utf8RegisterLength(registerLength) : undefined ) let found = false @@ -387,25 +323,28 @@ export const useAddRegisterZustand = create<AddRegisterZustand, [['zustand/mutat ? serverRegisterEdit.params : undefined - if (moved) { - serverZustand.removeRegister({ - uuid, - unitId, - address: moved.address, - registerType, - dataType: moved.dataType, - length: moved.length - }) - } - - if (!(await serverZustand.addRegister({ uuid, unitId, params }))) { + let added = false + // A move is a remove and an add, and one step to undo. + await asOneServerUnitStep(uuid, unitId, async () => { + 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 } }, @@ -447,7 +386,7 @@ export const useAddRegisterZustand = create<AddRegisterZustand, [['zustand/mutat state.comment = '' state.fixed = true state.stringValue = '' - state.registerLength = '10' + state.registerLength = FIELD_DEFAULTS.registerLength state.serverRegisterEdit = undefined state.pristine = undefined state.addressInUse = false 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<HTMLInputElement, MaskInputProps>((props, r return ( <IMaskInput {...other} - mask={IMask.MaskedNumber} + {...decimalMask(integer)} min={min} max={max} autofix - {...{ - scale: integer ? 0 : 7, - thousandsSeparator: '', - radix: '.', // fractional delimiter - mapToRadix: ['.', ','] // symbols to process as radix - }} inputRef={ref} onAccept={(value) => { set(value, notEmpty(value)) @@ -78,16 +73,10 @@ const MinInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref return ( <IMaskInput {...other} - mask={IMask.MaskedNumber} + {...decimalMask(integer)} min={min} max={max} autofix - {...{ - scale: integer ? 0 : 7, - thousandsSeparator: '', - radix: '.', // fractional delimiter - mapToRadix: ['.', ','] // symbols to process as radix - }} inputRef={ref} onAccept={(value) => set(value, notEmpty(value))} /> @@ -107,16 +96,10 @@ const MaxInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref return ( <IMaskInput {...other} - mask={IMask.MaskedNumber} + {...decimalMask(integer)} min={min} max={max} autofix - {...{ - scale: integer ? 0 : 7, - thousandsSeparator: '', - radix: '.', // fractional delimiter - mapToRadix: ['.', ','] // symbols to process as radix - }} inputRef={ref} onAccept={(value) => set(value, notEmpty(value))} /> diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx index 35297fea..d0cb0ca3 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -10,7 +10,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { useAddRegisterZustand } from './addRegister.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' -import { inTimestampWindow, timestampWindow } from './addRegister.zustand.helpers' +import { inTimestampWindow, timestampWindow, utf8MaxBytes } from './addRegister.zustand.helpers' import { ChangeEvent, ElementType, useCallback, useEffect, useMemo } from 'react' import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' @@ -252,7 +252,7 @@ const DateTimeField = meme(() => { 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<HTMLInputElement>): void => { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx index 22bca36d..b458baf8 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx @@ -11,6 +11,16 @@ interface ServerBitMapDetailProps { const BIT_INDICES = Array.from({ length: 16 }, (_, i) => i) +/** + * Sixteen bits of a server register, each with its comment. + * + * Both handlers take the bit they are about, so `ServerBit` gets one + * `useCallback` rather than an arrow per row. That saves no render yet: both + * close over `register` and `params`, and the parent hands down a new entry + * whenever the word moves, so all sixteen rows redraw either way. Measured + * before and after the arrows came out, 16 both times. TODO.md carries what + * the rest of it needs. + */ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Element => { const { params } = register const bitConfig = params.bitMap as BitMapConfig | undefined @@ -107,8 +117,8 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele bitIndex={bitIndex} active={getBit(Number(register.value), bitIndex)} comment={bitConfig?.[String(bitIndex)]?.comment} - onToggle={() => void handleToggle(bitIndex)} - onCommentChange={(c) => handleCommentChange(bitIndex, c)} + onToggle={handleToggle} + onCommentChange={handleCommentChange} /> ))} </Box> diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 77dc693e..d2be878d 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 { @@ -76,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> @@ -139,17 +147,41 @@ 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.serverRegisters[uuid]?.[unitId]?.[type] + return Object.entries(z.servers[uuid]?.registers[unitId]?.[type] ?? {}) + .map(([key, entry]) => `${key}:${entry.params.address},`) + .join('') }) - const registers = useMemo(() => Object.values(registerMap ?? {}), [registerMap]) + // 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 registerKeys = useMemo( + () => + 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((register) => ( + return registerKeys.map((registerKey) => ( <ServerRegisterRow - key={`server_register_${register.params.registerType.replace(/ /gm, '_')}_${register.params.address}`} - register={register} + key={`server_register_${type}_${registerKey}`} + type={type} + registerKey={registerKey} /> )) }) @@ -160,52 +192,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 ( - <Box - sx={{ - flex: collapse ? 0 : 1, - minWidth: collapse ? 160 : 560, - minHeight: collapse ? undefined : allOtherCollapsed ? '80%' : { xs: '30%', md: '48%' } - }} + <ServerPanel + name={name} + type={type} + openFlex={1} + openMinWidth={560} + contentSx={{ flex: 1, fontFamily: 'monospace', fontSize: '0.9em' }} > - <Paper - variant="outlined" - sx={{ - flex: 1, - width: '100%', - height: '100%', - backgroundColor: gridSurface, - fontSize: '0.95em', - position: 'relative' - }} - > - <ServerPartTitle name={name} registerType={type} /> - <Box - sx={{ - position: 'absolute', - top: 38, - left: 0, - right: 0, - bottom: 0, - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'auto', - fontFamily: 'monospace', - fontSize: '0.9em' - }} - > - {!collapse && <ServerRegisterRows type={type} />} - </Box> - </Paper> - </Box> + <ServerRegisterRows type={type} /> + </ServerPanel> ) }) 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..8202b2f6 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 @@ -12,8 +12,7 @@ const registers: { holding_registers: ServerRegister } = { holding_registers: {} const serverState = { selectedUuid: 'main', getUnitId: (): string => '0', - unitId: { main: '0' }, - serverRegisters: { main: { '0': registers } } + servers: { main: { unitId: '0', registers: { '0': registers } } } } vi.mock('@renderer/context/server.zustand', () => ({ @@ -122,3 +121,61 @@ 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( + <ServerRegisters name="Holding Registers" type="holding_registers" /> + ) + 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]) + }) + + // '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/components/server/ServerGrid/__tests__/addBoolInline.test.tsx b/src/renderer/src/components/server/ServerGrid/__tests__/addBoolInline.test.tsx new file mode 100644 index 00000000..c395c39c --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/__tests__/addBoolInline.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment happy-dom +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +// +// No test rendered this bar. Its address is component state seeded once from +// what the unit holds, and Add refused an address with nothing free above it in +// silence. +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// The server store registers IPC listeners and runs init() at import time. +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) }) +}) + +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar }) })) + +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { useServerZustand } from '@renderer/context/server.zustand' +import { getDefaultServer } from '@renderer/context/server.zustand.helpers' +import { ServerBoolEntry, UnitIdString } from '@shared' +import ServerBooleans from '../ServerBooleans' + +const UUID = 'u' +const SECOND_UUID = 'a-second-server' + +const coil = (): ServerBoolEntry => ({ value: false, comment: undefined }) + +/** The coils one unit holds, by address. */ +const coilsAt = (unitId: UnitIdString, addresses: number[]): Record<string, unknown> => ({ + [unitId]: { + coils: Object.fromEntries(addresses.map((address) => [String(address), coil()])), + discrete_inputs: {}, + input_registers: {}, + holding_registers: {} + } +}) + +const addressField = (): HTMLElement => + within(screen.getByTestId('add-bool-address-input-coils')).getByRole('textbox') + +const type = (value: string): void => { + act(() => { + fireEvent.input(addressField(), { target: { value } }) + }) +} + +const addBool = vi.fn() + +beforeEach(() => { + enqueueSnackbar.mockClear() + addBool.mockClear() + useServerZustand.setState({ + selectedUuid: UUID, + ready: { [UUID]: true }, + servers: { + [UUID]: { + ...getDefaultServer(), + unitId: '0', + registers: { ...coilsAt('0', [0, 1]), ...coilsAt('1', [0]) } + } + }, + addBool + } as never) +}) + +describe('the address the inline add bar offers', () => { + it('is the first one the unit does not hold', () => { + render(<ServerBooleans name="Coils" type="coils" />) + + expect(addressField()).toHaveValue('2') + }) + + // The field held what the last unit ended on, so adding on a unit holding + // fewer addresses started above every one of them. The unit switched to here + // holds one coil rather than none: an empty one was already put back to 0. + it('is read again when another unit is selected', () => { + render(<ServerBooleans name="Coils" type="coils" />) + expect(addressField()).toHaveValue('2') + + act(() => { + useServerZustand.setState((state) => { + const server = state.servers[UUID] + if (server) server.unitId = '1' + }) + }) + + expect(addressField()).toHaveValue('1') + }) + + // Two servers can be on the same unit id, and then the unit id does not move + // across the switch. Selecting a server is also how the bar's map changes + // most often, because a unit id is picked once per server. + it('is read again when another server is selected', () => { + useServerZustand.setState({ + ready: { [UUID]: true, [SECOND_UUID]: true }, + servers: { + [UUID]: { ...getDefaultServer(), registers: coilsAt('0', [0, 1]) }, + [SECOND_UUID]: { ...getDefaultServer(), registers: coilsAt('0', [0]) } + } + } as never) + render(<ServerBooleans name="Coils" type="coils" />) + expect(addressField()).toHaveValue('2') + + act(() => { + useServerZustand.setState({ selectedUuid: SECOND_UUID } as never) + }) + + expect(addressField()).toHaveValue('1') + }) +}) + +describe('an address with nothing free above it', () => { + beforeEach(() => { + useServerZustand.setState({ + servers: { [UUID]: { ...getDefaultServer(), registers: coilsAt('0', [65535]) } } + } as never) + }) + + it('is refused with a message', () => { + render(<ServerBooleans name="Coils" type="coils" />) + type('65535') + + fireEvent.click(screen.getByTestId('add-bool-btn-coils')) + + expect(enqueueSnackbar).toHaveBeenCalledWith({ + message: 'Every address from 65535 up is taken', + variant: 'error' + }) + expect(addBool).not.toHaveBeenCalled() + }) + + it('takes the one below it with nothing said', () => { + render(<ServerBooleans name="Coils" type="coils" />) + type('65534') + + fireEvent.click(screen.getByTestId('add-bool-btn-coils')) + + expect(enqueueSnackbar).not.toHaveBeenCalled() + expect(addBool).toHaveBeenCalledWith('coils', 65534) + }) +}) 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..c419597a 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,26 @@ 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 } } } + servers: { + main: { + unitId: '0', + registers: { '0': { coils, holding_registers: holdingRegisters } } + } + } } vi.mock('@renderer/context/server.zustand', () => ({ @@ -27,6 +42,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 +69,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(<ServerRegisters name="Holding Registers" type="holding_registers" />) + + 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(<ServerRegisters name="Holding Registers" type="holding_registers" />) + + 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/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') + }) +}) diff --git a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx index d948b621..23d64760 100644 --- a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx +++ b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx @@ -9,8 +9,15 @@ export interface ServerBitProps { bitIndex: number active: boolean comment: string | undefined - onToggle: () => void - onCommentChange: (comment: string | undefined) => void + /** + * Both take the bit they are about, so a caller hands over one `useCallback` + * rather than one arrow per row. `meme` is `memo` with `deepEqual`, which + * compares a function by identity, so an arrow built in a parent's JSX + * redraws every row the parent drew. `ServerBoolRow` passes the address it + * already holds and reads it back here. + */ + onToggle: (bitIndex: number) => void + onCommentChange: (bitIndex: number, comment: string | undefined) => void testIdPrefix?: string /** Number of digits to pad the index label (default: 2) */ padDigits?: number @@ -43,11 +50,13 @@ const ServerBit = meme( const commit = useCallback( (text: string) => { setEditing(false) - onCommentChange(text.trim() || undefined) + onCommentChange(bitIndex, text.trim() || undefined) }, - [onCommentChange] + [bitIndex, onCommentChange] ) + const handleToggle = useCallback(() => onToggle(bitIndex), [bitIndex, onToggle]) + return ( <Box data-testid={`${testIdPrefix}-${bitIndex}`} @@ -72,7 +81,7 @@ const ServerBit = meme( // On and off differ only in background and shadow, so the circle // renders identically to a test either way. This is the state itself. data-active={active} - onClick={onToggle} + onClick={handleToggle} sx={(theme) => ({ width: 12, height: 12, diff --git a/src/renderer/src/components/server/ServerGrid/shared/__tests__/ServerBit.test.tsx b/src/renderer/src/components/server/ServerGrid/shared/__tests__/ServerBit.test.tsx index ce413c45..6ee35148 100644 --- a/src/renderer/src/components/server/ServerGrid/shared/__tests__/ServerBit.test.tsx +++ b/src/renderer/src/components/server/ServerGrid/shared/__tests__/ServerBit.test.tsx @@ -36,7 +36,7 @@ describe('the toggle circle', () => { await userEvent.click(screen.getByTestId('server-bit-circle-3')) - expect(onToggle).toHaveBeenCalledOnce() + expect(onToggle).toHaveBeenCalledWith(3) }) }) @@ -49,6 +49,6 @@ describe('the comment', () => { await userEvent.clear(screen.getByRole('textbox')) await userEvent.type(screen.getByRole('textbox'), 'motor running{Enter}') - expect(onCommentChange).toHaveBeenCalledWith('motor running') + expect(onCommentChange).toHaveBeenCalledWith(3, 'motor running') }) }) diff --git a/src/renderer/src/components/server/__tests__/SelectServer.test.tsx b/src/renderer/src/components/server/__tests__/SelectServer.test.tsx new file mode 100644 index 00000000..fdeb25a4 --- /dev/null +++ b/src/renderer/src/components/server/__tests__/SelectServer.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +/// <reference types="@testing-library/jest-dom/vitest" /> +// +// `Object.keys(servers)` in a selector allocates a new array every time it +// runs, and zustand compares a selector's answer with `Object.is`. React reads +// the selector again on the render that follows, finds another new array and +// renders again: rendering this threw "Maximum update depth exceeded". +import { render, screen } from '@testing-library/react' +import { act } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MAIN_SERVER_UUID } from '@shared' + +vi.mock('notistack', () => ({ + useSnackbar: (): { enqueueSnackbar: () => void } => ({ enqueueSnackbar: vi.fn() }), + enqueueSnackbar: vi.fn() +})) + +import { stubRenderer } from '@renderer/context/__tests__/stubRenderer' + +stubRenderer() + +const { useServerZustand } = await import('@renderer/context/server.zustand') +const { getDefaultServer } = await import('@renderer/context/server.zustand.helpers') +const SelectServer = (await import('../SelectServer')).default + +const SECOND_UUID = 'the-server-on-503' + +beforeEach(() => { + useServerZustand.setState({ + selectedUuid: MAIN_SERVER_UUID, + ready: { [MAIN_SERVER_UUID]: true, [SECOND_UUID]: true }, + serverMode: 'tcp', + servers: { + [MAIN_SERVER_UUID]: { ...getDefaultServer(), port: '502' }, + [SECOND_UUID]: { ...getDefaultServer(), port: '503' } + } + }) +}) + +describe('the server toggle group', () => { + it('draws one toggle per server, labelled with its port', () => { + render(<SelectServer />) + + expect(screen.getByTestId('select-server-502')).toBeInTheDocument() + expect(screen.getByTestId('select-server-503')).toBeInTheDocument() + }) + + it('stands through a write to a field it does not read', () => { + render(<SelectServer />) + + act(() => { + useServerZustand.getState().setName('a name the toggles do not draw') + }) + + expect(screen.getByTestId('select-server-502')).toBeInTheDocument() + }) + + it('draws no toggle for a record holding no server', () => { + useServerZustand.setState({ servers: {} }) + + render(<SelectServer />) + + expect(screen.queryByTestId(/^select-server-/)).toBeNull() + }) +}) 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/components/shared/inputs/HostInput.tsx b/src/renderer/src/components/shared/inputs/HostInput.tsx index 8d981c79..f89e5e84 100644 --- a/src/renderer/src/components/shared/inputs/HostInput.tsx +++ b/src/renderer/src/components/shared/inputs/HostInput.tsx @@ -1,16 +1,52 @@ -import { forwardRef } from 'react' +import { isConnectionAddressGiven } from '@shared' +import { forwardRef, InputHTMLAttributes, useCallback, useEffect, useRef, useState } from 'react' import { meme } from './meme' import { MaskInputProps } from './types' -const HostInputForward = forwardRef<HTMLInputElement, MaskInputProps>((props, ref) => { - const { set, ...other } = props +type HostInputProps = MaskInputProps & + Omit<InputHTMLAttributes<HTMLInputElement>, 'value'> & { value?: unknown } + +/** + * The host field, holding its own text while it has focus. + * + * `setHost` writes the store only once main has answered, so a field reading + * its value from the store has it written from outside after the key, and Cmd+Z + * in it did nothing. Held here, the field keeps its own undo history. The + * store's value reaches the field while it has no focus, and on blur, so a + * store that lags behind the typing never overwrites it. + */ +const HostInputForward = forwardRef<HTMLInputElement, HostInputProps>((props, ref) => { + const { set, value, ...other } = props + const storeText = String(value ?? '') + const [text, setText] = useState(storeText) + const input = useRef<HTMLInputElement | null>(null) + + const attach = useCallback( + (element: HTMLInputElement | null) => { + input.current = element + if (typeof ref === 'function') ref(element) + else if (ref) ref.current = element + }, + [ref] + ) + + useEffect(() => { + if (document.activeElement !== input.current) setText(storeText) + }, [storeText]) + return ( <input {...other} - ref={ref} + ref={attach} + value={text} onChange={(e) => { - const value = e.target.value - set(value, value.trim().length > 0) + 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/LengthInput.tsx b/src/renderer/src/components/shared/inputs/LengthInput.tsx index 91cb6653..1d14f7b5 100644 --- a/src/renderer/src/components/shared/inputs/LengthInput.tsx +++ b/src/renderer/src/components/shared/inputs/LengthInput.tsx @@ -3,18 +3,27 @@ import { forwardRef } from 'react' import { integerMask } from './integerMask' import { meme } from './meme' import { MaskInputProps } from './types' +import { isReadLengthGiven, 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<HTMLInputElement, MaskInputProps>((props, ref) => { - const { set, max = 125, ...other } = props + const { set, max = MAX_READ_REGISTERS, ...other } = props return ( <IMaskInput {...other} {...integerMask} autofix min={0} - max={Math.min(125, max)} + max={max} inputRef={ref} - onAccept={(value) => set(value, Number(value) > 0)} + onAccept={(value) => set(value, isReadLengthGiven(Number(value)))} /> ) }) 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/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/__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 +/// <reference types="@testing-library/jest-dom/vitest" /> +// +// 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 => ( + <TextField + value={value} + slotProps={{ + input: { + inputComponent: HostInput as unknown as ElementType<InputBaseComponentProps, 'input'>, + 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') + }) +}) diff --git a/src/renderer/src/components/shared/inputs/__tests__/integerMask.test.tsx b/src/renderer/src/components/shared/inputs/__tests__/integerMask.test.tsx index c5d36f4c..f3524b89 100644 --- a/src/renderer/src/components/shared/inputs/__tests__/integerMask.test.tsx +++ b/src/renderer/src/components/shared/inputs/__tests__/integerMask.test.tsx @@ -3,15 +3,19 @@ // // `IMask.MaskedNumber` has a scale of 2 and a radix of ',', so a field that // takes the defaults accepts a decimal separator. The value reaching the setter -// is then a string `Number()` answers `NaN` to, and the store used to persist -// that as `null`. +// is then a string `Number()` answers `NaN` to, which the store persists as +// `null`. 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 LengthInput from '../LengthInput' import UIntInput from '../UintInput' import UnitIdInput from '../UnitIdInput' +import { maskInputProps } from '../types' const typeInto = async (input: HTMLInputElement, text: string): Promise<void> => { const user = userEvent.setup() @@ -24,6 +28,30 @@ const renderField = (field: JSX.Element): HTMLInputElement => { return screen.getByRole('textbox') as HTMLInputElement } +// react-imask assigns the value it is handed after it has registered the accept +// listener, and the mask mounts empty, so the assignment reads as a change. +// `setUnitId` and `setAddress` both guard on the value they already hold, and a +// mount of the toolbar field or of the scan dialog's is why. +describe('a masked field on mount', () => { + it('hands the setter the value it was given', () => { + const set = vi.fn() + renderField( + <TextField + value="7" + slotProps={{ + input: { + // The cast the field's own caller makes, in `ConnectionConfig`. + inputComponent: UnitIdInput as unknown as ElementType<InputBaseComponentProps, 'input'>, + inputProps: maskInputProps({ set }) + } + }} + /> + ) + + expect(set.mock.calls).toEqual([['7', true]]) + }) +}) + describe('an integer field and a decimal separator', () => { it('drops the comma from the unit id', async () => { const set = vi.fn() @@ -53,7 +81,7 @@ describe('an integer field and a decimal separator', () => { expect(input.value).toBe('15') }) - it('drops the dot as well, which the mask used to map onto the comma', async () => { + it('drops the dot as well', async () => { const set = vi.fn() const input = renderField(<UnitIdInput set={set} />) 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 } diff --git a/src/renderer/src/containers/Home.tsx b/src/renderer/src/containers/Home.tsx index 0a3dbb9d..bd3fb405 100644 --- a/src/renderer/src/containers/Home.tsx +++ b/src/renderer/src/containers/Home.tsx @@ -10,7 +10,7 @@ import { useCallback } from 'react' import modbuxImage from '../../../../resources/icon.png' import ClientIcon from '@renderer/svg/Client' import ServerIcon from '@renderer/svg/Server' -import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { sendEvent } from '@renderer/events' import Ploxc from '@renderer/svg/Ploxc' import GithubCat from '@renderer/svg/GithubCat' @@ -20,7 +20,7 @@ import GithubCat from '@renderer/svg/GithubCat' // // Button to open the modbus client const ClientButton = meme(() => { - const connected = useClientZustand((z) => z.clientState.connectState === 'connected') + const connected = useDataZustand((z) => z.clientState.connectState === 'connected') const handleClick = useCallback((): void => { const layoutZustand = useLayoutZustand.getState() diff --git a/src/renderer/src/containers/Server.tsx b/src/renderer/src/containers/Server.tsx index d2fde6cb..b67b0e98 100644 --- a/src/renderer/src/containers/Server.tsx +++ b/src/renderer/src/containers/Server.tsx @@ -13,7 +13,7 @@ import PrivilegedPortModal from '@renderer/components/server/PrivilegedPortModal import { ChangeEvent, useCallback } from 'react' const ServerName = meme(() => { - const name = useServerZustand((z) => z.name[z.selectedUuid] ?? '') + const name = useServerZustand((z) => z.servers[z.selectedUuid]?.name ?? '') const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>): void => { const serverZustand = useServerZustand.getState() diff --git a/src/renderer/src/context/__tests__/answeredClientState.test.ts b/src/renderer/src/context/__tests__/answeredClientState.test.ts new file mode 100644 index 00000000..0a9144b0 --- /dev/null +++ b/src/renderer/src/context/__tests__/answeredClientState.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom +// +// Main pushes `client_state` on a change, so a window opened after the last +// push starts on the initial literal. `data.zustand` asks once at import time +// for what main holds now, and a push that lands while that answer is in +// flight is the newer of the two. +// +// The ask sits there rather than in `client.zustand`'s `init` because the two +// modules import each other: entered through `data.zustand`, `client.zustand` +// runs to the end of its tail while `data.zustand` is still evaluating, and a +// name reached back across the cycle at that moment is in its temporal dead +// zone. `init` named one and the throw went into its catch, so on macos the +// window that came back after the last one closed read Connect over a client +// that was connected and polling. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState } from '@shared' +import type { ClientState } from '@shared' + +const disconnected: ClientState = { ...defaultClientState } + +const connectedAndPolling: ClientState = { + ...defaultClientState, + connectState: 'connected', + polling: true +} + +const scanning: ClientState = { + ...defaultClientState, + connectState: 'connected', + scanningRegisters: true +} + +/** The `client_state` listener the store registered, so a test can push. */ +let pushed: ((event: unknown, clientState: ClientState) => void) | undefined + +/** Resolves the answer to `get_client_state`, so a push can land first. */ +let answer: (clientState: ClientState) => void +let refuse: (error: Error) => void + +/** + * The window both stores find at import time. + * + * `isServerWindow` is a boolean the preload exposes rather than a channel, and + * a function in its place is truthy, which would leave both tails taking this + * for the split out window. + */ +const stub = ({ isServerWindow = false }: { isServerWindow?: boolean } = {}): void => { + pushed = undefined + const w = window as unknown as { electron: unknown; api: unknown } + w.electron = { + ipcRenderer: { + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => { + if (channel === 'client_state') + pushed = listener as (event: unknown, clientState: ClientState) => void + return (): void => {} + }, + send: (): void => {} + } + } + w.api = new Proxy( + {}, + { + get: (_target, method: string): unknown => + method === 'isServerWindow' + ? isServerWindow + : method === 'getClientState' + ? (): Promise<ClientState> => + new Promise<ClientState>((resolve, reject) => { + answer = resolve + refuse = reject + }) + : (): Promise<undefined> => Promise.resolve(undefined) + } + ) +} + +const push = (clientState: ClientState): void => { + if (!pushed) throw new Error('no client_state listener was registered') + pushed(undefined, clientState) +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + ;(globalThis as { window?: unknown }).window ??= globalThis + stub() +}) + +describe('the client state main answers with', () => { + // Both halves of the cycle, because which one a window enters through is the + // bundler's to decide and the answer reached the store through only one. + it.each(['../data.zustand', '../client.zustand'])( + 'reaches the store through %s', + async (entry) => { + await import(entry) + const { useDataZustand } = await import('../data.zustand') + + answer(connectedAndPolling) + + await vi.waitFor(() => + expect(useDataZustand.getState().clientState).toEqual(connectedAndPolling) + ) + } + ) + + it('loses to a push that landed while it was in flight', async () => { + const { useDataZustand } = await import('../data.zustand') + + push(scanning) + answer(connectedAndPolling) + + await vi.waitFor(() => expect(useDataZustand.getState().clientState).toEqual(scanning)) + }) + + // The refusal is what this waits for, because the state it leaves behind is + // the state it started on: waiting on that alone passes on the first attempt, + // before the rejection has been handled at all. + it('leaves the state alone when main does not answer', async () => { + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { useDataZustand } = await import('../data.zustand') + + refuse(new Error('no handler registered')) + + await vi.waitFor(() => expect(reported).toHaveBeenCalled()) + expect(useDataZustand.getState().clientState).toEqual(disconnected) + reported.mockRestore() + }) +}) + +/** + * Which window asks, recorded on the one property the tails read before they + * decide. + * + * `isServerWindow` has to answer before the recording, because reading it is + * what the guard does and a window that reads nothing else would look the same + * as one that asked nothing. + */ +const methodsAsked = (isServerWindow: boolean): string[] => { + stub({ isServerWindow }) + const asked: string[] = [] + const answers = window.api as unknown as Record<string, unknown> + window.api = new Proxy( + {}, + { + get: (_target, method: string): unknown => { + if (method === 'isServerWindow') return isServerWindow + asked.push(method) + return answers[method] + } + } + ) as never + return asked +} + +describe('the window that asks main what the client is doing', () => { + // `client_state` is about the one client main holds, and the split out server + // window shows none of it. The guard is the one `init` carries. + it.each([ + [false, true], + [true, false] + ])('is the server window: %s, so it asks: %s', async (isServerWindow, asks) => { + const asked = methodsAsked(isServerWindow) + + await import('../data.zustand') + + expect(asked.includes('getClientState')).toBe(asks) + }) +}) 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..66646f7a --- /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<void> => { + 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 when both stores are loaded', 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__/bitMapExpanded.test.ts b/src/renderer/src/context/__tests__/bitMapExpanded.test.ts new file mode 100644 index 00000000..ff460b59 --- /dev/null +++ b/src/renderer/src/context/__tests__/bitMapExpanded.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom +// +// One row at a time carries the expanded panel, and the row that is already +// expanded is what the toggle closes. The store reads its own field from inside +// the recipe, where the draft holds it. +import { beforeEach, describe, expect, it } from 'vitest' +import { useBitMapZustand } from '../bitmap.zustand' + +const expandedAddress = (): number | null => useBitMapZustand.getState().expandedAddress + +beforeEach(() => { + useBitMapZustand.setState({ expandedAddress: null, detailHeight: 0 }) +}) + +describe('the row holding the expanded bitmap', () => { + it('is the one last asked for', () => { + useBitMapZustand.getState().toggleExpanded(12) + + expect(expandedAddress()).toBe(12) + }) + + it('closes when it is asked for again', () => { + useBitMapZustand.getState().toggleExpanded(12) + useBitMapZustand.getState().toggleExpanded(12) + + expect(expandedAddress()).toBe(null) + }) + + it('moves rather than closing when another row is asked for', () => { + useBitMapZustand.getState().toggleExpanded(12) + useBitMapZustand.getState().toggleExpanded(13) + + expect(expandedAddress()).toBe(13) + }) +}) + +// The grid's row positions come from `getRowHeight`, so the panel measures +// itself and writes what it found here. +describe('how tall the panel rendered', () => { + it('is what was measured', () => { + useBitMapZustand.getState().setDetailHeight(184) + + expect(useBitMapZustand.getState().detailHeight).toBe(184) + }) +}) diff --git a/src/renderer/src/context/__tests__/client.zustand.test.ts b/src/renderer/src/context/__tests__/client.zustand.test.ts index 24940856..0edacb28 100644 --- a/src/renderer/src/context/__tests__/client.zustand.test.ts +++ b/src/renderer/src/context/__tests__/client.zustand.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { ClientState } from '@shared' +import { defaultClientState } from '@shared' +import type { ClientState, RegisterMapping } from '@shared' /** * The store registers its IPC listeners and calls `init()` at import time, so @@ -34,21 +35,14 @@ const handlers = vi.hoisted(() => { }) import { useClientZustand } from '../client.zustand' +import { useDataZustand } from '../data.zustand' -const disconnected: ClientState = { - connectState: 'disconnected', - polling: false, - scanningUnitIds: false, - scanningRegisters: false, - reading: false -} +const disconnected: ClientState = { ...defaultClientState } const connectedAndPolling: ClientState = { + ...defaultClientState, connectState: 'connected', - polling: true, - scanningUnitIds: false, - scanningRegisters: false, - reading: false + polling: true } const pushClientState = (clientState: ClientState): void => { @@ -57,71 +51,31 @@ const pushClientState = (clientState: ClientState): void => { handler(undefined, clientState) } -/** The answer resolves only once this is called, so a push can land first. */ -let answer: (clientState: ClientState) => void const stubApi = (): void => { window.api = { updateConnectionConfig: vi.fn(), updateRegisterConfig: vi.fn(), setReadConfiguration: vi.fn(), - getClientState: vi.fn( - () => - new Promise<ClientState>((resolve) => { - answer = resolve - }) - ) + getClientState: vi.fn(() => new Promise<ClientState>(() => {})) } as never } beforeEach(() => { stubApi() - useClientZustand.setState({ ready: false, clientState: disconnected } as never) + useClientZustand.setState({ ready: false } as never) + useDataZustand.setState({ clientState: disconnected }) }) -describe('init asks main what the client is doing', () => { - it('takes the answer, so a window opened after the last push catches up', async () => { - const initialised = useClientZustand.getState().init() - answer(connectedAndPolling) - await initialised - - expect(useClientZustand.getState().clientState).toEqual(connectedAndPolling) - }) - - it('keeps a push that landed while the answer was in flight', async () => { - const initialised = useClientZustand.getState().init() - - const scanning: ClientState = { - ...connectedAndPolling, - polling: false, - scanningRegisters: true - } - pushClientState(scanning) - answer(connectedAndPolling) - await initialised - - expect(useClientZustand.getState().clientState).toEqual(scanning) - }) - - it('leaves the state alone when main does not answer', async () => { - window.api = { - ...window.api, - getClientState: vi.fn(() => Promise.reject(new Error('no handler registered'))) - } as never - - await expect(useClientZustand.getState().init()).resolves.toBeUndefined() - expect(useClientZustand.getState().clientState).toEqual(disconnected) - }) - - /** - * The half that has to keep working, so it asserts what `init` did before it - * asked anything and never touches the answer. A change to the question must - * leave this green. - */ - it('is ready and has pushed both configs before it asks', () => { +/** + * What main answers about the client is `answeredClientState.test.ts`, because + * `data.zustand` asks at import time rather than through `init`. + */ +describe('init hands main the config this window loaded', () => { + it('is ready and has pushed both configs', () => { const connectionConfig = useClientZustand.getState().connectionConfig const registerConfig = useClientZustand.getState().registerConfig - void useClientZustand.getState().init() + useClientZustand.getState().init() expect(useClientZustand.getState().ready).toBe(true) expect(useClientZustand.getState().readConfiguration).toBe(false) @@ -135,6 +89,59 @@ describe('the client_state listener', () => { it('writes what main pushed', () => { pushClientState(connectedAndPolling) - expect(useClientZustand.getState().clientState).toEqual(connectedAndPolling) + expect(useDataZustand.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<typeof vi.fn> => { + const setRegisterMapping = vi.fn(() => Promise.resolve(answer)) + window.api = { ...window.api, setRegisterMapping } as never + 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({ + ready: true, + readConfiguration: true, + 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' + ) + expect(useClientZustand.getState().readConfiguration).toBe(false) + }) + + // 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' + ) + // 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/__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/__tests__/clientUndo.test.ts b/src/renderer/src/context/__tests__/clientUndo.test.ts new file mode 100644 index 00000000..cd2005c7 --- /dev/null +++ b/src/renderer/src/context/__tests__/clientUndo.test.ts @@ -0,0 +1,350 @@ +// @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<string, unknown> + window.api = new Proxy( + {}, + { + get: (_target, name: string): unknown => + name === method ? (): Promise<undefined> => Promise.resolve(undefined) : underneath[name] + } + ) as never +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +const load = async (): Promise<{ + client: () => ReturnType<typeof import('../client.zustand').useClientZustand.getState> + undo: () => ReturnType<typeof import('../undo.zustand').useUndoZustand.getState> + 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-connected') + 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('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) + 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__/configSetters.test.ts b/src/renderer/src/context/__tests__/configSetters.test.ts new file mode 100644 index 00000000..1078be71 --- /dev/null +++ b/src/renderer/src/context/__tests__/configSetters.test.ts @@ -0,0 +1,110 @@ +// @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 { defaultClientState } from '@shared' +import { recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' + +const load = async (): Promise<{ + useClientZustand: typeof import('../client.zustand').useClientZustand + useDataZustand: typeof import('../data.zustand').useDataZustand +}> => ({ + useClientZustand: (await import('../client.zustand')).useClientZustand, + useDataZustand: (await import('../data.zustand')).useDataZustand +}) + +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, useDataZustand } = await load() + const before = useClientZustand.getState().connectionConfig.rtu.options.baudRate + useDataZustand.getState().setClientState({ + ...defaultClientState, + connectState: 'connected' + }) + 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__/corruptedConfig.test.ts b/src/renderer/src/context/__tests__/corruptedConfig.test.ts index 6b438a78..de0005e8 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]) }) @@ -136,12 +137,30 @@ describe('a persisted client config from a newer version', () => { }) describe('a persisted server config with one field that fails its schema', () => { + // A shipped blob carries six records keyed by uuid, which the migration + // folds into one record of servers before the repair reads them. The field + // that failed is named inside the server it belongs to. it('lets the module finish evaluating', async () => { - localStorage.setItem('server.zustand', JSON.stringify({ state: { port: 'nope' }, version: 3 })) + localStorage.setItem( + 'server.zustand', + JSON.stringify({ + state: { + uuids: ['u'], + port: { u: 502 }, + unitId: { u: '0' }, + name: { u: 'bench' }, + littleEndian: { u: false }, + serverRegisters: { u: {} }, + usedAddresses: { u: {} } + }, + version: 3 + }) + ) const { useServerZustand } = await import('../server.zustand') expect(useServerZustand.getState().configReset?.fields).toContain('port') + expect(useServerZustand.getState().servers.u?.name).toBe('bench') }) it('says nothing when the config parses', async () => { diff --git a/src/renderer/src/context/__tests__/exact64BitValue.test.ts b/src/renderer/src/context/__tests__/exact64BitValue.test.ts index cdd17334..483b65ce 100644 --- a/src/renderer/src/context/__tests__/exact64BitValue.test.ts +++ b/src/renderer/src/context/__tests__/exact64BitValue.test.ts @@ -56,7 +56,7 @@ const seeded = async (dataType: 'uint64' | 'int64'): Promise<Store> => { const stored = (store: Store): ServerRegisterValue => { const registers: ServerRegisters | undefined = - store.useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0'] + store.useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers['0'] const found = registers?.holding_registers[10] if (!found) throw new Error('the register at address 10 is not there') return found.value @@ -82,8 +82,9 @@ describe('a uint64 register a client writes', () => { expect(stored(store)).toBe('18446744073709551615') }) - // The flush clears the batcher, so the next word write reads the entry. That - // read used to be the rounded number. + // The flush clears the batcher, so the next word write reads the entry, and + // what it finds there has to be the exact digits rather than the rounded + // number. it('keeps the other three words when one more word is written after a flush', async () => { const store = await seeded('uint64') for (const [offset, word] of [0xffff, 0xffff, 0xffff, 0xffff].entries()) { 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__/pendingRegisterValue.test.ts b/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts index 8ad8721d..daf3258f 100644 --- a/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts +++ b/src/renderer/src/context/__tests__/pendingRegisterValue.test.ts @@ -49,7 +49,7 @@ const seeded = async (): Promise<typeof import('../server.zustand')> => { /** The entry the store holds, or a failure saying it is not there. */ const stored = (store: typeof import('../server.zustand')): ServerRegister[string] => { const registers: ServerRegisters | undefined = - store.useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0'] + store.useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers['0'] const found = registers?.holding_registers[10] if (!found) throw new Error('the register at address 10 is not there') return found diff --git a/src/renderer/src/context/__tests__/persistedAddressValidity.test.ts b/src/renderer/src/context/__tests__/persistedAddressValidity.test.ts new file mode 100644 index 00000000..51062c84 --- /dev/null +++ b/src/renderer/src/context/__tests__/persistedAddressValidity.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// +// `partialize` persists `connectionConfig` and not `valid`, so a blank COM port +// or a half typed host went to disk while the flag that says main never got it +// did not. The next launch came up with the field showing that text and the +// flag reading true, and Connect took a press on it. The flag is read off the +// value now, which is what the two fields decide about a value and what disk +// carries. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stubRenderer } from './stubRenderer' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +/** A stored client config naming `host` over TCP and `com` over RTU. */ +const stored = (host: string, com: string, length = 10): void => { + localStorage.setItem( + 'client.zustand', + JSON.stringify({ + state: { + name: 'bench', + registerConfig: { + address: 0, + length, + type: 'holding_registers', + pollRate: 1000, + timeout: 1000, + littleEndian: false, + addressBase: '0', + show64BitValues: false, + advancedMode: false + }, + connectionConfig: { + protocol: 'ModbusRtu', + unitId: 1, + tcp: { host, options: { port: 502, timeout: 5000 } }, + rtu: { + com, + options: { baudRate: '19200', dataBits: 8, stopBits: 1, parity: 'none' } + } + } + }, + version: 3 + }) + ) +} + +describe('the validity of a connection address off disk', () => { + it('reads false for a blank COM port, and the text stays in the field', async () => { + stored('127.0.0.1', '') + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().valid.com).toBe(false) + expect(useClientZustand.getState().connectionConfig.rtu.com).toBe('') + }) + + it('reads false for a host of spaces', async () => { + stored(' ', '/dev/ttys011') + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().valid.host).toBe(false) + }) + + it('reads true for both when both name something', async () => { + stored('127.0.0.1', '/dev/ttys011') + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().valid.host).toBe(true) + expect(useClientZustand.getState().valid.com).toBe(true) + }) + + // The read length carries the same kind of flag, over a value the schema + // takes: `RegisterConfigSchema` accepts 0 and `setLength` keeps a cleared + // field in the store without sending it. + it('reads false for a read length of zero', async () => { + stored('127.0.0.1', '/dev/ttys011', 0) + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().valid.length).toBe(false) + expect(useClientZustand.getState().registerConfig.length).toBe(0) + }) + + it('reads true for a read length that asks for something', async () => { + stored('127.0.0.1', '/dev/ttys011', 10) + + const { useClientZustand } = await import('../client.zustand') + + expect(useClientZustand.getState().valid.length).toBe(true) + }) + + // A first launch has no blob, and the defaults are what the fields show. + it('reads the defaults on a launch with nothing stored', async () => { + const { useClientZustand } = await import('../client.zustand') + const { connectionConfig, valid } = useClientZustand.getState() + + expect(valid.host).toBe(connectionConfig.tcp.host.trim().length > 0) + expect(valid.com).toBe(connectionConfig.rtu.com.trim().length > 0) + expect(valid.length).toBe(useClientZustand.getState().registerConfig.length > 0) + }) +}) diff --git a/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts b/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts index 485fd51c..e0a14e41 100644 --- a/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts +++ b/src/renderer/src/context/__tests__/persistedRegisterAddress.test.ts @@ -48,16 +48,16 @@ describe('a server config stored with a register outside the map', () => { } } }, - version: 4 + version: 3 }) ) const { useServerZustand } = await import('../server.zustand') - const registers = useServerZustand.getState().serverRegisters.u?.['1'] + const registers = useServerZustand.getState().servers.u?.registers['1'] expect(Object.keys(registers?.holding_registers ?? {})).toEqual(['100']) expect(Object.keys(registers?.coils ?? {})).toEqual(['3']) - expect(useServerZustand.getState().name.u).toBe('bench') + expect(useServerZustand.getState().servers.u?.name).toBe('bench') expect(useServerZustand.getState().configReset).toBeUndefined() }) }) @@ -101,12 +101,12 @@ describe('a server config stored with a generator the interval floor refuses', ( } } }, - version: 5 + version: 3 }) ) const { useServerZustand } = await import('../server.zustand') - expect(useServerZustand.getState().usedAddresses.u?.['1']?.holding_registers).toEqual([]) + expect(useServerZustand.getState().servers.u?.usedAddresses['1']?.holding_registers).toEqual([]) }) }) diff --git a/src/renderer/src/context/__tests__/readWhenMainCan.test.ts b/src/renderer/src/context/__tests__/readWhenMainCan.test.ts index ad2e6cb1..56f02651 100644 --- a/src/renderer/src/context/__tests__/readWhenMainCan.test.ts +++ b/src/renderer/src/context/__tests__/readWhenMainCan.test.ts @@ -1,13 +1,12 @@ // @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' import { ApiCall, recordApiCalls, stubRenderer } from './stubRenderer' @@ -15,13 +14,7 @@ const calls: ApiCall[] = [] const methods = (): string[] => calls.map((call) => call.method) -const idle: ClientState = { - connectState: 'connected', - polling: false, - scanningUnitIds: false, - scanningRegisters: false, - reading: false -} +const idle: ClientState = { ...defaultClientState, connectState: 'connected' } const row: RegisterData = { id: 0, @@ -56,134 +49,291 @@ beforeEach(() => { recordApiCalls(calls) }) -describe('read configuration, turned on', () => { - it('asks main to read, after main has been told the flag', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState(idle) +// 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 () => { + it('tells main the flag when off', async () => { const { useClientZustand, useDataZustand } = await load() - useClientZustand.getState().setClientState(idle) - useDataZustand.getState().setRegisterData([]) + useDataZustand.getState().setClientState(idle) + useClientZustand.getState().setReadConfiguration(true) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + useClientZustand.getState().setReadConfiguration(false) - expect(methods()).toEqual(['setReadConfiguration', 'read']) + expect(methods()).toEqual(['setReadConfiguration']) }) }) -describe('read configuration, in a state main would refuse', () => { - it('turned off, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState(idle) - useClientZustand.getState().setReadConfiguration(true) +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) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(false) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig', 'read']) }) - it('disconnected, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, connectState: 'disconnected' }) + it('asks for nothing on an empty grid', async () => { + const { useClientZustand, useDataZustand } = await load() + 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 polling, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, polling: true }) + it('asks for nothing disconnected', async () => { + const { useClientZustand, useDataZustand } = await load() + 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']) }) - it('while a unit id scan runs, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, scanningUnitIds: true }) + it('asks for nothing while polling', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState({ ...idle, polling: true }) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig']) }) - it('while a register scan runs, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, scanningRegisters: true }) + it('asks for nothing while a register scan runs', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState({ ...idle, scanningRegisters: true }) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 - useClientZustand.getState().setReadConfiguration(true) + await useClientZustand.getState().setLittleEndian(true) - expect(methods()).toEqual(['setReadConfiguration']) + expect(methods()).toEqual(['updateRegisterConfig']) }) - it('while a read is in flight, asks for nothing', async () => { - const { useClientZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, reading: true }) + it('asks for nothing while a unit id scan runs', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState({ ...idle, scanningUnitIds: true }) + 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 a read is in flight', async () => { const { useClientZustand, useDataZustand } = await load() - useClientZustand.getState().setClientState(idle) + useDataZustand.getState().setClientState({ ...idle, reading: 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 write is in flight', async () => { const { useClientZustand, useDataZustand } = await load() - useClientZustand.getState().setClientState(idle) - useDataZustand.getState().setRegisterData([]) + useDataZustand.getState().setClientState({ ...idle, writing: true }) + useDataZustand.getState().setRegisterData([row]) calls.length = 0 await useClientZustand.getState().setLittleEndian(true) expect(methods()).toEqual(['updateRegisterConfig']) }) +}) - it('asks for nothing while a unit id scan runs', async () => { +/** + * The unit id and the register type, which change what the grid is about. + * + * `clearRegisterDataWhenIdle` emptied the grid for both, and returned early + * with read configuration on because the grid is drawn from the mapping there. + * So the old unit's values stayed in the named rows under the new unit id, and + * a type change left the rows of the type before it: `RegisterGrid` redraws + * the mapping on a change of `readConfiguration` and not of `type`. + */ +describe('read configuration on, and the question the grid answers changes', () => { + /** + * The mapping with a holding register, an input register and a coil. + * + * The coil carries a data type, which no press can put there: the grid + * mounts that column for the two number types alone. A config file can, and + * that is the entry `_read` ignores while `showMapping` draws it. + */ + const withMapping = async ( + useClientZustand: typeof import('../client.zustand').useClientZustand + ): Promise<void> => { + const mapping = emptyRegisterMapping() + mapping.holding_registers['0'] = { dataType: 'uint16', comment: 'holding' } + mapping.input_registers['9'] = { dataType: 'uint16', comment: 'input' } + mapping.coils['4'] = { dataType: 'uint16', comment: 'coil' } + await useClientZustand.getState().replaceRegisterMapping(mapping) + useClientZustand.getState().setReadConfiguration(true) + } + + it('a new unit id redraws the mapping and asks main to read it', async () => { const { useClientZustand, useDataZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, scanningUnitIds: true }) - useDataZustand.getState().setRegisterData([row]) + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + useDataZustand.getState().setRegisterData([{ ...row, hex: 'BEEF' }]) calls.length = 0 - await useClientZustand.getState().setLittleEndian(true) + await useClientZustand.getState().setUnitId('3') + + expect(methods()).toEqual(['updateConnectionConfig', 'read']) + expect(useDataZustand.getState().registerData.map((data) => data.hex)).toEqual(['0000']) + }) + + it('a new register type draws that type and asks main to read it', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + calls.length = 0 + + await useClientZustand.getState().setType('input_registers') + + expect(methods()).toEqual(['updateRegisterConfig', 'read']) + expect(useDataZustand.getState().registerData.map((data) => data.id)).toEqual([9]) + }) + + /** + * A bit type, where main reads the toolbar's block rather than the mapping. + * + * `_read` groups by data type for the two number types alone, so a read + * asked for here comes back as `length` coils from `address` and replaces + * the rows `showMapping` just drew. Both fields hold whatever was in them + * when read configuration went on, because both are disabled under it. + */ + it('a bit type draws the mapping and asks for nothing', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + calls.length = 0 + + await useClientZustand.getState().setType('coils') expect(methods()).toEqual(['updateRegisterConfig']) + expect(useDataZustand.getState().registerData.map((data) => data.id)).toEqual([4]) }) - it('asks for nothing while a read is in flight', async () => { + // The same fallback, reached the other way: a groupable type the mapping + // configures nothing for. `RegisterConfig`'s `nothingConfigured` effect turns + // read configuration off a render later and the grid clears, so a read asked + // for here is answered into a grid that is about to go. + it('a type the mapping configures nothing for asks for nothing', async () => { const { useClientZustand, useDataZustand } = await load() - useClientZustand.getState().setClientState({ ...idle, reading: true }) - useDataZustand.getState().setRegisterData([row]) + useDataZustand.getState().setClientState(idle) + const mapping = emptyRegisterMapping() + mapping.holding_registers['0'] = { dataType: 'uint16', comment: 'holding' } + await useClientZustand.getState().replaceRegisterMapping(mapping) + useClientZustand.getState().setReadConfiguration(true) calls.length = 0 - await useClientZustand.getState().setLittleEndian(true) + await useClientZustand.getState().setType('input_registers') + + expect(methods()).toEqual(['updateRegisterConfig']) + expect(useDataZustand.getState().registerData).toEqual([]) + }) + + // The refusal rule is the same one, so a state main would refuse costs the + // ask and not the redraw: the rows on screen are the old unit's either way. + it('a new unit id while a poll runs leaves the grid to the poll', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + useDataZustand.getState().setClientState({ ...idle, polling: true }) + useDataZustand.getState().setRegisterData([{ ...row, hex: 'BEEF' }]) + calls.length = 0 + + await useClientZustand.getState().setUnitId('3') + + expect(methods()).toEqual(['updateConnectionConfig']) + expect(useDataZustand.getState().registerData.map((data) => data.hex)).toEqual(['BEEF']) + }) + + it('a new unit id while a write is in flight redraws and asks for nothing', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + useDataZustand.getState().setClientState({ ...idle, writing: true }) + useDataZustand.getState().setRegisterData([{ ...row, hex: 'BEEF' }]) + calls.length = 0 + + await useClientZustand.getState().setUnitId('3') + + expect(methods()).toEqual(['updateConnectionConfig']) + expect(useDataZustand.getState().registerData.map((data) => data.hex)).toEqual(['0000']) + }) + + /** + * The address and the length are not that question. + * + * `_read` builds its groups from the mapping and falls back to the toolbar's + * group only when the mapping has none, so with read configuration on those + * two change nothing about what is read. Redrawing would trade the values a + * device answered for `showMapping`'s zeros, and on a disconnected client + * `readWhenMainCan` asks for nothing to put back. + */ + it('a new address leaves the rows and asks for nothing', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + useDataZustand.getState().setRegisterData([{ ...row, hex: 'BEEF' }]) + calls.length = 0 + + await useClientZustand.getState().setAddress('7') expect(methods()).toEqual(['updateRegisterConfig']) + expect(useDataZustand.getState().registerData.map((data) => data.hex)).toEqual(['BEEF']) + }) + + it('a new length leaves the rows and asks for nothing', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + await withMapping(useClientZustand) + useDataZustand.getState().setRegisterData([{ ...row, hex: 'BEEF' }]) + calls.length = 0 + + await useClientZustand.getState().setLength('7', true) + + expect(methods()).toEqual(['updateRegisterConfig']) + expect(useDataZustand.getState().registerData.map((data) => data.hex)).toEqual(['BEEF']) + }) + + // With read configuration off the grid still empties, which is what the two + // setters did before and what the address and length fields rely on. + it('a new unit id with read configuration off empties the grid', async () => { + const { useClientZustand, useDataZustand } = await load() + useDataZustand.getState().setClientState(idle) + useDataZustand.getState().setRegisterData([row]) + calls.length = 0 + + await useClientZustand.getState().setUnitId('3') + + expect(methods()).toEqual(['updateConnectionConfig']) + expect(useDataZustand.getState().registerData).toEqual([]) }) }) 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__/resetBools.test.ts b/src/renderer/src/context/__tests__/resetBools.test.ts new file mode 100644 index 00000000..0e0178d4 --- /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().servers[MAIN_SERVER_UUID]?.registers['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/__tests__/scanRowBatching.test.ts b/src/renderer/src/context/__tests__/scanRowBatching.test.ts new file mode 100644 index 00000000..dd441833 --- /dev/null +++ b/src/renderer/src/context/__tests__/scanRowBatching.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +// +// A scan sends one message per chunk and the grid draws the whole list again on +// each one, so the rows are collected and written on a timer. What that timer +// is holding has to go when the grid is replaced or a scan starts again. +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState, getDummyRegisterData } from '@shared' +import { fireEvent, stubRenderer } from './stubRenderer' + +const SCAN_FLUSH_MS = 100 + +const rows = (addresses: number[]): unknown[] => addresses.map(getDummyRegisterData) + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + localStorage.clear() + stubRenderer() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** The store, with the client told whether a scan is running. */ +const loaded = async ( + scanningRegisters: boolean +): Promise<{ + addresses: () => number[] + dropPendingScanRows: () => void +}> => { + const { useDataZustand, dropPendingScanRows } = await import('../data.zustand') + useDataZustand.setState({ clientState: { ...defaultClientState, scanningRegisters } }) + + return { + addresses: () => useDataZustand.getState().registerData.map((row) => row.id), + dropPendingScanRows + } +} + +describe('rows a scan finds', () => { + it('reach the grid in one write per flush', async () => { + const { addresses } = await loaded(true) + + fireEvent('register_data', rows([0, 1])) + fireEvent('register_data', rows([2])) + expect(addresses()).toEqual([]) + + vi.advanceTimersByTime(SCAN_FLUSH_MS) + + expect(addresses()).toEqual([0, 1, 2]) + }) + + it('are dropped when the scan is asked to forget them', async () => { + const { addresses, dropPendingScanRows } = await loaded(true) + fireEvent('register_data', rows([0, 1])) + + dropPendingScanRows() + vi.advanceTimersByTime(SCAN_FLUSH_MS) + + expect(addresses()).toEqual([]) + }) +}) + +// 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', () => { + it('replace the grid at once', async () => { + const { addresses } = await loaded(false) + + fireEvent('register_data', rows([7])) + + expect(addresses()).toEqual([7]) + }) + + it('take what the scan had waiting with them', async () => { + const { addresses } = await loaded(true) + fireEvent('register_data', rows([0, 1])) + + const { useDataZustand } = await import('../data.zustand') + useDataZustand.setState({ + clientState: { ...defaultClientState, scanningRegisters: false } + }) + fireEvent('register_data', rows([7])) + vi.advanceTimersByTime(SCAN_FLUSH_MS) + + expect(addresses()).toEqual([7]) + }) +}) 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__/serverEndianness.test.ts b/src/renderer/src/context/__tests__/serverEndianness.test.ts index 76fbc62c..27642cf3 100644 --- a/src/renderer/src/context/__tests__/serverEndianness.test.ts +++ b/src/renderer/src/context/__tests__/serverEndianness.test.ts @@ -33,35 +33,33 @@ const recordingBoundary = (): void => { const persisted = { state: { selectedUuid: MAIN_SERVER_UUID, - uuids: [MAIN_SERVER_UUID], - serverRegisters: { + servers: { [MAIN_SERVER_UUID]: { - '0': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: { - 10: { - value: 1, - params: { - address: 10, - registerType: 'holding_registers', - dataType: 'int32', - comment: '', - value: 1 + port: '502', + unitId: '0', + littleEndian: false, + registers: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 10: { + value: 1, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 1 + } } } } - } + }, + usedAddresses: { '0': { holding_registers: [10], input_registers: [] } } } - }, - usedAddresses: { - [MAIN_SERVER_UUID]: { '0': { holding_registers: [10], input_registers: [] } } - }, - port: { [MAIN_SERVER_UUID]: '502' }, - unitId: { [MAIN_SERVER_UUID]: '0' }, - name: {}, - littleEndian: { [MAIN_SERVER_UUID]: false } + } }, version: CURRENT_SERVER_ZUSTAND_VERSION } @@ -102,7 +100,7 @@ describe('who tells main the byte order', () => { expect(methods()).toEqual(['setServerEndianness', 'syncServerRegister']) expect(calls[0]?.payload).toEqual({ uuid: MAIN_SERVER_UUID, littleEndian: true }) - expect(useServerZustand.getState().littleEndian[MAIN_SERVER_UUID]).toBe(true) + expect(useServerZustand.getState().servers[MAIN_SERVER_UUID]?.littleEndian).toBe(true) }) it('sends the registers without saying how to encode them', async () => { diff --git a/src/renderer/src/context/__tests__/serverPortCollision.test.ts b/src/renderer/src/context/__tests__/serverPortCollision.test.ts new file mode 100644 index 00000000..cdfb7e77 --- /dev/null +++ b/src/renderer/src/context/__tests__/serverPortCollision.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +// +// A port another server held was refused before the boundary was reached, so +// no `backend_message` was emitted and the field snapped back to the old value +// with nothing said. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAIN_SERVER_UUID } from '@shared' +import { recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' +import { getDefaultServer } from '../server.zustand.helpers' + +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ enqueueSnackbar })) + +const SECOND_UUID = 'the-server-on-503' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + enqueueSnackbar.mockClear() + stubRenderer() +}) + +/** The store with two ready servers, 502 selected and 503 beside it. */ +const twoServers = async (): Promise<{ + setPort: (port: string) => Promise<boolean> + portOf: (uuid: string) => string | undefined +}> => { + const { useServerZustand } = await import('../server.zustand') + 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 } + }) + return { + setPort: useServerZustand.getState().setPort, + portOf: (uuid) => useServerZustand.getState().servers[uuid]?.port + } +} + +describe('a port another server holds', () => { + it('is refused with a message naming it', async () => { + const { setPort } = await twoServers() + + await setPort('503') + + expect(enqueueSnackbar).toHaveBeenCalledWith({ + message: 'Port 503 is already used by another server', + variant: 'error' + }) + }) + + it('never reaches main', async () => { + const { setPort, portOf } = await twoServers() + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await setPort('503') + + expect(calls.filter((call) => call.method === 'setServerPort')).toEqual([]) + expect(portOf(MAIN_SERVER_UUID)).toBe('502') + }) +}) + +describe('the port this server already holds', () => { + // Retyping the same number is no change, and main's probe would answer + // `EADDRINUSE` for this server's own listener. + it('is refused with nothing said', async () => { + const { setPort } = await twoServers() + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await setPort('502') + + expect(enqueueSnackbar).not.toHaveBeenCalled() + expect(calls.filter((call) => call.method === 'setServerPort')).toEqual([]) + }) +}) + +describe('a port a deleted server held', () => { + // The port went with the key. A record per uuid answered for a server the + // list no longer had, until a sweep reached it. + it('is taken, not refused', async () => { + const { useServerZustand } = await import('../server.zustand') + await twoServers() + await useServerZustand.getState().deleteServer(SECOND_UUID) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await useServerZustand.getState().setPort('503') + + expect(enqueueSnackbar).not.toHaveBeenCalled() + expect(calls.map((call) => call.method)).toContain('setServerPort') + }) +}) diff --git a/src/renderer/src/context/__tests__/serverRtuMode.test.ts b/src/renderer/src/context/__tests__/serverRtuMode.test.ts index 6b45f6a7..d5718343 100644 --- a/src/renderer/src/context/__tests__/serverRtuMode.test.ts +++ b/src/renderer/src/context/__tests__/serverRtuMode.test.ts @@ -47,35 +47,33 @@ const persisted = (com: string): string => JSON.stringify({ state: { selectedUuid: 'a-second-server', - uuids: [MAIN_SERVER_UUID], - serverRegisters: { + servers: { [MAIN_SERVER_UUID]: { - '0': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: { - 10: { - value: 1, - params: { - address: 10, - registerType: 'holding_registers', - dataType: 'uint16', - comment: '', - value: 1 + port: '502', + unitId: '0', + littleEndian: true, + registers: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 10: { + value: 1, + params: { + address: 10, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } } } } - } + }, + usedAddresses: { '0': { holding_registers: [10], input_registers: [] } } } }, - usedAddresses: { - [MAIN_SERVER_UUID]: { '0': { holding_registers: [10], input_registers: [] } } - }, - port: { [MAIN_SERVER_UUID]: '502' }, - unitId: { [MAIN_SERVER_UUID]: '0' }, - name: {}, - littleEndian: { [MAIN_SERVER_UUID]: true }, serverMode: 'rtu', serialConfig: { com, options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } } }, @@ -236,3 +234,45 @@ describe('a serial option changed in TCP mode', () => { expect(methods()).toEqual([]) }) }) + +// The toggle awaits these two and nothing else calls them. Each one takes the +// transport it is leaving down before it writes the mode, because `init` reads +// the mode to decide what to open. +describe('switching the server between the two modes', () => { + it('takes every TCP listener down, then opens the serial port', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + useServerZustand.setState({ serverMode: 'tcp' }) + calls = [] + + await useServerZustand.getState().switchToRtu() + + expect(methods()).toEqual([ + 'stopAllTcpServers', + 'startRtuServer', + 'setServerEndianness', + 'syncBools', + 'syncServerRegister' + ]) + expect(useServerZustand.getState().serverMode).toBe('rtu') + }) + + it('takes the serial port down, then opens the TCP listener again', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted('/dev/ttyUSB0')) + recordingBoundary() + const { useServerZustand } = await import('../server.zustand') + calls = [] + + await useServerZustand.getState().switchToTcp() + + expect(methods()).toEqual([ + 'stopRtuServer', + 'createServer', + 'setServerEndianness', + 'syncBools', + 'syncServerRegister' + ]) + expect(useServerZustand.getState().serverMode).toBe('tcp') + }) +}) diff --git a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts index bf61a7bb..af4bd1c2 100644 --- a/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreRehydrate.test.ts @@ -23,21 +23,22 @@ const store = (addresses: number[]): string => JSON.stringify({ state: { selectedUuid: 'u', - uuids: ['u'], - port: { u: '502' }, - name: { u: 'bench' }, - unitId: { u: '0' }, - littleEndian: { u: false }, - usedAddresses: {}, - serverRegisters: { + servers: { u: { - '0': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: Object.fromEntries( - addresses.map((address) => [String(address), register(address)]) - ) + port: '502', + name: 'bench', + unitId: '0', + littleEndian: false, + usedAddresses: {}, + registers: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: Object.fromEntries( + addresses.map((address) => [String(address), register(address)]) + ) + } } } } @@ -49,12 +50,14 @@ const store = (addresses: number[]): string => const withSecondServer = (raw: string, uuid: string): string => { const blob = JSON.parse(raw) const { state } = blob - state.uuids.push(uuid) state.selectedUuid = uuid - state.port[uuid] = '503' - state.unitId[uuid] = '0' - state.littleEndian[uuid] = false - state.serverRegisters[uuid] = {} + state.servers[uuid] = { + port: '503', + unitId: '0', + littleEndian: false, + registers: {}, + usedAddresses: {} + } return JSON.stringify(blob) } @@ -67,7 +70,7 @@ const settle = async (): Promise<void> => { const held = async (): Promise<string[]> => { const { useServerZustand } = await import('../server.zustand') - return Object.keys(useServerZustand.getState().serverRegisters.u?.['0']?.holding_registers ?? {}) + return Object.keys(useServerZustand.getState().servers.u?.registers['0']?.holding_registers ?? {}) } beforeEach(() => { @@ -122,11 +125,11 @@ describe('the server window closing', () => { fireEvent('window_update', { main: true, server: false }) await settle() - expect(useServerZustand.getState().uuids).toContain('made-in-the-split-window') + expect(Object.keys(useServerZustand.getState().servers)).toContain('made-in-the-split-window') expect(useServerZustand.getState().ready['made-in-the-split-window']).toBe(true) useServerZustand.getState().setUnitId('7') - expect(useServerZustand.getState().unitId['made-in-the-split-window']).toBe('7') + expect(useServerZustand.getState().servers['made-in-the-split-window']?.unitId).toBe('7') }) // A uuid this window's own `init` wrote `false` for is one main refused, and @@ -146,6 +149,117 @@ 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.servers = '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('servers') + expect(useServerZustand.getState().servers).toEqual(useServerZustand.getInitialState().servers) + }) + + // The drop that salvages a register runs in the step to version 4, so a blob + // already at this version reaches `PersistedServerSchema` whole. A key and a + // `params.address` that disagree cost the field there, which is what every + // other rule on that schema costs. + it('resets the registers of a key whose register names another address', async () => { + const edited = JSON.parse(store([0])) + edited.state.servers.u.registers['0'].holding_registers = { '5': register(9) } + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(edited)) + + const { useServerZustand } = await import('../server.zustand') + await settle() + + expect(useServerZustand.getState().configReset?.fields).toContain('registers') + expect(useServerZustand.getState().servers.u?.registers).toEqual({}) + }) + + // The reset is what makes the app usable again and it is also what destroys + // the evidence, so the key is copied before it goes. `persist` wraps + // `setState` with its own `setItem`, so a repair written through the store + // before the copy is taken leaves the copy holding the repaired blob. + it('keeps the register that failed in the copy a bug report can carry', async () => { + const edited = JSON.parse(store([0])) + edited.state.servers.u.registers['0'].holding_registers = { '5': register(9) } + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(edited)) + + await import('../server.zustand') + await settle() + + const corrupt = localStorage.getItem(`${SERVER_ZUSTAND_STORAGE_KEY}.corrupt`) + expect(corrupt).toContain('"address":9') + expect(localStorage.getItem(SERVER_ZUSTAND_STORAGE_KEY)).not.toContain('"address":9') + }) + + // What one field of one server costs. Read whole, `servers` is one field + // holding every one of them, so the register below would take the port and + // the name with it and the second server's registers besides. + it('keeps the port, the name and the server beside it', async () => { + const edited = JSON.parse(withSecondServer(store([0]), 'the-other-one')) + edited.state.servers.u.registers['0'].holding_registers = { '5': register(9) } + edited.state.servers['the-other-one'].registers = { + '0': { coils: {}, discrete_inputs: {}, input_registers: {}, holding_registers: {} } + } + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(edited)) + + const { useServerZustand } = await import('../server.zustand') + await settle() + + const { servers } = useServerZustand.getState() + expect(servers.u?.port).toBe('502') + expect(servers.u?.name).toBe('bench') + expect(Object.keys(servers['the-other-one']?.registers ?? {})).toEqual(['0']) + }) + + // `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') + 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/__tests__/serverStoreWrites.test.ts b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts index a3353e0b..18af5f0d 100644 --- a/src/renderer/src/context/__tests__/serverStoreWrites.test.ts +++ b/src/renderer/src/context/__tests__/serverStoreWrites.test.ts @@ -38,11 +38,11 @@ describe('a server that was just created', () => { await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) - const state = useServerZustand.getState() - expect(state.serverRegisters[NEW_UUID]).toEqual({}) - expect(state.usedAddresses[NEW_UUID]).toEqual({}) - expect(state.unitId[NEW_UUID]).toBe('0') - expect(state.port[NEW_UUID]).toBe('5020') + const server = useServerZustand.getState().servers[NEW_UUID] + expect(server?.registers).toEqual({}) + expect(server?.usedAddresses).toEqual({}) + expect(server?.unitId).toBe('0') + expect(server?.port).toBe('5020') }) it('takes a register on the unit the dialog offers', async () => { @@ -65,9 +65,9 @@ describe('a server that was just created', () => { } }) - const state = useServerZustand.getState() - expect(state.serverRegisters[NEW_UUID]?.['0']?.holding_registers[10]?.value).toBe(0) - expect(state.usedAddresses[NEW_UUID]?.['0']?.['holding_registers']).toEqual([10]) + const server = useServerZustand.getState().servers[NEW_UUID] + expect(server?.registers['0']?.holding_registers[10]?.value).toBe(0) + expect(server?.usedAddresses['0']?.['holding_registers']).toEqual([10]) }) // Main encodes the register and sends its `register_value` words before it @@ -104,7 +104,7 @@ describe('a server that was just created', () => { await vi.waitFor(() => expect( - useServerZustand.getState().serverRegisters[NEW_UUID]?.['0']?.holding_registers[10]?.value + useServerZustand.getState().servers[NEW_UUID]?.registers['0']?.holding_registers[10]?.value ).toBe(-1) ) }) @@ -132,7 +132,7 @@ describe('a server that was just created', () => { } }) - expect(useServerZustand.getState().serverRegisters[NEW_UUID]).toEqual({}) + expect(useServerZustand.getState().servers[NEW_UUID]?.registers).toEqual({}) }) }) @@ -143,7 +143,7 @@ describe('what clean writes', () => { useServerZustand.getState().clean(MAIN_SERVER_UUID) expect( - Object.keys(useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID] ?? {}) + Object.keys(useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers ?? {}) ).toEqual([]) }) }) @@ -161,13 +161,15 @@ describe('a config written before the unit map was left empty', () => { JSON.stringify({ state: { selectedUuid: MAIN_SERVER_UUID, - uuids: [MAIN_SERVER_UUID], - serverRegisters: { [MAIN_SERVER_UUID]: { '0': registers, '1': registers } }, - usedAddresses: { [MAIN_SERVER_UUID]: { '0': {}, '1': {} } }, - port: { [MAIN_SERVER_UUID]: '502' }, - unitId: { [MAIN_SERVER_UUID]: '0' }, - name: {}, - littleEndian: { [MAIN_SERVER_UUID]: false } + servers: { + [MAIN_SERVER_UUID]: { + port: '502', + unitId: '0', + littleEndian: false, + registers: { '0': registers, '1': registers }, + usedAddresses: { '0': {}, '1': {} } + } + } }, version: CURRENT_SERVER_ZUSTAND_VERSION }) @@ -178,8 +180,37 @@ describe('a config written before the unit map was left empty', () => { const state = useServerZustand.getState() expect(state.configReset).toBeUndefined() - expect(Object.keys(state.serverRegisters[MAIN_SERVER_UUID] ?? {})).toEqual(['0', '1']) - expect(state.serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[1]?.value).toBe(false) + expect(Object.keys(state.servers[MAIN_SERVER_UUID]?.registers ?? {})).toEqual(['0', '1']) + expect(state.servers[MAIN_SERVER_UUID]?.registers['0']?.coils[1]?.value).toBe(false) + }) +}) + +describe('deleteServer', () => { + it('leaves nothing keyed by the uuid behind, the name included', async () => { + stubCreateServer(5020) + const { useServerZustand } = await import('../server.zustand') + + await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) + useServerZustand.getState().setName('the one being deleted') + + await useServerZustand.getState().deleteServer(NEW_UUID) + + const state = useServerZustand.getState() + expect(NEW_UUID in state.servers).toBe(false) + expect(NEW_UUID in state.ready).toBe(false) + }) + + // The name went into a record of its own and outlived the delete, and the + // sweep that was supposed to take it read a record the delete had already + // emptied. A name is a field of a server now, so a uuid naming none takes + // nothing. + it('writes no name for a uuid that names no server', async () => { + const { useServerZustand } = await import('../server.zustand') + + useServerZustand.getState().setSelectedUuid(NEW_UUID) + useServerZustand.getState().setName('never bound') + + expect(NEW_UUID in useServerZustand.getState().servers).toBe(false) }) }) @@ -199,8 +230,9 @@ describe('resetServer', () => { await useServerZustand.getState().resetServer(MAIN_SERVER_UUID) expect(resetServer).toHaveBeenCalledWith(MAIN_SERVER_UUID) - expect(useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]).toEqual({}) - expect(useServerZustand.getState().usedAddresses[MAIN_SERVER_UUID]).toEqual({}) + const server = useServerZustand.getState().servers[MAIN_SERVER_UUID] + expect(server?.registers).toEqual({}) + expect(server?.usedAddresses).toEqual({}) }) }) @@ -211,7 +243,7 @@ describe('getUnitId', () => { const unitId = useServerZustand.getState().getUnitId(NEW_UUID) expect(unitId).toBe('0') - expect(Object.keys(useServerZustand.getState().unitId)).toEqual([MAIN_SERVER_UUID]) + expect(Object.keys(useServerZustand.getState().servers)).toEqual([MAIN_SERVER_UUID]) }) }) @@ -230,7 +262,7 @@ describe('a bool the store writes', () => { useServerZustand.getState().addBool('coils', 3) expect( - useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[3]?.value + useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]?.value ).toBe(false) expect(setBool).toHaveBeenCalledWith({ uuid: MAIN_SERVER_UUID, @@ -250,16 +282,37 @@ describe('a bool the store writes', () => { useServerZustand.getState().addBool('coils', 3) expect( - useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]?.['0']?.coils[3]?.value + useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers['0']?.coils[3]?.value ).toBe(true) }) + // Main sends `register_value` from inside the call the store is waiting on, + // and the batcher hands over 50 ms later, so a flush can land for a server + // `deleteServer` took out in between. Dropped rather than written, which is + // what `setRegisterValue` does with an address that is gone. + it('is dropped for a uuid that names no server', async () => { + stubCreateServer(5020) + const { useServerZustand } = await import('../server.zustand') + await useServerZustand.getState().createServer({ uuid: NEW_UUID, port: 5020 }) + await useServerZustand.getState().deleteServer(NEW_UUID) + + useServerZustand.getState().setBool({ + registerType: 'coils', + address: 3, + boolState: true, + optionalUuid: NEW_UUID, + optionalUnitId: '0' + }) + + expect(NEW_UUID in useServerZustand.getState().servers).toBe(false) + }) + it('builds no unit when a bool is removed from one that holds nothing', async () => { const { useServerZustand } = await import('../server.zustand') useServerZustand.getState().clean(MAIN_SERVER_UUID) useServerZustand.getState().removeBool('coils', 3) - expect(useServerZustand.getState().serverRegisters[MAIN_SERVER_UUID]).toEqual({}) + expect(useServerZustand.getState().servers[MAIN_SERVER_UUID]?.registers).toEqual({}) }) }) 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..c9602827 --- /dev/null +++ b/src/renderer/src/context/__tests__/serverUndo.test.ts @@ -0,0 +1,472 @@ +// @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' + +const { enqueueSnackbar } = vi.hoisted(() => ({ enqueueSnackbar: vi.fn() })) +vi.mock('notistack', () => ({ enqueueSnackbar })) + +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<unknown>): void => { + const underneath = window.api as unknown as Record<string, unknown> + window.api = new Proxy( + {}, + { get: (_target, name: string): unknown => (name === method ? answer : underneath[name]) } + ) as never +} + +beforeEach(() => { + 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)) + 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<typeof import('../server.zustand').useServerZustand.getState> + undo: () => ReturnType<typeof import('../undo.zustand').useUndoZustand.getState> + serverUndo: typeof import('../serverUndo') +}> => { + 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: { + [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-gone') + 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) + }) +}) + +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', () => { + 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('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') + 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/__tests__/serverViewDrawsAfterInit.test.ts b/src/renderer/src/context/__tests__/serverViewDrawsAfterInit.test.ts index 207547d7..23faaf4a 100644 --- a/src/renderer/src/context/__tests__/serverViewDrawsAfterInit.test.ts +++ b/src/renderer/src/context/__tests__/serverViewDrawsAfterInit.test.ts @@ -19,27 +19,28 @@ const persisted = (): string => JSON.stringify({ state: { selectedUuid: MAIN_SERVER_UUID, - uuids: [MAIN_SERVER_UUID], - port: { [MAIN_SERVER_UUID]: '502' }, - name: { [MAIN_SERVER_UUID]: 'bench' }, - unitId: { [MAIN_SERVER_UUID]: '0' }, - littleEndian: { [MAIN_SERVER_UUID]: false }, - usedAddresses: {}, - serverRegisters: { + servers: { [MAIN_SERVER_UUID]: { - '0': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: { - '0': { - value: 1, - params: { - address: 0, - registerType: 'holding_registers', - dataType: 'uint16', - comment: '', - value: 1 + port: '502', + name: 'bench', + unitId: '0', + littleEndian: false, + usedAddresses: {}, + registers: { + '0': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + '0': { + value: 1, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } } } } @@ -119,9 +120,10 @@ describe('the flag the server view draws on', () => { }) }) -// The `try` stood around the whole loop, so a refusal for the first uuid threw -// out of it and `createServer` was never called for the second: no listener on -// its port, none of its registers in main, and `ready` false with no message. +// The `catch` is per uuid. Around the whole loop, a refusal for the first uuid +// would throw out of it and leave `createServer` uncalled for the second: no +// listener on its port, none of its registers in main, and `ready` false with +// no message. describe('one server main refuses', () => { const SECOND = 'a-second-server' @@ -153,11 +155,13 @@ describe('one server main refuses', () => { it('costs that server and no other', async () => { const blob = JSON.parse(persisted()) - blob.state.uuids.push(SECOND) - blob.state.port[SECOND] = '503' - blob.state.unitId[SECOND] = '0' - blob.state.littleEndian[SECOND] = false - blob.state.serverRegisters[SECOND] = {} + blob.state.servers[SECOND] = { + port: '503', + unitId: '0', + littleEndian: false, + registers: {}, + usedAddresses: {} + } localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, JSON.stringify(blob)) const created: string[] = [] diff --git a/src/renderer/src/context/__tests__/setRegisterValue.test.ts b/src/renderer/src/context/__tests__/setRegisterValue.test.ts index 41e2acbf..d5c4a8f2 100644 --- a/src/renderer/src/context/__tests__/setRegisterValue.test.ts +++ b/src/renderer/src/context/__tests__/setRegisterValue.test.ts @@ -5,7 +5,7 @@ // `resetRegisters` are both one click, which is what puts a flush on an address // that is gone. import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MAIN_SERVER_UUID, ServerRegister, ServerRegisters } from '@shared' +import { MAIN_SERVER_UUID, ServerRegister, ServerRegisters, ServerRegistersPerUnit } from '@shared' import { stubRenderer } from './stubRenderer' beforeEach(() => { @@ -20,10 +20,10 @@ beforeEach(() => { * A unit is made when something is written into it, so a miss here means the * store was not seeded. */ -const unit = ( - registers: Record<string, Record<string, ServerRegisters | undefined> | undefined> -): ServerRegisters => { - const found = registers[MAIN_SERVER_UUID]?.['0'] +const unit = (state: { + servers: Record<string, { registers: ServerRegistersPerUnit } | undefined> +}): ServerRegisters => { + const found = state.servers[MAIN_SERVER_UUID]?.registers['0'] if (!found) throw new Error('the main server has no default unit') return found } @@ -57,7 +57,7 @@ const seeded = async (): Promise<typeof import('../server.zustand')> => { } const registers = (store: typeof import('../server.zustand')): ServerRegister => - unit(store.useServerZustand.getState().serverRegisters).holding_registers + unit(store.useServerZustand.getState()).holding_registers describe('setRegisterValue', () => { it('writes the value of an entry that is there', async () => { @@ -73,7 +73,7 @@ describe('setRegisterValue', () => { it('drops a value for an address whose entry is gone', async () => { const store = await seeded() store.useServerZustand.setState((state) => { - delete unit(state.serverRegisters).holding_registers[10] + delete unit(state).holding_registers[10] }) expect(() => @@ -89,8 +89,8 @@ describe('setRegisterValue', () => { it('writes the entries beside one that is gone', async () => { const store = await seeded() store.useServerZustand.setState((state) => { - unit(state.serverRegisters).holding_registers[20] = entry(20, 2) - delete unit(state.serverRegisters).holding_registers[10] + unit(state).holding_registers[20] = entry(20, 2) + delete unit(state).holding_registers[10] }) store.useServerZustand.getState().setRegisterValue([ diff --git a/src/renderer/src/context/__tests__/settersAnswer.test.ts b/src/renderer/src/context/__tests__/settersAnswer.test.ts new file mode 100644 index 00000000..8bbc14b1 --- /dev/null +++ b/src/renderer/src/context/__tests__/settersAnswer.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment happy-dom +// +// A caller that replays a setter has to know whether main took the value. Each +// setter answers `false` on every refusal and `true` once main holds the value, +// including when it held it already. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState, MAIN_SERVER_UUID } from '@shared' +import { stubRenderer } from './stubRenderer' +import { getDefaultServer } from '../server.zustand.helpers' + +vi.mock('notistack', () => ({ enqueueSnackbar: vi.fn() })) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() +}) + +/** Answers one channel differently from the stub, and leaves the rest to it. */ +const answerWith = (method: string, answer: (payload: unknown) => Promise<unknown>): void => { + const underneath = window.api as unknown as Record<string, unknown> + window.api = new Proxy( + {}, + { get: (_target, name: string): unknown => (name === method ? answer : underneath[name]) } + ) as never +} + +const loadClient = async (): Promise<{ + useClientZustand: typeof import('../client.zustand').useClientZustand + connect: () => void +}> => { + const { useClientZustand } = await import('../client.zustand') + const { useDataZustand } = await import('../data.zustand') + const connect = (): void => + useDataZustand.getState().setClientState({ ...defaultClientState, connectState: 'connected' }) + return { useClientZustand, connect } +} + +describe('a client setter', () => { + it('answers true once main holds the value', async () => { + const { useClientZustand } = await loadClient() + const client = useClientZustand.getState() + + expect(await client.setPollRate(5000)).toBe(true) + expect(await client.setBaudRate('19200')).toBe(true) + expect(await client.setHost('10.0.0.5', true)).toBe(true) + expect(await client.setUnitId('7')).toBe(true) + }) + + it('answers true for a value main holds already', async () => { + const { useClientZustand } = await loadClient() + await useClientZustand.getState().setUnitId('7') + await useClientZustand.getState().setAddress('40') + + expect(await useClientZustand.getState().setUnitId('7')).toBe(true) + expect(await useClientZustand.getState().setAddress('40')).toBe(true) + }) + + it('answers false for a payload main refuses', async () => { + const { useClientZustand } = await loadClient() + const client = useClientZustand.getState() + + expect(await client.setUnitId('1,5')).toBe(false) + expect(await client.setAddress('4,0')).toBe(false) + expect(await client.setPollRate(Number.NaN)).toBe(false) + }) + + it('answers false for a value it keeps but never sends', async () => { + const { useClientZustand } = await loadClient() + const client = useClientZustand.getState() + + expect(await client.setHost('', false)).toBe(false) + expect(await client.setCom('', false)).toBe(false) + expect(await client.setLength('0', false)).toBe(false) + }) + + it('answers false for a connection field while a connection stands', async () => { + const { useClientZustand, connect } = await loadClient() + connect() + const client = useClientZustand.getState() + + expect(await client.setHost('10.0.0.5', true)).toBe(false) + expect(await client.setBaudRate('19200')).toBe(false) + expect(await client.setProtocol('ModbusRtu')).toBe(false) + }) + + it('answers false before the store is ready', async () => { + const { useClientZustand } = await loadClient() + useClientZustand.setState({ ready: false }) + const client = useClientZustand.getState() + + expect(await client.setPollRate(5000)).toBe(false) + expect(await client.setType('coils')).toBe(false) + }) +}) + +describe('replacing the register mapping', () => { + it('answers true when main takes the mapping', async () => { + const { useClientZustand } = await loadClient() + + expect(await useClientZustand.getState().clearRegisterMapping()).toBe(true) + }) + + it('answers false when main refuses it', async () => { + const { useClientZustand } = await loadClient() + + expect(await useClientZustand.getState().replaceRegisterMapping({ coils: 'x' } as never)).toBe( + false + ) + }) +}) + +const SECOND_UUID = 'the-server-on-503' + +const loadServer = async (): Promise<typeof import('../server.zustand').useServerZustand> => { + const { useServerZustand } = await import('../server.zustand') + 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 } + }) + return useServerZustand +} + +describe('the server port', () => { + it('answers true once main binds the port asked for', async () => { + const useServerZustand = await loadServer() + answerWith('setServerPort', (payload) => Promise.resolve((payload as { port: number }).port)) + + expect(await useServerZustand.getState().setPort('1502')).toBe(true) + }) + + it('answers true for the port the server holds already', async () => { + const useServerZustand = await loadServer() + + expect(await useServerZustand.getState().setPort('502')).toBe(true) + }) + + it('answers false for a port another server holds', async () => { + const useServerZustand = await loadServer() + + expect(await useServerZustand.getState().setPort('503')).toBe(false) + }) + + it('answers false when main binds nothing', async () => { + const useServerZustand = await loadServer() + answerWith('setServerPort', () => Promise.resolve(undefined)) + + expect(await useServerZustand.getState().setPort('1502')).toBe(false) + }) + + it('answers false for a server that is not ready', async () => { + const useServerZustand = await loadServer() + useServerZustand.setState({ ready: { [MAIN_SERVER_UUID]: false } }) + + expect(await useServerZustand.getState().setPort('1502')).toBe(false) + }) + + // `EACCES`, `EADDRINUSE` and port 0 all answer the port the server kept. + it('answers false when main keeps the port it had, and shows that one', async () => { + const useServerZustand = await loadServer() + answerWith('setServerPort', () => Promise.resolve(502)) + + expect(await useServerZustand.getState().setPort('1502')).toBe(false) + expect(useServerZustand.getState().servers[MAIN_SERVER_UUID]?.port).toBe('502') + }) +}) + +describe('the server byte order', () => { + it('answers true for a ready server', async () => { + const useServerZustand = await loadServer() + + expect(await useServerZustand.getState().setLittleEndian(true)).toBe(true) + }) + + it('answers false for a server that is not ready', async () => { + const useServerZustand = await loadServer() + useServerZustand.setState({ ready: { [MAIN_SERVER_UUID]: false } }) + + expect(await useServerZustand.getState().setLittleEndian(true)).toBe(false) + }) +}) + +describe('creating a server', () => { + it('answers true when main binds a port', async () => { + const useServerZustand = await loadServer() + answerWith('createServer', () => Promise.resolve(1504)) + + expect(await useServerZustand.getState().createServer({ uuid: 'new', port: 1504 })).toBe(true) + }) + + it('answers false when main binds nothing', async () => { + const useServerZustand = await loadServer() + answerWith('createServer', () => Promise.resolve(undefined)) + + expect(await useServerZustand.getState().createServer({ uuid: 'new', port: 1504 })).toBe(false) + }) +}) + +describe('a bool', () => { + it('answers whether it was added', async () => { + const useServerZustand = await loadServer() + + expect(useServerZustand.getState().addBool('coils', 3)).toBe(true) + expect(useServerZustand.getState().addBool('coils', 3)).toBe(false) + }) + + it('answers whether it was removed', async () => { + const useServerZustand = await loadServer() + useServerZustand.getState().addBool('coils', 3) + + expect(useServerZustand.getState().removeBool('coils', 3)).toBe(true) + expect(useServerZustand.getState().removeBool('coils', 3)).toBe(false) + }) +}) diff --git a/src/renderer/src/context/__tests__/stubRenderer.ts b/src/renderer/src/context/__tests__/stubRenderer.ts index 617b413b..dc60da8b 100644 --- a/src/renderer/src/context/__tests__/stubRenderer.ts +++ b/src/renderer/src/context/__tests__/stubRenderer.ts @@ -1,8 +1,10 @@ import { AddRegisterParamsSchema, ClientState, + defaultClientState, ConnectionConfigSchema, - RegisterConfigSchema + RegisterConfigSchema, + RegisterMappingSchema } from '@shared' /** @@ -21,13 +23,7 @@ const answerConfig = (schema: { Promise.resolve(schema.safeParse(payload).success ? true : undefined) } -const disconnected: ClientState = { - connectState: 'disconnected', - polling: false, - scanningUnitIds: false, - scanningRegisters: false, - reading: false -} +const disconnected: ClientState = { ...defaultClientState } /** * A channel whose type cannot answer `undefined` answers something here. @@ -39,6 +35,7 @@ const disconnected: ClientState = { const answers: Record<string, (payload: unknown) => Promise<unknown>> = { 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<number[] | undefined> => 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__/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<string>(), '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<string>(), '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<string>(), '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<string>(), '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<number>() + 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<string>(), '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<string>(), '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<string>(), '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/__tests__/uuidWithNoStoredPort.test.ts b/src/renderer/src/context/__tests__/uuidWithNoStoredPort.test.ts new file mode 100644 index 00000000..52fc8074 --- /dev/null +++ b/src/renderer/src/context/__tests__/uuidWithNoStoredPort.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +// +// A port is stored as the string the field holds, so a hand-edited key can +// carry one `PortSchema` refuses. `init` then opened the server on it, main +// answered undefined and the server stood in the toggle group with an empty +// label. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CreateServerParamsSchema, + CURRENT_SERVER_ZUSTAND_VERSION, + SERVER_ZUSTAND_STORAGE_KEY +} from '@shared' +import { type ApiCall, recordApiCalls, stubRenderer } from './stubRenderer' + +const persisted = (port: Record<string, string>, uuids = ['u']): string => + JSON.stringify({ + state: { + selectedUuid: uuids[0], + servers: Object.fromEntries( + uuids.map((uuid) => [ + uuid, + { + // A string `Number` answers NaN for, which `PortSchema` refuses. + // `PersistedServerSchema` takes any string, so this reaches `portToOpen`. + port: port[uuid] ?? 'nope', + unitId: '0', + littleEndian: false, + registers: {}, + usedAddresses: {} + } + ]) + ) + }, + version: CURRENT_SERVER_ZUSTAND_VERSION + }) + +/** + * Main answering with the port it bound, which is what the store writes. + * + * Parsed the way `main/ipc.ts` guards the channel, because a `NaN` is exactly + * the payload this file is about: the boundary answers `undefined` for it and + * the store writes nothing. + */ +const stubCreateServer = (): void => { + const w = window as unknown as { api: Record<string, unknown> } + const boundary = w.api + w.api = new Proxy(boundary, { + get: (target, method: string): unknown => + method === 'createServer' + ? (payload: unknown): Promise<number | undefined> => { + const parsed = CreateServerParamsSchema.safeParse(payload) + return Promise.resolve(parsed.success ? parsed.data.port : undefined) + } + : Reflect.get(target, method) + }) +} + +/** Waits out `init`, which runs from module scope with nothing awaiting it. */ +const settle = async (): Promise<void> => { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +const portsAskedFor = (calls: ApiCall[]): unknown[] => + calls.filter((call) => call.method === 'createServer').map((call) => call.payload) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + stubRenderer() + stubCreateServer() +}) + +describe('a server whose port is no port', () => { + it('is opened on the registered port when nothing holds it', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted({})) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + const { useServerZustand } = await import('../server.zustand') + await settle() + + expect(portsAskedFor(calls)).toEqual([{ uuid: 'u', port: 502 }]) + expect(useServerZustand.getState().servers.u?.port).toBe('502') + }) + + // Main probes sockets, and the uuids after this one have no listener yet, so + // a walk starting at 502 would hand this server the port the last one holds + // and move that one along. + it('is opened past the ports the other servers hold', async () => { + localStorage.setItem( + SERVER_ZUSTAND_STORAGE_KEY, + persisted({ first: '502', last: '503' }, ['first', 'middle', 'last']) + ) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + const { useServerZustand } = await import('../server.zustand') + await settle() + + expect(portsAskedFor(calls)).toEqual([ + { uuid: 'first', port: 502 }, + { uuid: 'middle', port: 504 }, + { uuid: 'last', port: 503 } + ]) + expect(useServerZustand.getState().servers.last?.port).toBe('503') + }) +}) + +describe('a server whose port is one', () => { + it('is opened on the one it holds', async () => { + localStorage.setItem(SERVER_ZUSTAND_STORAGE_KEY, persisted({ u: '5021' })) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + const { useServerZustand } = await import('../server.zustand') + await settle() + + expect(portsAskedFor(calls)).toEqual([{ uuid: 'u', port: 5021 }]) + expect(useServerZustand.getState().servers.u?.port).toBe('5021') + }) +}) diff --git a/src/renderer/src/context/__tests__/volatileState.test.ts b/src/renderer/src/context/__tests__/volatileState.test.ts new file mode 100644 index 00000000..9c890164 --- /dev/null +++ b/src/renderer/src/context/__tests__/volatileState.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +// +// 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. 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' +import { stubRenderer } from './stubRenderer' + +const transaction: Transaction = { + id: 'a', + timestamp: 0, + unitId: 1, + address: 0, + code: 3, + responseLength: 2, + timeout: false, + request: '0103000000010000', + responses: [] +} + +const scanResult: ScanUnitIDResult = { + id: 1, + registerTypes: ['holding_registers'], + refusedRegisterTypes: [], + requestedRegisterTypes: ['holding_registers'], + errorMessage: { coils: '', discrete_inputs: '', input_registers: '', holding_registers: '' } +} + +/** Every storage key written since the list was last emptied, in order. */ +const keysWritten: string[] = [] + +const load = async (): Promise<{ + useClientZustand: typeof import('../client.zustand').useClientZustand + useDataZustand: typeof import('../data.zustand').useDataZustand +}> => ({ + useClientZustand: (await import('../client.zustand')).useClientZustand, + useDataZustand: (await import('../data.zustand')).useDataZustand +}) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + keysWritten.length = 0 + stubRenderer() + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string) => { + keysWritten.push(key) + }) +}) + +describe('what main pushes about the client', () => { + it('reaches no storage, where a persisted field reaches it', async () => { + const { useClientZustand, useDataZustand } = await load() + const dataZustand = useDataZustand.getState() + keysWritten.length = 0 + + dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) + dataZustand.addTransactions([transaction]) + dataZustand.addScanUnitIdResults([scanResult]) + dataZustand.setScanProgress(50) + dataZustand.setLastSuccessfulTransactionMillis(1) + + expect(keysWritten).toEqual([]) + + // The same spy under a field `partialize` keeps, so an empty list above is + // the store staying off disk rather than the spy never being called. + useClientZustand.getState().setName('a name') + + expect(keysWritten).toEqual([CLIENT_ZUSTAND_STORAGE_KEY]) + }) + + it('lands in the store all the same', async () => { + const { useDataZustand } = await load() + const dataZustand = useDataZustand.getState() + + dataZustand.setClientState({ ...defaultClientState, connectState: 'connected' }) + dataZustand.addTransactions([transaction]) + dataZustand.addScanUnitIdResults([scanResult]) + dataZustand.setScanProgress(50) + dataZustand.setLastSuccessfulTransactionMillis(1) + + const state = useDataZustand.getState() + expect(state.clientState.connectState).toBe('connected') + expect(state.transactions).toEqual([transaction]) + expect(state.scanUnitIdResults).toEqual([scanResult]) + expect(state.scanProgress).toBe(50) + expect(state.lastSuccessfulTransactionMillis).toBe(1) + }) +}) diff --git a/src/renderer/src/context/__tests__/writeAfterAccept.test.ts b/src/renderer/src/context/__tests__/writeAfterAccept.test.ts index ae6acc8b..fdf3b25e 100644 --- a/src/renderer/src/context/__tests__/writeAfterAccept.test.ts +++ b/src/renderer/src/context/__tests__/writeAfterAccept.test.ts @@ -1,12 +1,13 @@ // @vitest-environment happy-dom // // The mask fields have a radix of ',' and map '.' onto it, so `1,5` typed into -// the unit id is `NaN` by the time a setter sees it. The store used to write it -// and call main afterwards, `JSON.stringify` turned it into `null`, and the next -// launch refused the whole persisted config and reset it. +// the unit id is `NaN` by the time a setter sees it. A store that wrote it and +// called main afterwards persisted `null` through `JSON.stringify`, and the +// next launch refused the whole config and reset it. import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultClientState } from '@shared' import type { RegisterData } from '@shared' -import { stubRenderer } from './stubRenderer' +import { recordApiCalls, stubRenderer, type ApiCall } from './stubRenderer' const load = async (): Promise<{ clientZustand: typeof import('../client.zustand') @@ -34,14 +35,16 @@ beforeEach(() => { }) describe('a payload the boundary refuses', () => { - it('leaves the unit id the store had', async () => { - const { clientZustand } = await load() + it('leaves the unit id the store had, and the rows that unit answered', async () => { + const { clientZustand, dataZustand } = await load() const { useClientZustand } = clientZustand await useClientZustand.getState().setUnitId('7') + dataZustand.useDataZustand.getState().setRegisterData(rows) await useClientZustand.getState().setUnitId('1,5') expect(useClientZustand.getState().connectionConfig.unitId).toBe(7) + expect(dataZustand.useDataZustand.getState().registerData).toHaveLength(1) }) it('leaves the address and the rows read at it', async () => { @@ -59,12 +62,51 @@ describe('a payload the boundary refuses', () => { }) describe('a payload the boundary takes', () => { - it('writes the unit id', async () => { - const { clientZustand } = await load() + // A unit id names which device answers, so the rows the last one answered + // are about another device. + it('writes the unit id and drops the rows the old one answered', async () => { + const { clientZustand, dataZustand } = await load() + dataZustand.useDataZustand.getState().setRegisterData(rows) await clientZustand.useClientZustand.getState().setUnitId('7') expect(clientZustand.useClientZustand.getState().connectionConfig.unitId).toBe(7) + expect(dataZustand.useDataZustand.getState().registerData).toEqual([]) + }) + + // A mount of the masked field hands the setter the id the store already + // holds, which `integerMask.test.tsx` measures, so this is what keeps opening + // the scan dialog or walking to Home and back from emptying the grid. + it('keeps the rows when the unit id it is handed is the one it holds', async () => { + const { clientZustand, dataZustand } = await load() + const { useClientZustand } = clientZustand + await useClientZustand.getState().setUnitId('7') + dataZustand.useDataZustand.getState().setRegisterData(rows) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await useClientZustand.getState().setUnitId('7') + + expect(dataZustand.useDataZustand.getState().registerData).toHaveLength(1) + expect(calls).toEqual([]) + }) + + // `clearRegisterDataWhenIdle`: a poll is about to put new rows there, and + // emptying the grid under it is a flicker rather than an answer. + it('keeps the rows while a poll is running', async () => { + const { clientZustand, dataZustand } = await load() + const { useClientZustand } = clientZustand + dataZustand.useDataZustand.getState().setRegisterData(rows) + dataZustand.useDataZustand.getState().setClientState({ + ...defaultClientState, + connectState: 'connected', + polling: true + }) + + await useClientZustand.getState().setUnitId('7') + + expect(useClientZustand.getState().connectionConfig.unitId).toBe(7) + expect(dataZustand.useDataZustand.getState().registerData).toHaveLength(1) }) it('writes the address and drops the rows read at the old one', async () => { @@ -80,8 +122,9 @@ describe('a payload the boundary takes', () => { describe('a value the field marks invalid', () => { /** - * The host and the length are the two the field reads back out of the store, - * so they are written without being sent and the input keeps what was typed. + * The host, the COM port and the length are the three the field reads back + * out of the store, so they are written without being sent and the input + * keeps what was typed. */ it('keeps the half-typed host in the store and marks it invalid', async () => { const { clientZustand } = await load() @@ -93,6 +136,35 @@ describe('a value the field marks invalid', () => { expect(useClientZustand.getState().valid.host).toBe(false) }) + // `ConnectionConfigRtuSchema` types `com` as a string and takes a blank one, + // so nothing at the boundary refuses a connection config naming no port. + it('keeps a blank COM port in the store and sends nothing', async () => { + const { clientZustand } = await load() + const { useClientZustand } = clientZustand + await useClientZustand.getState().setCom('COM9', true) + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await useClientZustand.getState().setCom(' ', false) + + expect(useClientZustand.getState().connectionConfig.rtu.com).toBe(' ') + expect(useClientZustand.getState().valid.com).toBe(false) + expect(calls).toEqual([]) + }) + + it('sends a COM port the field accepts', async () => { + const { clientZustand } = await load() + const { useClientZustand } = clientZustand + const calls: ApiCall[] = [] + recordApiCalls(calls) + + await useClientZustand.getState().setCom('COM9', true) + + expect(useClientZustand.getState().connectionConfig.rtu.com).toBe('COM9') + expect(useClientZustand.getState().valid.com).toBe(true) + expect(calls).toEqual([{ method: 'updateConnectionConfig', payload: { rtu: { com: 'COM9' } } }]) + }) + it('keeps an empty length in the store and marks it invalid', async () => { const { clientZustand } = await load() const { useClientZustand } = clientZustand diff --git a/src/renderer/src/context/client.zustand.ts b/src/renderer/src/context/client.zustand.ts index 95f6c908..dbd96909 100644 --- a/src/renderer/src/context/client.zustand.ts +++ b/src/renderer/src/context/client.zustand.ts @@ -1,9 +1,9 @@ /* 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 { + ClientSet, PersistedClientZustand, PersistedClientZustandSchema, ClientZustand @@ -15,12 +15,21 @@ import { migrateClientState, carryFormerClientState, CLIENT_ZUSTAND_STORAGE_KEY, - emptyRegisterMapping + clientOwner, + configuredReadGroups, + emptyRegisterMapping, + isConnectionAddressGiven, + isReadLengthGiven, + RegisterConfig, + RegisterMapping, + SerialPortOptions } from '@shared' -import { useDataZustand } from './data.zustand' +import { showMapping, useDataZustand } from './data.zustand' import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' -import { onEvent } from '@renderer/events' +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. @@ -35,61 +44,173 @@ let _ipcTimer: ReturnType<typeof setTimeout> | 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<boolean> => { if (_ipcTimer) clearTimeout(_ipcTimer) _ipcTimer = null - window.api.setRegisterMapping(useClientZustand.getState().registerMapping) + return (await window.api.setRegisterMapping(registerMapping)) ?? false } /** - * Drop the rows on screen, unless something is about to replace them. + * Answer a question the rows on screen no longer answer. + * + * Address, length and type each change what a read asks for, and the unit id + * changes which device answers it, so the rows from the last read are about + * something else now. A poll puts new ones there on its own. + * + * With read configuration on, emptying the grid is the wrong answer, because + * the grid is drawn from the mapping there and the configured rows would go + * with it. Whether there is a right one depends on the field, which is what + * `readsTheMapping` names. + * + * The unit id and the type change what the mapping is read from, so the rows + * are redrawn and main is asked to fill them: leaving them alone left the old + * unit's values in the named rows, and after a type change left the rows of + * the type before it, because `RegisterGrid` redraws the mapping on a change + * of `readConfiguration` and not of `type`. That is what `setReadConfiguration` + * does when it is switched on and what `setLittleEndian` does for its own + * question. + * + * The address and the length change nothing there. `_read` builds its groups + * from the mapping and falls back to the toolbar's group only when the mapping + * has none, so the rows still answer the same question, and redrawing them + * would trade values a device answered for `showMapping`'s zeros. Both fields + * are disabled while read configuration is on, so this is the rule rather than + * a state to reach. * - * Address, length and type each change what a read asks for, so the rows from - * the last read answer a different question. Polling puts new ones there on its - * own, and so does read configuration. + * `configuredReadGroups` is that same fallback asked before the ask. A bit + * type configures nothing main will read, and neither does a type with no + * group under it, so a read there comes back as the toolbar's block over the + * mapping just drawn. The redraw still happens, because the mapping is what + * the grid is about; the ask does not. */ -const clearRegisterDataWhenIdle = (): void => { - const { clientState, readConfiguration } = useClientZustand.getState() - if (clientState.polling || readConfiguration) return +const clearRegisterDataWhenIdle = (readsTheMapping: boolean): void => { + const { readConfiguration, registerConfig, registerMapping } = useClientZustand.getState() + if (useDataZustand.getState().clientState.polling) return + if (readConfiguration) { + if (!readsTheMapping) return + showMapping() + if (configuredReadGroups(true, registerConfig.type, registerMapping).length > 0) { + readWhenMainCan() + } + return + } useDataZustand.getState().setRegisterData([]) } /** - * Ask main for a read, unless it is in no position to answer. + * Whether a connection field may change, which is while no connection stands. * - * `read` refuses and says so in a snackbar when a poll, either scan or a read - * already in flight owns the port, and again when nothing is connected, so a - * caller that asks anyway costs the user a warning it did not ask for. The four - * states are `_readLoopOwner` in `modbusClient`. + * Five setters ask it: `setSerialOption` for the four serial options, and + * `setProtocol`, `setPort`, `setHost` and `setCom`. `connectState` lives in + * `data.zustand` with the rest of what main pushes, so they read it there + * rather than out of the state they are writing. */ -const readWhenMainCan = (): void => { - const { connectState, polling, scanningUnitIds, scanningRegisters, reading } = - useClientZustand.getState().clientState - if (connectState !== 'connected') return - if (polling || scanningUnitIds || scanningRegisters || reading) return - window.api.read() +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 extends ClientField>( + field: Field, + before: ClientFieldValues[Field], + after: ClientFieldValues[Field] +): void => { + if (before === after) return + useUndoZustand.getState().recordClient(clientFieldSteps[field](before)) } /** - * Whether a `client_state` push has landed since the module was evaluated. + * One serial option, sent and then written where main took it. * - * `init` asks main what the client is doing, because main pushes on a change - * and a window opened after the last push starts on the initial literal. The - * answer is main's state read when the handler ran, so a push that arrives - * while the answer is in flight is the newer of the two and keeps its value. + * `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. */ -let clientStatePushed = false +const setSerialOption = async <Key extends keyof SerialPortOptions>( + set: ClientSet, + get: () => ClientZustand, + key: Key, + value: SerialPortOptions[Key] +): Promise<boolean> => { + 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<keyof SerialPortOptions>(key, before, clientFieldReaders[key](get())) + return true +} + +/** + * 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 <Key extends keyof RegisterConfig>( + set: ClientSet, + get: () => ClientZustand, + key: Key, + value: RegisterConfig[Key] +): Promise<boolean> => { + 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<keyof RegisterConfig>(key, before, value) + return true +} + +/** + * Ask main for a read, unless it is in no position to answer. + * + * `read` refuses and says so in a snackbar when anything owns the client, and + * again when nothing is connected, so a caller that asks anyway costs the user + * a warning it did not ask for. `clientOwner` is the question main asks, which + * is why it is asked here rather than restated. + */ +const readWhenMainCan = (): void => { + const { clientState } = useDataZustand.getState() + if (clientState.connectState !== 'connected') return + if (clientOwner(clientState)) return + window.api.read() +} carryFormerClientState(localStorage) @@ -100,7 +221,7 @@ export const useClientZustand = create< persist( mutative((set, get) => ({ // Config - init: async () => { + init: () => { const { connectionConfig, registerConfig } = get() window.api.updateConnectionConfig(connectionConfig) @@ -110,32 +231,27 @@ export const useClientZustand = create< set((state) => { state.readConfiguration = false state.ready = true - }) - // Ready is set before this, so a store action does not wait on a round - // trip. The catch is the point of the try: this runs from module scope - // with nothing awaiting it, and a rejection there is an unhandled one. - // Falling through leaves the initial literal, which is what the window - // showed before it asked. - try { - const clientState = await window.api.getClientState() - if (!clientStatePushed) - set((state) => { - state.clientState = clientState - }) - } catch { - // Main answers this synchronously; a rejection means it is not there. - } + // `partialize` persists `connectionConfig` and not `valid`, so a + // blank COM port typed before a quit comes back in the field with + // the flag reading true, and Connect took a press on it. The flag is + // read off the value rather than stored, because it is what the two + // fields decide about a value and a value is what disk carries. + state.valid.host = isConnectionAddressGiven(connectionConfig.tcp.host) + state.valid.com = isConnectionAddressGiven(connectionConfig.rtu.com) + state.valid.length = isReadLengthGiven(registerConfig.length) + }) }, connectionConfig: defaultConnectionConfig, registerConfig: defaultRegisterConfig, - // Connection state - // Register mapping name: '', - setName: (name) => + setName: (name) => { + const before = get().name set((state) => { state.name = name - }), + }) + recordField('name', before, name) + }, configReset: undefined, acknowledgeConfigReset: () => set((state) => { @@ -144,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' @@ -165,43 +282,42 @@ 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() }, - replaceRegisterMapping: (registerMapping) => { + setMappingEntry: (type, register, entry) => { + set((state) => { + if (entry === undefined) delete state.registerMapping[type][register] + else state.registerMapping[type][register] = entry + }) + syncRegisterMappingToMain() + }, + 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. 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 false + set((state) => { state.registerMapping = registerMapping }) - flushRegisterMappingToMain() + return true }, clearRegisterMapping: () => get().replaceRegisterMapping(emptyRegisterMapping()), - transactions: [], - addTransaction: (transaction) => - set((state) => { - state.transactions.unshift(transaction) - while (state.transactions.length > 1000) state.transactions.pop() - }), - clearTransactions: () => - set((state) => { - state.transactions = [] - }), // State - clientState: { - connectState: 'disconnected', - polling: false, - scanningUnitIds: false, - scanningRegisters: false, - reading: false - }, - setClientState: (clientState) => - set((state) => { - state.clientState = clientState - }), ready: false, readConfiguration: false, @@ -220,38 +336,51 @@ export const useClientZustand = create< // `1,5` in a mask field is `NaN`, `UnitIdSchema` refuses it, and a store // 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. + // no field waits on the answer. The exception is a field carrying a + // validity flag, which sends nothing while that flag is false; the host, + // the COM port and the length each say why where they stand. + // + // 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 - if (currentState.clientState.connectState !== 'disconnected') return + if (!get().ready) return false + if (!isDisconnected()) return false - if (!(await window.api.updateConnectionConfig({ protocol }))) return + 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 }, // // // TCP setPort: async (port) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + if (!get().ready) return false + 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 + return false set((state) => { state.connectionConfig.tcp.options.port = newPort }) + recordField('port', before, newPort) + return true }, setHost: async (host, valid) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + 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. @@ -260,130 +389,101 @@ export const useClientZustand = create< state.valid.host = false state.connectionConfig.tcp.host = host }) - return + recordField('host', before, host) + return false } - if (!(await window.api.updateConnectionConfig({ tcp: { host } }))) return + if (!(await window.api.updateConnectionConfig({ tcp: { host } }))) return false set((state) => { state.valid.host = true state.connectionConfig.tcp.host = host }) + recordField('host', before, host) + return true }, // // // RTU setCom: async (com, valid) => { - const currentState = get() - if (!currentState.ready) return - if (currentState.clientState.connectState !== 'disconnected') return + if (!get().ready) return false + if (!isDisconnected()) return false + + // The field reads its text from the store, so a blank port name is + // 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 + } - if (!(await window.api.updateConnectionConfig({ rtu: { com } }))) return + if (!(await window.api.updateConnectionConfig({ rtu: { com } }))) return false set((state) => { - state.valid.com = !!valid + state.valid.com = true state.connectionConfig.rtu.com = com }) + recordField('com', before, com) + return true }, - 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 + const currentState = get() + if (!currentState.ready) return false + // `UnitIdInput` is an `IMaskInput`, which fires `accept` when the value + // it is handed differs from the empty mask it mounts with, so every + // mount of the toolbar field and of the scan dialog's calls this with + // the id the store already holds. The rows below would go with it. const newUnitId = Number(unitId) - if (!(await window.api.updateConnectionConfig({ unitId: newUnitId }))) return + if (newUnitId === currentState.connectionConfig.unitId) return true + + if (!(await window.api.updateConnectionConfig({ unitId: newUnitId }))) return false set((state) => { state.connectionConfig.unitId = newUnitId }) + recordField('unitId', currentState.connectionConfig.unitId, newUnitId) + clearRegisterDataWhenIdle(true) + return true }, setAddress: async (address) => { const currentState = get() - if (!currentState.ready) return + if (!currentState.ready) return false const newAddress = Number(address) - if (newAddress === currentState.registerConfig.address) return + if (newAddress === currentState.registerConfig.address) return true - if (!(await window.api.updateRegisterConfig({ address: newAddress }))) return + if (!(await window.api.updateRegisterConfig({ address: newAddress }))) return false set((state) => { state.registerConfig.address = newAddress }) - clearRegisterDataWhenIdle() + recordField('address', currentState.registerConfig.address, newAddress) + clearRegisterDataWhenIdle(false) + return true }, setLength: async (length, valid) => { const currentState = get() - if (!currentState.ready) return + if (!currentState.ready) return false const newLength = Number(length) @@ -394,94 +494,61 @@ export const useClientZustand = create< state.valid.length = false state.registerConfig.length = newLength }) - return + recordField('length', currentState.registerConfig.length, newLength) + return false } - if (!(await window.api.updateRegisterConfig({ length: newLength }))) return + if (!(await window.api.updateRegisterConfig({ length: newLength }))) return false set((state) => { state.valid.length = true state.registerConfig.length = newLength }) - clearRegisterDataWhenIdle() + recordField('length', currentState.registerConfig.length, newLength) + clearRegisterDataWhenIdle(false) + return true }, setType: async (type) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ type }))) return + if (!get().ready) return false + const before = get().registerConfig.type + if (!(await window.api.updateRegisterConfig({ type }))) return false set((state) => { state.registerConfig.type = type }) - clearRegisterDataWhenIdle() + recordField('type', before, type) + clearRegisterDataWhenIdle(true) + return true }, setLittleEndian: async (littleEndian) => { - if (!get().ready) return - if (!(await window.api.updateRegisterConfig({ littleEndian }))) return + 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 // until the next read. An empty grid has nothing to put right. if (useDataZustand.getState().registerData.length > 0) readWhenMainCan() + return true }, setReadConfiguration: (readConfiguration) => { if (!get().ready) return 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: 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 - }) - }, - // Transaction - lastSuccessfulTransactionMillis: null, - setLastSuccessfulTransactionMillis: (value) => - set((state) => { - state.lastSuccessfulTransactionMillis = value - }), - // Unit ID Scannning - scanUnitIdResults: [], - addScanUnitIdResult: (scanUnitIDResult) => - set((state) => { - state.scanUnitIdResults.unshift(scanUnitIDResult) - while (state.scanUnitIdResults.length > 256) state.scanUnitIdResults.pop() - }), - clearScanUnitIdResults: () => - set((state) => { - state.scanUnitIdResults = [] - }), - // Scanning progress - scanProgress: 0, - setScanProgress: (scanProgress) => - set((state) => { - state.scanProgress = scanProgress - }), + setPollRate: (pollRate) => setRegisterConfigField(set, get, 'pollRate', pollRate), + setTimeout: (timeout) => setRegisterConfigField(set, get, 'timeout', timeout), // Serial port discovery serialPorts: [], @@ -521,7 +588,9 @@ export const useClientZustand = create< const clientZustand = useClientZustand.getState() /** - * The window this module may call main from, the question its two siblings ask. + * The window this module may call main from, the question its siblings ask. + * `layout.zustand`, `server.zustand` and `data.zustand` each read it too, and + * `data.zustand`'s tail is the other one that calls main from module scope. * * `App.tsx` imports `containers/Client` statically and `Client.tsx:11` imports * this file, so `out/renderer/assets/` holds one js file and both windows @@ -533,9 +602,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. * - * 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. + * 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. Its `setState` still writes the shared key, for the same + * reason `init`'s does. */ const isServerWindow = window.api.isServerWindow @@ -551,43 +622,17 @@ if (repair) useClientZustand.setState({ ...repair.state, configReset: repair.res // Sync the main process state with the front end if (!isServerWindow) clientZustand.init() -// -// -// -// -// Listen to events to set the state - -// Client state, like polling, scanning, etc. -onEvent('client_state', (clientState) => { - clientStatePushed = true - const clientZustand = useClientZustand.getState() - clientZustand.setClientState(clientState) -}) - -// Transactions from the transation log -onEvent('transaction', (transaction) => { - const clientZustand = useClientZustand.getState() - clientZustand.addTransaction(transaction) -}) - -// Unit ID scanning results -onEvent('scan_unit_id_result', (scanUnitIDResult) => { - const clientZustand = useClientZustand.getState() - clientZustand.addScanUnitIdResult(scanUnitIDResult) -}) - -// Scan progress -onEvent('scan_progress', (scanProgress) => { - const clientZustand = useClientZustand.getState() - clientZustand.setScanProgress(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/client.zustand.types.ts b/src/renderer/src/context/client.zustand.types.ts index 824b92c5..4928a965 100644 --- a/src/renderer/src/context/client.zustand.types.ts +++ b/src/renderer/src/context/client.zustand.types.ts @@ -3,11 +3,8 @@ import { RegisterType, ModbusBaudRate, Parity, - ClientState, - Transaction, RegisterMapping, RegisterMapValue, - ScanUnitIDResult, RegisterMappingSchema, ConnectionConfigSchema, RegisterConfigSchema, @@ -34,14 +31,9 @@ export const PersistedClientZustandSchema = z.object({ export type PersistedClientZustand = z.infer<typeof PersistedClientZustandSchema> export type ClientZustand = { - transactions: Transaction[] - clientState: ClientState ready: boolean readConfiguration: boolean valid: Valid - lastSuccessfulTransactionMillis: number | null - scanUnitIdResults: ScanUnitIDResult[] - scanProgress: number setName: (name: string) => void // Register mapping setRegisterMapping: <K extends keyof RegisterMapValue, V extends RegisterMapValue[K]>( @@ -49,50 +41,47 @@ export type ClientZustand = { key: K, value: V ) => void - replaceRegisterMapping: (registerMapping: RegisterMapping) => void - clearRegisterMapping: () => 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<boolean> + clearRegisterMapping: () => Promise<boolean> /** 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. */ acknowledgeConfigReset: () => void - // Transaction log - addTransaction: (transactions: Transaction) => void - clearTransactions: () => void // Config - init: () => Promise<void> - // State - setClientState: (clientState: ClientState) => void - // Configuration actions, each one waiting on the boundary before it writes - setProtocol: (protocol: Protocol) => Promise<void> + init: () => void + // Configuration actions, each one waiting on the boundary before it writes. + // Each answers whether main holds the value afterwards: `false` on every + // refusal, `true` once it is written or when it was already there. + setProtocol: (protocol: Protocol) => Promise<boolean> setPort: AsyncMaskSetFn setHost: AsyncMaskSetFn setUnitId: AsyncMaskSetFn setAddress: AsyncMaskSetFn setLength: AsyncMaskSetFn - setType: (type: RegisterType) => Promise<void> + setType: (type: RegisterType) => Promise<boolean> setCom: AsyncMaskSetFn - setBaudRate: (baudRate: ModbusBaudRate) => Promise<void> - setParity: (parity: Parity) => Promise<void> - setDataBits: (dataBits: DataBits) => Promise<void> - setStopBits: (stopBits: StopBits) => Promise<void> - setPollRate: (pollRate: number) => Promise<void> - setTimeout: (timeout: number) => Promise<void> - setLittleEndian: (littleEndian: boolean) => Promise<void> + setBaudRate: (baudRate: ModbusBaudRate) => Promise<boolean> + setParity: (parity: Parity) => Promise<boolean> + setDataBits: (dataBits: DataBits) => Promise<boolean> + setStopBits: (stopBits: StopBits) => Promise<boolean> + setPollRate: (pollRate: number) => Promise<boolean> + setTimeout: (timeout: number) => Promise<boolean> + setLittleEndian: (littleEndian: boolean) => Promise<boolean> // Layout configuration settings (i want them to be persistent) - setAddressBase: (addressBase: '0' | '1') => Promise<void> - setAdvancedMode: (advancedMode: boolean) => Promise<void> - setShow64BitValues: (show64BitValues: boolean) => Promise<void> - - // Transaction - setLastSuccessfulTransactionMillis: (value: number | null) => void - - // Unit ID Scannning - addScanUnitIdResult: (scanUnitIdResult: ScanUnitIDResult) => void - clearScanUnitIdResults: () => void - - // Scan progress - setScanProgress: (scanProgress: number) => void + setAddressBase: (addressBase: '0' | '1') => Promise<boolean> + setAdvancedMode: (advancedMode: boolean) => Promise<boolean> + setShow64BitValues: (show64BitValues: boolean) => Promise<boolean> // Read configuration setReadConfiguration: (readConfiguration: boolean) => void @@ -106,7 +95,12 @@ export type ClientZustand = { validateSerialPort: (portPath: string) => Promise<SerialPortValidationResult> } & PersistedClientZustand +export type ClientSet = (recipe: (state: ClientZustand) => void) => void + export type MaskSetFn<V extends string = string> = (value: V, valid?: boolean) => void /** A masked setter that waits on the backend before the value settles. */ -export type AsyncMaskSetFn<V extends string = string> = (value: V, valid?: boolean) => Promise<void> +export type AsyncMaskSetFn<V extends string = string> = ( + value: V, + valid?: boolean +) => Promise<boolean> diff --git a/src/renderer/src/context/clientUndo.ts b/src/renderer/src/context/clientUndo.ts new file mode 100644 index 00000000..a470e183 --- /dev/null +++ b/src/renderer/src/context/clientUndo.ts @@ -0,0 +1,186 @@ +import { isConnectionAddressGiven, isReadLengthGiven } from '@shared' +import { deepEqual } from 'fast-equals' +import { useClientZustand } from './client.zustand' +import { showMapping, useDataZustand } from './data.zustand' +import { replayTop, useUndoZustand } from './undo.zustand' +import { + clientFieldReaders, + clientFieldSteps, + clientStepKey, + pushStep +} from './undo.zustand.helpers' +import { + ClientConfiguration, + ClientConfigurationStep, + ClientField, + ClientFieldStep, + ClientFieldStepOf, + ClientFieldValues, + ClientMappingStep, + ClientUndoStep, + UndoOutcome, + UndoRefusal, + UndoStack +} 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<boolean> +} = { + 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) +} + +/** + * 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<ClientField> = new Set<ClientField>([ + '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. + * + * 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 <Field extends ClientField>( + step: ClientFieldStepOf<Field> +): Promise<ClientFieldStep | UndoRefusal | undefined> => { + 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) + 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<ClientMappingStep | undefined> => { + 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<ClientConfigurationStep | undefined> => { + 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<ClientUndoStep | UndoRefusal | undefined> => { + switch (step.kind) { + case 'field': + return replayField(step) + case 'mapping': + return replayMapping(step) + case 'configuration': + return replayConfiguration(step) + } +} + +const clientStack = { + read: (): UndoStack<ClientUndoStep> => useUndoZustand.getState().client, + write: (stack: UndoStack<ClientUndoStep>): void => useUndoZustand.getState().setClient(stack) +} + +const move = (direction: 'undo' | 'redo'): Promise<UndoOutcome> => + replayTop(clientStack, direction, replay) + +export const undoClient = (): Promise<UndoOutcome> => move('undo') +export const redoClient = (): Promise<UndoOutcome> => 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<void>): Promise<void> => { + 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/data.zustand.ts b/src/renderer/src/context/data.zustand.ts index 715ff548..f0a1ac4a 100644 --- a/src/renderer/src/context/data.zustand.ts +++ b/src/renderer/src/context/data.zustand.ts @@ -3,10 +3,42 @@ 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 this module for +// `showMapping` and for the store its guards read `connectState` out of. Both +// sides hold because every name either side reaches across the cycle is +// reached from inside a function body, which runs after both modules have +// evaluated. Neither tail may name one: `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' +import { + ClientState, + RegisterData, + ScanUnitIDResult, + Transaction, + defaultClientState, + dummyWords +} from '@shared' +/** + * What main pushes about the client, held for as long as the window lives. + * + * Six of the ten `EVENTS_TO_RENDERER` are the client's and all six land here. + * The other four drive a server, the windows or the snackbar. + * + * Nothing here is persisted, and that is the point. zustand's persist wraps + * `setState` and serializes the whole partialized state on every call with no + * debounce, so a field written at event rate inside `client.zustand` paid for + * the register mapping it keeps on every write. Measured in the app's own + * 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. 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) => ({ // Register data @@ -24,6 +56,53 @@ export const useDataZustand = create<DataZustand, [['zustand/mutative', never]]> setAddressGroups: (groups) => set((state) => { state.addressGroups = groups + }), + + // State + clientState: { ...defaultClientState }, + setClientState: (clientState) => + set((state) => { + state.clientState = clientState + }), + + // Transaction log + transactions: [], + addTransactions: (transactions) => + set((state) => { + state.transactions.unshift(...[...transactions].reverse()) + while (state.transactions.length > 1000) state.transactions.pop() + }), + clearTransactions: () => { + pendingTransactions.drop() + set((state) => { + state.transactions = [] + }) + }, + lastSuccessfulTransactionMillis: null, + setLastSuccessfulTransactionMillis: (value) => + set((state) => { + state.lastSuccessfulTransactionMillis = value + }), + + // Unit ID scanning + scanUnitIdResults: [], + addScanUnitIdResults: (scanUnitIDResults) => + set((state) => { + state.scanUnitIdResults.unshift(...[...scanUnitIDResults].reverse()) + while (state.scanUnitIdResults.length > 256) state.scanUnitIdResults.pop() + }), + clearScanUnitIdResults: () => { + pendingUnitIdResults.drop() + set((state) => { + state.scanUnitIdResults = [] + }) + }, + + // Scan progress + scanProgress: 0, + setScanProgress: (scanProgress) => + set((state) => { + state.scanProgress = scanProgress }) })) ) @@ -34,8 +113,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 = { @@ -83,12 +162,84 @@ export const dropPendingScanRows = (): void => { pendingScanRows = [] } +/** + * Events that arrive one per request, held back and written on the same timer. + * + * 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. + */ +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 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 + +/** + * Write the state main answered with, unless a push has landed since the ask. + * + * A push that arrives while the answer is in flight is the newer of the two + * and keeps its value. + */ +const adoptAnsweredClientState = (clientState: ClientState): void => { + if (clientStatePushed) return + useDataZustand.getState().setClientState(clientState) +} + +/** + * Ask main what the client is doing, because a push says only that it changed. + * + * Main pushes `client_state` on a change, so a window opened after the last + * one starts on the literal above: on macos the app outlives its windows, and + * the window that comes back showed Connect over a client that was connected + * and polling. The ask sits here rather than in `client.zustand`'s `init` + * because these two modules import each other, and a name reached across that + * cycle while the other half is still evaluating is a name in its temporal + * dead zone. It threw into `init`'s catch, which reported nothing. + * + * The guard is the one `init` carries: `client_state` is about the one client + * main holds, and the split out server window shows none of it. + */ +if (!window.api.isServerWindow) { + window.api + .getClientState() + .then(adoptAnsweredClientState) + .catch((error) => console.error('The client state main holds was not read:', error)) +} + // Data read from the registers onEvent('register_data', (registerData) => { const dataZustand = useDataZustand.getState() - const clientZustand = useClientZustand.getState() - if (clientZustand.clientState.scanningRegisters) { + if (dataZustand.clientState.scanningRegisters) { pendingScanRows.push(...registerData) if (!scanFlushTimeout) scanFlushTimeout = setTimeout(flushScanRows, SCAN_FLUSH_MS) } else { @@ -97,10 +248,33 @@ onEvent('register_data', (registerData) => { dataZustand.setRegisterData(registerData) } - clientZustand.setLastSuccessfulTransactionMillis(DateTime.now().toMillis()) + dataZustand.setLastSuccessfulTransactionMillis(DateTime.now().toMillis()) }) onEvent('address_groups', (addressGroups) => { const dataZustand = useDataZustand.getState() dataZustand.setAddressGroups(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() + if (!clientState.scanningUnitIds) pendingUnitIdResults.flush() + const dataZustand = useDataZustand.getState() + dataZustand.setClientState(clientState) +}) + +// Transactions from the transation log +onEvent('transaction', (transaction) => pendingTransactions.push(transaction)) + +// Unit ID scanning results +onEvent('scan_unit_id_result', (scanUnitIDResult) => pendingUnitIdResults.push(scanUnitIDResult)) + +// Scan progress +onEvent('scan_progress', (scanProgress) => { + const dataZustand = useDataZustand.getState() + dataZustand.setScanProgress(scanProgress) +}) diff --git a/src/renderer/src/context/data.zustand.types.ts b/src/renderer/src/context/data.zustand.types.ts index 66f72d84..9eeba19e 100644 --- a/src/renderer/src/context/data.zustand.types.ts +++ b/src/renderer/src/context/data.zustand.types.ts @@ -1,4 +1,4 @@ -import { AddressGroup, RegisterData } from '@shared' +import { AddressGroup, ClientState, RegisterData, ScanUnitIDResult, Transaction } from '@shared' export interface DataZustand { // Register data @@ -7,4 +7,24 @@ export interface DataZustand { appendRegisterData: (data: RegisterData[]) => void addressGroups: AddressGroup[] setAddressGroups: (groups: AddressGroup[]) => void + + // State + clientState: ClientState + setClientState: (clientState: ClientState) => void + + // Transaction log + transactions: Transaction[] + addTransactions: (transactions: Transaction[]) => void + clearTransactions: () => void + lastSuccessfulTransactionMillis: number | null + setLastSuccessfulTransactionMillis: (value: number | null) => void + + // Unit ID scanning + scanUnitIdResults: ScanUnitIDResult[] + addScanUnitIdResults: (scanUnitIdResults: ScanUnitIDResult[]) => void + clearScanUnitIdResults: () => void + + // Scan progress + scanProgress: number + setScanProgress: (scanProgress: number) => void } diff --git a/src/renderer/src/context/layout.zustand.ts b/src/renderer/src/context/layout.zustand.ts index 8f8cea5d..9e4f7742 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<LayoutZustand, [['zustand/mutative', never]]>( - mutative((set, get) => ({ + mutative((set) => ({ showLog: false, version: '', setVersion: (version) => @@ -19,11 +19,11 @@ export const useLayoutZustand = create<LayoutZustand, [['zustand/mutative', neve showGridWhileScanning: true, toggleShowGridWhileScanning: () => set((state) => { - state.showGridWhileScanning = !get().showGridWhileScanning + state.showGridWhileScanning = !state.showGridWhileScanning }), toggleShowClientRawValues: () => set((state) => { - state.showClientRawValues = !get().showClientRawValues + state.showClientRawValues = !state.showClientRawValues }), setHideHomeButton: (hide) => @@ -32,12 +32,7 @@ export const useLayoutZustand = create<LayoutZustand, [['zustand/mutative', neve }), toggleShowLog: () => set((state) => { - const currentState = get() - state.showLog = !currentState.showLog - }), - setShowLog: (show: boolean) => - set((state) => { - state.showLog = show + state.showLog = !state.showLog }), appType: isServerWindow ? 'server' : undefined, setAppType: (appType: AppType | undefined) => @@ -47,6 +42,25 @@ export const useLayoutZustand = create<LayoutZustand, [['zustand/mutative', neve })) ) +/** + * The app version, fetched where the field it fills lives. + * + * It was fetched from `client.zustand`'s module tail, the one call that tail + * made from the window it is otherwise guarded out of. Both windows need the + * answer: `Home.tsx` prints it, and `SaveButton` and `OpenSaveClear` write it + * into every config saved, the second of those from the split out server + * window. `get_app_version` asks `app` rather than the client, so no window is + * refused it. + * + * A rejected invoke leaves `version` at its empty default, which is a + * `modbuxVersion` of `''` in a saved config and a Home screen with no number. + * Nothing the user can act on, so this says it happened and nothing more. + */ +window.api + .getAppVersion() + .then((version) => 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 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/repairPersistedStore.ts b/src/renderer/src/context/repairPersistedStore.ts index dea502e7..8d1bfb8c 100644 --- a/src/renderer/src/context/repairPersistedStore.ts +++ b/src/renderer/src/context/repairPersistedStore.ts @@ -12,6 +12,25 @@ interface PersistedAt { /** What the blob on disk carried, which only `migrate` is offered. */ persistedVersion: number | undefined currentVersion: number + /** + * The state to read, where a caller repaired part of it first. + * + * `persist` wraps `setState` with its own `setItem`, so a caller that wrote + * its repair through the store would have overwritten the key before + * `keepCorrupt` below copied it, and the copy meant for a bug report would + * hold the repaired blob. Measured: with the write first, the register that + * failed was in neither the key nor the copy. + */ + state?: unknown + /** + * Fields a caller repaired itself, which are reported and kept. + * + * The server store reads each server back on its own before calling here, + * because `servers` is one field holding every one of them. Those fields + * parse by the time the walk below reaches them, so without this the warn, + * the copy and the message would all say nothing was lost. + */ + alsoReset?: string[] } /** @@ -38,17 +57,21 @@ interface PersistedAt { export const repairPersistedStore = <Shape extends z.ZodRawShape>( store: { getState: () => unknown; getInitialState: () => object }, schema: z.ZodObject<Shape>, - { storageKey, persistedVersion, currentVersion }: PersistedAt + { storageKey, persistedVersion, currentVersion, state, alsoReset = [] }: PersistedAt ): StoreRepair<z.infer<z.ZodObject<Shape>>> | undefined => { + const savedByNewerVersion = persistedVersion !== undefined && persistedVersion > currentVersion const repair = repairPersisted( schema, - store.getState(), + state ?? store.getState(), store.getInitialState(), - persistedVersion !== undefined && persistedVersion > currentVersion + savedByNewerVersion ) - if (repair.reset === undefined) return undefined - console.warn(`${storageKey} repaired`, repair.reset) + const fields = [...(repair.reset?.fields ?? []), ...alsoReset] + if (fields.length === 0 && !savedByNewerVersion) return undefined + const reset = { fields, savedByNewerVersion } + + console.warn(`${storageKey} repaired`, reset) keepCorrupt(localStorage, storageKey) - return { state: repair.state, reset: repair.reset } + return { state: repair.state, reset } } diff --git a/src/renderer/src/context/serialPorts.ts b/src/renderer/src/context/serialPorts.ts index 7c896738..af8f806f 100644 --- a/src/renderer/src/context/serialPorts.ts +++ b/src/renderer/src/context/serialPorts.ts @@ -11,7 +11,7 @@ export interface SerialPortsSlice { * * Both stores hold one of these, and both refresh buttons are disabled while * the flag is set, so a flag left true is a button that never comes back. - * `listSerialPorts` in `modbusClient` catches its own failures and answers + * `listSerialPorts` in `modbusClient/serialPorts.ts` catches its own failures and answers * `[]`, so today only a change in main reaches the `finally`. */ export const loadSerialPorts = async <State extends SerialPortsSlice>( diff --git a/src/renderer/src/context/server.zustand.helpers.ts b/src/renderer/src/context/server.zustand.helpers.ts index a19e439f..4140aff8 100644 --- a/src/renderer/src/context/server.zustand.helpers.ts +++ b/src/renderer/src/context/server.zustand.helpers.ts @@ -1,55 +1,81 @@ import { checkHasConfig, + DEFAULT_MODBUS_PORT, defaultSerialPortOptions, + EncodableDataType, + findAvailablePort, getUsedAddresses, + holdsExact64Bits, MAIN_SERVER_UUID, + PortSchema, RegisterParams, + repairPersisted, + registerWidth, SerialPortOptions, ServerRegisters, + ServerRegistersPerUnit, + ServerRegisterValue, ServerSerialConfig, + toExact64Bits, UnitIdString } from '@shared' +import { round } from 'lodash' import { - DefinedServerRegisters, + PersistedServer, + PersistedServerSchema, ServerSet, ServerZustand, UsedAddresses } from './server.zustand.types' -export const extractUnitIdsWithData = (serverRegisters: DefinedServerRegisters): UnitIdString[] => { +export const extractUnitIdsWithData = (serverRegisters: ServerRegistersPerUnit): 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 } -export const syncBoolsWithBackend = async ( - serverRegisters: DefinedServerRegisters, - unitId: UnitIdString, - syncUuid: string -): Promise<void> => { +/** + * The two bool arrays main takes, built from what a unit holds. + * + * `syncBools` replaces what the unit holds rather than adding to it: + * `setBoolsFromArray` clears the map first, so an array shorter than 65536 + * erases every address past its end. Both callers build the pair the same way; + * `resetBools` then blanks the one it is clearing. + */ +export const boolArraysOf = ( + serverRegisters: ServerRegistersPerUnit, + 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: ServerRegistersPerUnit, + unitId: UnitIdString, + syncUuid: string +): Promise<void> => { await window.api.syncBools({ uuid: syncUuid, unitId, - coils, - discrete_inputs: discreteInputs + ...boolArraysOf(serverRegisters, unitId) }) } export const syncRegistersWithBackend = async ( - serverRegisters: DefinedServerRegisters, + serverRegisters: ServerRegistersPerUnit, unitId: UnitIdString, uuid: string ): Promise<{ @@ -58,10 +84,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, @@ -84,36 +110,93 @@ const getDefaultUsedAddresses = (): UsedAddresses => ({ holding_registers: [] }) -/** The used-address map for a uuid, made on the first write into it. */ -const usedAddressesOf = ( - state: ServerZustand, - uuid: string -): Partial<Record<string, UsedAddresses>> => (state.usedAddresses[uuid] ??= {}) +/** + * An empty server on the registered port. + * + * Two callers with one question between them: `createServer` starts a server + * here, and `repairServers` falls back to a field of this one for a field it + * could not read. A repaired port of 502 that the main server already holds + * costs nothing: `TcpServers.create` probes the socket, walks up from there + * and answers the port it bound, which is what the store writes. + */ +export const getDefaultServer = (): PersistedServer => ({ + port: String(DEFAULT_MODBUS_PORT), + unitId: '0', + name: undefined, + littleEndian: false, + registers: {}, + usedAddresses: {} +}) +/** + * The used addresses of one unit, made on the first write into it. + * + * Nothing where the uuid names no server, for the reason `unitRegisters` + * gives. + */ export const unitUsedAddresses = ( state: ServerZustand, uuid: string, unitId: UnitIdString -): UsedAddresses => (usedAddressesOf(state, uuid)[unitId] ??= getDefaultUsedAddresses()) +): UsedAddresses | undefined => { + const server = state.servers[uuid] + if (!server) return undefined + return (server.usedAddresses[unitId] ??= getDefaultUsedAddresses()) +} /** - * Where an empty unit is made, and the only place that makes one. + * The register map of one unit, made on the first write into it. * - * `clean` gives a uuid two empty maps rather than an entry for each of the 256 + * `clean` gives a server an empty map rather than an entry for each of the 256 * unit ids, so a unit gets its entry the first time something is written into * it. A read takes the optional chain instead: a unit nobody has written to * holds nothing, and asking what is in it should not create it. + * + * Nothing where the uuid names no server. `setBool` takes a uuid off a + * `register_value` event, and main sends those from inside the call the store + * is waiting on, so one can arrive for a server `deleteServer` has just taken + * out. Creating the entry would put that server back with no port, no name and + * no listener. */ -export const serverRegistersOf = ( - state: ServerZustand, - uuid: string -): Partial<Record<string, ServerRegisters>> => (state.serverRegisters[uuid] ??= {}) - export const unitRegisters = ( state: ServerZustand, uuid: string, unitId: UnitIdString -): ServerRegisters => (serverRegistersOf(state, uuid)[unitId] ??= getDefaultServerRegisters()) +): ServerRegisters | undefined => { + const server = state.servers[uuid] + if (!server) return undefined + return (server.registers[unitId] ??= getDefaultServerRegisters()) +} + +/** + * Reads each server back through its own schema, field by field. + * + * `repairPersisted` works a top level field at a time, and `servers` is one + * field holding every server. Read whole, one register the schema refuses + * would cost every port, every name and every register in the store. Read this + * way it costs the registers of the one server that carries it. + * + * Nothing where `servers` is not a record of its own, because there are no + * entries to walk and an empty one written back would parse. The caller leaves + * the field where it is and `repairPersisted` resets it to the one main + * server. An array goes the same way: its indices are not uuids. + */ +export const repairServers = ( + servers: unknown +): { servers: Record<string, PersistedServer>; fields: string[] } | undefined => { + if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return undefined + + const repaired: Record<string, PersistedServer> = {} + const fields = new Set<string>() + + for (const [uuid, server] of Object.entries(servers)) { + const repair = repairPersisted(PersistedServerSchema, server, getDefaultServer()) + repaired[uuid] = repair.state + for (const field of repair.reset?.fields ?? []) fields.add(field) + } + + return { servers: repaired, fields: [...fields] } +} /** * Hands main everything a uuid holds, then marks it ready. @@ -127,23 +210,22 @@ export const syncUuidToBackend = async ( get: () => ServerZustand, syncUuid: string ): Promise<void> => { - const serverRegisters = get().serverRegisters[syncUuid] ?? {} - set((state) => { - state.serverRegisters[syncUuid] ??= {} - }) + const server = get().servers[syncUuid] + if (!server) return await window.api.setServerEndianness({ uuid: syncUuid, - littleEndian: !!get().littleEndian[syncUuid] + littleEndian: server.littleEndian }) - for (const unitId of extractUnitIdsWithData(serverRegisters)) { - await syncBoolsWithBackend(serverRegisters, unitId, syncUuid) + for (const unitId of extractUnitIdsWithData(server.registers)) { + await syncBoolsWithBackend(server.registers, unitId, syncUuid) const { inputRegisterRegisterValues, holdingRegisterRegisterValues } = - await syncRegistersWithBackend(serverRegisters, unitId, syncUuid) + await syncRegistersWithBackend(server.registers, unitId, syncUuid) set((state) => { const addresses = unitUsedAddresses(state, syncUuid, unitId) + if (!addresses) return addresses['input_registers'] = getUsedAddresses(inputRegisterRegisterValues) addresses['holding_registers'] = getUsedAddresses(holdingRegisterRegisterValues) }) @@ -154,6 +236,32 @@ export const syncUuidToBackend = async ( }) } +/** + * The port `init` asks main to open a uuid on. + * + * A port is stored as the string the field holds, so a hand-edited key can + * carry `''` or `'http'`, and `Number` of either is refused by `PortSchema`. + * Opened on that, the server would stand in the toggle group with an empty + * label, no listener and `ready` false. + * + * The walk is over what the store has handed out rather than from 502, because + * the uuids after this one have no listener yet: main probes sockets, so it + * would hand this server the port the next one is about to ask for, and that + * one would then be moved on. Nothing free between 502 and 10502 leaves the + * registered port and main's own walk, which is a key naming ten thousand + * servers. + */ +export const portToOpen = (uuid: string, servers: Record<string, PersistedServer>): number => { + const storedPort = Number(servers[uuid]?.port) + if (PortSchema.safeParse(storedPort).success) return storedPort + + const taken = Object.values(servers) + .map((server) => Number(server.port)) + .filter((port) => PortSchema.safeParse(port).success) + + return findAvailablePort(taken) ?? DEFAULT_MODBUS_PORT +} + export const getDefaultSerialConfig = (): ServerSerialConfig => ({ com: '', options: { ...defaultSerialPortOptions } @@ -252,3 +360,131 @@ export class ServerDelayedSetter<T, P> { return this._pending.get(cacheKey) } } + +/** + * One word written over a stored composite, and the value that leaves. + * + * The inverse of `createRegisters`: that one lays a value out over its + * registers, this one reads a value back out after one of those registers has + * been overwritten. It knows nothing about uuids, units or batching, which is + * `applyRegisterValue`'s half. + * + * `undefined` is "the entry holds no composite", which aborts rather than + * merging the word into a composite of zero: the other three registers are + * what zero would cost. + */ +export const foldWordIntoComposite = ({ + currentValue, + dataType, + littleEndian, + offsetRegisters, + word +}: { + currentValue: ServerRegisterValue | bigint + dataType: EncodableDataType + littleEndian: boolean + offsetRegisters: number + word: number +}): { composite: number | bigint; value: ServerRegisterValue } | undefined => { + const byteLength = registerWidth(dataType) * 2 + const view = new DataView(new ArrayBuffer(byteLength)) + + // 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 + // `word`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, so neither carries a + // `default`. `newComposite` is declared without a value, which is what makes + // a twelfth `EncodableDataType` 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. + case 'int64': { + const composite = toExact64Bits(currentValue) + if (composite === undefined) return undefined + view.setBigInt64(0, composite, littleEndian) + break + } + case 'uint64': + case 'datetime': { + const composite = toExact64Bits(currentValue) + if (composite === undefined) return undefined + view.setBigUint64(0, composite, littleEndian) + break + } + case 'double': + view.setFloat64(0, Number(currentValue) || 0, littleEndian) + break + } + + // Overwrite just the one 16-bit register that the client wrote + view.setUint16(offsetRegisters * 2, word, littleEndian) + + 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 + // `Number` carries 53 of them: four words of 0xFFFF came out + // 18446744073709552000 rather than 18446744073709551615, and the next single + // word write read that back through `BigInt`, which `setBigUint64` took + // modulo 2 ** 64, leaving the entry holding the low word alone. + // + // The cache above the caller held the exact composite already, so the loss + // only showed after a flush cleared it and the fallback was the entry. + const value: ServerRegisterValue = holdsExact64Bits(dataType) + ? newComposite.toString() + : round(Number(newComposite), ['float', 'double'].includes(dataType) ? 3 : 0) + + return { composite: newComposite, value } +} diff --git a/src/renderer/src/context/server.zustand.ts b/src/renderer/src/context/server.zustand.ts index c15abe42..1b596df4 100644 --- a/src/renderer/src/context/server.zustand.ts +++ b/src/renderer/src/context/server.zustand.ts @@ -10,6 +10,7 @@ import { import { mutative } from 'zustand-mutative' import { persist } from 'zustand/middleware' import { + DEFAULT_MODBUS_PORT, getUsedAddresses, MAIN_SERVER_UUID, ServerRegisterEntry, @@ -20,38 +21,100 @@ import { CURRENT_SERVER_ZUSTAND_VERSION, SERVER_ZUSTAND_STORAGE_KEY, registerWidth, + MAX_NUMBER_REGISTER_WIDTH, ModbusBaudRate, RegisterType, RegisterValue, - ServerRegisterValue, - holdsExact64Bits, - toExact64Bits + ServerRegisters, + BooleanRegisters, + NumberRegisters } from '@shared' import { onEvent } from '@renderer/events' -import { round } from 'lodash' +import { enqueueSnackbar } from 'notistack' import { + boolArraysOf, extractUnitIdsWithData, - serverRegistersOf, + foldWordIntoComposite, + getDefaultServer, + repairServers, syncRegistersWithBackend, syncUuidToBackend, unitRegisters, unitUsedAddresses, getDefaultSerialConfig, + portToOpen, restartRtuServer, setSerialOption, ServerDelayedSetter } from './server.zustand.helpers' import { loadSerialPorts } from './serialPorts' import { repairPersistedStore } from './repairPersistedStore' +import { useUndoZustand } from './undo.zustand' +import { unitStructure } from './undo.zustand.helpers' +import { deepEqual } from 'fast-equals' /** - * 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 +/** + * 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]] @@ -66,13 +129,7 @@ export const useServerZustand = create< ready: { [MAIN_SERVER_UUID]: false }, initialized: false, selectedUuid: MAIN_SERVER_UUID, - uuids: [MAIN_SERVER_UUID], - port: { [MAIN_SERVER_UUID]: '502' }, - unitId: { [MAIN_SERVER_UUID]: undefined }, - serverRegisters: { [MAIN_SERVER_UUID]: undefined }, - usedAddresses: { [MAIN_SERVER_UUID]: undefined }, - name: { [MAIN_SERVER_UUID]: undefined }, - littleEndian: { [MAIN_SERVER_UUID]: false }, + servers: { [MAIN_SERVER_UUID]: getDefaultServer() }, serverMode: 'tcp' as const, serialConfig: getDefaultSerialConfig(), serialPorts: [], @@ -80,64 +137,45 @@ export const useServerZustand = create< rtuServerActive: false, clean: (uuid) => set((state) => { - state.unitId[uuid] = '0' - state.serverRegisters[uuid] = {} - state.usedAddresses[uuid] = {} + const server = state.servers[uuid] + if (!server) return + server.unitId = '0' + server.registers = {} + server.usedAddresses = {} }), - /** - * Remove all state entries for uuids that are not present in the uuids array. - * This prevents memory leaks and UI bugs from stale state. - */ - cleanOrphanedServerState: () => { - set((state) => { - const uuids = state.uuids - Object.keys(state.port).forEach((uuid) => { - if (!uuids.includes(uuid)) { - delete state.port[uuid] - delete state.unitId[uuid] - delete state.serverRegisters[uuid] - delete state.usedAddresses[uuid] - delete state.name[uuid] - delete state.littleEndian[uuid] - } - }) - }) - }, createServer: async (params) => { // Only update port from backend response, never from input. A refused // payload answers undefined, and writing that would put the string // "undefined" in the port field. const actualPort = await window.api.createServer(params) - if (actualPort === undefined) return + if (actualPort === undefined) return false const { uuid } = params set((state) => { - state.port[uuid] = String(actualPort) + state.servers[uuid] = { ...getDefaultServer(), port: String(actualPort) } state.ready[uuid] = true - state.uuids.push(uuid) state.selectedUuid = uuid }) - // `clean` writes through a `set` of its own, and a `set` that runs - // inside a recipe is discarded when that recipe finalises. It stood - // inside this one since 352e4df, so a new server got no unit map. - get().clean(uuid) - get().cleanOrphanedServerState() + // 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) => { - state.uuids = state.uuids.filter((u) => u !== 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 + delete state.servers[uuid] + delete state.ready[uuid] + // The delete button is off for the main server, so the record keeps + // at least that one and the selection lands on a server that is + // there. + const [firstRemaining = MAIN_SERVER_UUID] = Object.keys(state.servers) if (state.selectedUuid === uuid) state.selectedUuid = firstRemaining - delete state.port[uuid] - delete state.unitId[uuid] - delete state.serverRegisters[uuid] - delete state.usedAddresses[uuid] - delete state.littleEndian[uuid] }) - get().cleanOrphanedServerState() }, resetServer: async (uuid) => { await window.api.resetServer(uuid) @@ -148,41 +186,29 @@ export const useServerZustand = create< * store initialized once it has been through them all. * * `containers/Server.tsx` draws nothing until the flag is set, so a - * rejected invoke used to cost the whole server view rather than the one - * uuid it belonged to, on that launch and on every one after it. The uuid - * keeps `ready` false, which is what its three setters refuse on, and - * that is the whole cost now. `openServer` is where the catch sits, so - * the servers after it still get opened. + * rejected invoke that escaped here would cost the whole server view + * rather than the one uuid it belongs to, on that launch and on every one + * after it. The uuid keeps `ready` false, which is what its three setters + * refuse on, and that is the whole cost. `openServer` is where the catch + * sits, so the servers after it still get opened. */ 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 Object.keys(state.servers)) state.ready[readyUuid] = false }) const state = get() const mode = state.serverMode ?? 'tcp' - // Ensure every uuid has a unitId and littleEndian entry (for backward compatibility) - set((state) => { - for (const uuid of state.uuids) { - if (state.unitId[uuid] === undefined) { - state.unitId[uuid] = '0' - } - if (state.littleEndian[uuid] === undefined) { - state.littleEndian[uuid] = false // Default to Big-Endian - } - } - }) - /** * Opens one server and hands main what it holds, and answers whether * that got through. * - * The `catch` is per uuid, because that is the unit of the cost: it - * stood around the loop, and a refusal for the first uuid then meant - * `createServer` was never called for the second at all, so it had no - * listener on its port, none of its registers in main, and `ready` - * false with no message. + * The `catch` is per uuid, because that is the unit of the cost. + * Around the loop instead, a refusal for the first uuid would leave + * `createServer` uncalled for the second, so it would have no listener + * on its port, none of its registers in main, and `ready` false with no + * message. * * Empty, because this runs from module scope with nothing awaiting it * and a rejection there is an unhandled one. Main reports its own @@ -196,7 +222,8 @@ export const useServerZustand = create< if (actualPort === undefined) return set((state) => { - state.port[syncUuid] = String(actualPort) + const server = state.servers[syncUuid] + if (server) server.port = String(actualPort) }) await syncUuidToBackend(set, get, syncUuid) @@ -230,22 +257,20 @@ export const useServerZustand = create< }) } else { // TCP mode: existing flow - const uuidsToSync = uuid ? [uuid] : state.uuids + const uuidsToSync = uuid ? [uuid] : Object.keys(state.servers) for (const syncUuid of uuidsToSync) { - await openServer(syncUuid, Number(state.port[syncUuid])) + await openServer(syncUuid, portToOpen(syncUuid, get().servers)) } - if (state.uuids.length === 0) { - state.createServer({ port: 502, uuid: MAIN_SERVER_UUID }) + if (uuidsToSync.length === 0) { + state.createServer({ port: DEFAULT_MODBUS_PORT, uuid: MAIN_SERVER_UUID }) set((state) => { state.ready[MAIN_SERVER_UUID] = true }) } } - get().cleanOrphanedServerState() - set((state) => { state.initialized = true }) @@ -256,42 +281,71 @@ 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) => { - state.name[uuid] = name + 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) + if (!registers) return if (registers[registerType][address]) return registers[registerType][address] = { value: false } 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.serverRegisters[uuid]?.[unitId] + const registers = state.servers[uuid]?.registers[unitId] if (registers?.[registerType][address] === undefined) return delete registers[registerType][address] 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) => { - 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) + if (!registers) continue const entry = registers[registerType][address] if (entry) { entry.value = boolState @@ -306,39 +360,33 @@ 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.serverRegisters[uuid]?.[unitId]?.[registerType]?.[address] + 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 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 - }) + 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) set((state) => { - unitRegisters(state, uuid, unitId)[registerType] = {} + const registers = unitRegisters(state, uuid, unitId) + if (registers) registers[registerType] = {} }) const newBools: SyncBoolsParameters = { uuid, unitId, - coils: currentCoils, - discrete_inputs: currentDiscreteInputs, + ...bools, [registerType]: new Array(65536).fill(false) } window.api.syncBools(newBools) + recordUnit(get, uuid, unitId, before) }, addRegister: async (addParams) => { const { uuid, unitId, params } = addParams @@ -355,16 +403,20 @@ 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 set((state) => { const registers = unitRegisters(state, uuid, unitId) + const addresses = unitUsedAddresses(state, uuid, unitId) + if (!registers || !addresses) return registers[params.registerType][params.address] = { value: 0, params } - unitUsedAddresses(state, uuid, unitId)[params.registerType] = getUsedAddresses( - Object.values(registers[params.registerType]).map((r) => r.params) + addresses[params.registerType] = getUsedAddresses( + 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. @@ -382,21 +434,27 @@ export const useServerZustand = create< }, removeRegister: (removeParams) => { const { uuid, unitId, registerType, address } = removeParams + const before = get().servers[uuid]?.registers[unitId] set((state) => { - const registers = state.serverRegisters[uuid]?.[unitId] + const registers = state.servers[uuid]?.registers[unitId] if (registers === undefined) return + // After the check, because it makes the entry it answers with and a + // unit holding no registers has no addresses to mark. + const addresses = unitUsedAddresses(state, uuid, unitId) + if (addresses === undefined) return delete registers[registerType][address] - unitUsedAddresses(state, uuid, unitId)[registerType] = getUsedAddresses( - Object.values(registers[registerType]).map((r) => r.params) + addresses[registerType] = getUsedAddresses( + Object.values(registers[registerType]).map((register) => register.params) ) }) window.api.removeServerRegister(removeParams) + recordUnit(get, uuid, unitId, before) }, 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) @@ -409,7 +467,7 @@ export const useServerZustand = create< // its own: a bool entry is a value, and a register entry carries // the params that say what it is. There is nothing here to build // one from. - const entry = state.serverRegisters[uuid]?.[unitId]?.[registerType][address] + const entry = state.servers[uuid]?.registers[unitId]?.[registerType][address] if (!entry) continue entry.value = value } @@ -418,69 +476,171 @@ 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) => { - unitRegisters(state, uuid, unitId)[registerType] = {} - unitUsedAddresses(state, uuid, unitId)[registerType] = [] + const registers = unitRegisters(state, uuid, unitId) + const addresses = unitUsedAddresses(state, uuid, unitId) + if (!registers || !addresses) return + registers[registerType] = {} + addresses[registerType] = [] }) + recordUnit(get, uuid, unitId, before) }, - setPort: async (port) => { - const currentState = get() - const uuid = currentState.selectedUuid - if (!currentState.ready[uuid]) return - - const { port: currentPorts, selectedUuid } = get() + 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) + ) + } + }) - get().cleanOrphanedServerState() + // 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) }) + } - // Port cannot be already used for another server - const portAlreadyExists = Object.values(currentPorts).includes(port) - const portIsMyPort = port === currentPorts[selectedUuid] - if (portAlreadyExists && !portIsMyPort) return - if (portIsMyPort) return + 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 + if (!get().ready[uuid]) return false + + const servers = get().servers + if (port === servers[uuid]?.port) return true + + // The check stays here, because `setPort` has a second caller in + // `PrivilegedPortModal` and a rule written in the field is one that + // caller does not run. The message goes where the check is: the + // standalone `enqueueSnackbar` is what a store can reach, and both + // callers are a click, which is after `main.tsx` has built the + // provider. `repairPersistedStore` says why module scope cannot. + if (Object.values(servers).some((server) => server.port === port)) { + enqueueSnackbar({ + message: `Port ${port} is already used by another server`, + variant: 'error' + }) + return false + } // 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 + if (actualPort === undefined) return false set((state) => { - state.port[uuid] = String(actualPort) + 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) }, setUnitId: (unitId) => { const currentState = get() const uuid = currentState.selectedUuid if (!currentState.ready[uuid]) return set((state) => { - state.unitId[uuid] = unitId + const server = state.servers[uuid] + if (server) server.unitId = unitId }) }, setLittleEndian: async (littleEndian) => { const currentState = get() const uuid = currentState.selectedUuid - if (!currentState.ready[uuid]) return + if (!currentState.ready[uuid]) return false + const before = currentState.servers[uuid]?.littleEndian set((state) => { - state.littleEndian[uuid] = littleEndian + 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. await window.api.setServerEndianness({ uuid, littleEndian }) - const serverRegisters = currentState.serverRegisters[uuid] - if (!serverRegisters) return + const serverRegisters = currentState.servers[uuid]?.registers + if (!serverRegisters) return true const unitIdsWithData = extractUnitIdsWithData(serverRegisters) for (const unitId of unitIdsWithData) { await syncRegistersWithBackend(serverRegisters, unitId, uuid) } + return true }, replaceServerRegisters: (unitId, registers) => { const uuid = get().selectedUuid set((state) => { - serverRegistersOf(state, uuid)[unitId] = registers + const server = state.servers[uuid] + if (server) server.registers[unitId] = registers }) }, switchToRtu: async () => { @@ -514,12 +674,12 @@ export const useServerZustand = create< /** * A read, and nothing else. * - * Two of its callers ask from inside a running recipe, where the `set` - * this used to make was discarded and attempted again on the next call. - * `init` gives every uuid a unit id and `clean` gives a new one '0', so - * there was nothing left for it to repair. + * Two of its callers ask from inside a running recipe, where a `set` + * made here would be discarded and attempted again on the next call. + * Every server carries a unit id, so the fallback answers for a uuid + * that names no server rather than for a server with no unit id. */ - getUnitId: (uuid: string): UnitIdString => get().unitId[uuid] ?? '0' + getUnitId: (uuid: string): UnitIdString => get().servers[uuid]?.unitId ?? '0' })), { name: SERVER_ZUSTAND_STORAGE_KEY, @@ -529,14 +689,8 @@ export const useServerZustand = create< return migrateServerState(persistedState, version) as PersistedServerZustand }, partialize: (state) => ({ - name: state.name, - port: state.port, selectedUuid: state.selectedUuid, - serverRegisters: state.serverRegisters, - unitId: state.unitId, - usedAddresses: state.usedAddresses, - uuids: state.uuids, - littleEndian: state.littleEndian, + servers: state.servers, serverMode: state.serverMode, serialConfig: state.serialConfig }) @@ -546,14 +700,35 @@ 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. `MessageReceiver` selects `configReset`, so a reset + * found at the close is told the same way one found at load is. + */ +const repairFromTheKey = (): void => { + // Each server read back on its own first. `repairPersisted` works a top + // level field at a time and `servers` is the one field holding all of them, + // so read whole, one register the schema refuses would cost every port, + // every name and every register in the store. + // + // Handed over rather than written through the store, because a `setState` + // here persists, and the copy `keepCorrupt` takes is of the key. + const currentState = useServerZustand.getState() + const repairedServers = repairServers(currentState.servers) + + const repair = repairPersistedStore(useServerZustand, PersistedServerZustandSchema, { + storageKey: SERVER_ZUSTAND_STORAGE_KEY, + persistedVersion, + currentVersion: CURRENT_SERVER_ZUSTAND_VERSION, + state: repairedServers && { ...currentState, servers: repairedServers.servers }, + alsoReset: repairedServers?.fields + }) + if (repair) useServerZustand.setState({ ...repair.state, configReset: repair.reset }) +} -if (repair) useServerZustand.setState({ ...repair.state, configReset: repair.reset }) +repairFromTheKey() // Init server useServerZustand.getState().init() @@ -609,7 +784,7 @@ export const applyRegisterValue = (payload: RegisterValue): void => { // Handle coils and discrete inputs if (payload.registerType === 'coils' || payload.registerType === 'discrete_inputs') { const { uuid, unitId, registerType, address, value: booleanValue } = payload - const entry = serverZustand.serverRegisters[uuid]?.[unitId]?.[registerType]?.[address] + const entry = serverZustand.servers[uuid]?.registers[unitId]?.[registerType]?.[address] if (entry === undefined) return const cacheKey = batchKey(uuid, unitId, registerType, address) @@ -635,17 +810,24 @@ 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.servers[*].registers[*][registerType] this + // word belongs to. A register covers its own address and the ones after + // 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 - 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 - MAX_NUMBER_REGISTER_WIDTH; + candidateAddress-- + ) { + const candidateEntry = + serverZustand.servers[uuid]?.registers[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) { @@ -657,140 +839,29 @@ export const applyRegisterValue = (payload: RegisterValue): void => { const currentValue = delayedRegister.getValue(cacheKey) ?? serverRegisterEntry.value const { dataType } = serverRegisterEntry.params // Get littleEndian from global server state - const littleEndian = serverZustand.littleEndian[uuid] ?? false + const littleEndian = serverZustand.servers[uuid]?.littleEndian ?? false // Skip composite merging for types that don't use numeric compositing if (dataType === 'utf8') return // Strings: no composite value if (dataType === 'none') return // No data type, nothing to compose - // 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 + // 2) Determine which register-offset was written const offsetRegisters = address - entryAddress - if (offsetRegisters < 0 || offsetRegisters >= registersCount) { - // Out of range for this composite entry—ignore - return - } - - // 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 - } - // 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 - }) + if (offsetRegisters < 0 || offsetRegisters >= registerWidth(dataType)) { + // Out of range for this composite entry, ignore return } - // 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 - } - - // A decimal string where the composite fills 64 bits as an integer, because - // `Number` carries 53 of them: four words of 0xFFFF came out - // 18446744073709552000 rather than 18446744073709551615, and the next single - // word write read that back through `BigInt`, which `setBigUint64` took - // modulo 2 ** 64, leaving the entry holding the low word alone. - // - // The cache above held the exact composite already, so the loss only showed - // after a flush cleared it and the fallback was the entry. - const value: ServerRegisterValue = holdsExact64Bits(dataType) - ? newComposite.toString() - : round(Number(newComposite), ['float', 'double'].includes(dataType) ? 3 : 0) + // 3) Overwrite that one word in the stored composite and read the value back + const folded = foldWordIntoComposite({ + currentValue, + dataType, + littleEndian, + offsetRegisters, + word: numberValue + }) + if (!folded) return + const { composite: newComposite, value } = folded delayedRegister.setValue(cacheKey, newComposite) @@ -808,7 +879,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 }) }) @@ -833,8 +904,8 @@ let serverWindowOwnsTheKey = false /** * Mark the uuids the rehydrate introduced as ones main knows. * - * `ready` is not in `partialize`, so a rehydrate brings back `uuids`, `port` - * and `selectedUuid` and leaves this window's own `ready` where `init` left it. + * `ready` is not in `partialize`, so a rehydrate brings back `servers` and + * `selectedUuid` and leaves this window's own `ready` where `init` left it. * A server made in the split out window therefore came back with no entry, and * `setPort`, `setUnitId` and `setLittleEndian` each refuse on that with no * message, until a restart. The window that made it ran `createServer` for it, @@ -846,8 +917,8 @@ let serverWindowOwnsTheKey = false * `PrivilegedPortModal` runs its check on. */ const markRehydratedUuidsReady = (): void => { - const { uuids, ready } = useServerZustand.getState() - const introduced = uuids.filter((uuid) => ready[uuid] === undefined) + const { servers, ready } = useServerZustand.getState() + const introduced = Object.keys(servers).filter((uuid) => ready[uuid] === undefined) if (introduced.length === 0) return useServerZustand.setState({ ready: { ...ready, ...Object.fromEntries(introduced.map((uuid) => [uuid, true])) } @@ -872,8 +943,12 @@ onEvent('window_update', ({ server }) => { } if (!serverWindowOwnsTheKey) return serverWindowOwnsTheKey = false + persistedVersion = undefined 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/renderer/src/context/server.zustand.types.ts b/src/renderer/src/context/server.zustand.types.ts index 739f907b..2c0a7a17 100644 --- a/src/renderer/src/context/server.zustand.types.ts +++ b/src/renderer/src/context/server.zustand.types.ts @@ -25,18 +25,37 @@ import { z } from 'zod' export const UsedAddressesSchema = z.record(NumberRegistersSchema, z.array(z.number())) export type UsedAddresses = z.infer<typeof UsedAddressesSchema> +/** + * Everything one server holds, under the uuid that names it. + * + * Every field here belongs to that one server, so a uuid the store lists is a + * uuid with a port, a unit id and a register map beside it. There is no second + * list to agree with and nothing to sweep: taking the key out takes all six. + * + * `ready` is not in here. It says whether main has bound this uuid, and + * nothing on disk answers that. + */ +export const PersistedServerSchema = z.object({ + port: z.string(), + unitId: UnitIdStringSchema, + name: z.string().optional(), + littleEndian: z.boolean(), + registers: ServerRegistersPerUnitSchema, + usedAddresses: z.record(UnitIdStringSchema, UsedAddressesSchema) +}) +export type PersistedServer = z.infer<typeof PersistedServerSchema> + export const PersistedServerZustandSchema = z.object({ selectedUuid: z.string(), - uuids: z.array(z.string()), - serverRegisters: z.record(z.string(), z.union([ServerRegistersPerUnitSchema, z.undefined()])), - usedAddresses: z.record( - z.string(), - z.union([z.record(UnitIdStringSchema, UsedAddressesSchema), z.undefined()]) - ), - port: z.record(z.string(), z.string()), - unitId: z.record(z.string(), z.union([UnitIdStringSchema, z.undefined()])), - name: z.record(z.string(), z.string().optional()), - littleEndian: z.record(z.string(), z.boolean()), + /** + * The servers, keyed by uuid, in the order they were made. + * + * `SelectServer` draws its toggle group off `Object.keys`, which walks a key + * that is not an array index in insertion order. Modbux writes `v4()` and + * `MAIN_SERVER_UUID`, and both carry dashes. A hand-edited key of `'0'` would + * be drawn first whatever it was written after, and that is the whole cost. + */ + servers: z.record(z.string(), PersistedServerSchema), serverMode: ServerModeSchema.optional(), serialConfig: ServerSerialConfigSchema.optional() }) @@ -78,28 +97,26 @@ export type ServerZustand = { /** * Whether `init` has run to its end, whatever it managed. * - * `containers/Server.tsx` fades the whole server view in on this. It used to - * read `ready`, which is per uuid and written only on a sync that got - * through, so a rejected invoke anywhere in `init`'s loop left the view blank - * on that launch and on every one after it, with nothing on screen to clear - * the config with. The two questions are separate: this one is about the - * store, `ready` is about a server. + * `containers/Server.tsx` fades the whole server view in on this rather than + * on `ready`, which is per uuid and written only on a sync that got through: + * a rejected invoke anywhere in `init`'s loop would leave the view blank on + * that launch and on every one after it, with nothing on screen to clear the + * config with. The two questions are separate: this one is about the store, + * `ready` is about a server. */ initialized: boolean + /** Empties a server's registers and puts it back on unit 0. */ clean: (uuid: string) => void - /** - * Remove all state entries for uuids that are not present in the uuids array. - * This prevents memory leaks and UI bugs from stale state. - */ - cleanOrphanedServerState: () => void setSelectedUuid: (uuid: string) => void - createServer: (params: CreateServerParams) => Promise<void> + createServer: (params: CreateServerParams) => Promise<boolean> deleteServer: (uuid: string) => Promise<void> /** Empties a uuid on both sides: main's data and generators, then `clean`. */ resetServer: (uuid: string) => Promise<void> init: (uuid?: string) => Promise<void> - addBool: (type: BooleanRegisters, address: number) => void - removeBool: (type: BooleanRegisters, address: number) => void + 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<SetBoolParameters>) => void setBoolComment: (type: BooleanRegisters, address: number, comment: string | undefined) => void resetBools: (type: BooleanRegisters) => void @@ -107,11 +124,29 @@ export type ServerZustand = { removeRegister: (params: RemoveRegisterParams) => void setRegisterValue: (params: SetRegisterValueParameters | Array<SetRegisterValueParameters>) => 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<void> + /** + * 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<void> // 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` + // answers whether a server stands, on whichever port main found free, + // `setLittleEndian` whether the server was ready, and `addBool` and + // `removeBool` whether they changed anything. setPort: AsyncMaskSetFn setUnitId: MaskSetFn<UnitIdString> - setLittleEndian: (value: boolean) => Promise<void> + setLittleEndian: (value: boolean) => Promise<boolean> // Replace replaceServerRegisters: (unitId: UnitIdString, registers: ServerRegisters) => void setName: (name: string) => void @@ -133,8 +168,3 @@ export type ServerZustand = { /** The recipe half of the store's `set`, for a helper that writes through it. */ export type ServerSet = (recipe: (state: ServerZustand) => void) => void - -export type DefinedServerRegisters = Exclude< - PersistedServerZustand['serverRegisters'][UnitIdString], - undefined -> diff --git a/src/renderer/src/context/serverUndo.ts b/src/renderer/src/context/serverUndo.ts new file mode 100644 index 00000000..f9880aba --- /dev/null +++ b/src/renderer/src/context/serverUndo.ts @@ -0,0 +1,212 @@ +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' +import { pushStep, serverStepKey, unitStructure } from './undo.zustand.helpers' +import { + ServerBoolStep, + ServerLittleEndianStep, + ServerNameStep, + ServerPortStep, + ServerRecordStep, + ServerUndoStep, + ServerUnitStep, + UndoOutcome, + UndoRefusal, + 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<ServerNameStep | UndoRefusal> => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return 'refused-gone' + 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<ServerPortStep | UndoRefusal | undefined> => { + const server = useServerZustand.getState().servers[step.uuid] + if (!server) return 'refused-gone' + 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<ServerLittleEndianStep | UndoRefusal | undefined> => { + const server = useServerZustand.getState().servers[step.uuid] + 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<ServerBoolStep | UndoRefusal> => { + const entry = + useServerZustand.getState().servers[step.uuid]?.registers[step.unitId]?.[step.registerType][ + step.address + ] + if (!entry) return 'refused-gone' + 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<ServerUnitStep | UndoRefusal> => { + const server = useServerZustand.getState().servers[step.uuid] + 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) + 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<ServerRecordStep | undefined> => { + 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) + + // A port something else took meanwhile is walked past, and the server + // listens on another one than it had. Said, because a master still aims at + // the old one. + const port = useServerZustand.getState().servers[step.uuid]?.port + if (port !== undefined && port !== step.value.port) { + enqueueSnackbar({ + message: `Port ${step.value.port} is taken, so the server is back on port ${port}`, + variant: 'warning' + }) + } + return replaced +} + +const replay = (step: ServerUndoStep): Promise<ServerUndoStep | UndoRefusal | undefined> => { + 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<ServerUndoStep> => useUndoZustand.getState().server, + write: (stack: UndoStack<ServerUndoStep>): void => useUndoZustand.getState().setServer(stack) +} + +export const undoServer = (): Promise<UndoOutcome> => replayTop(serverStack, 'undo', replay) +export const redoServer = (): Promise<UndoOutcome> => 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<void>, + changedStep: () => ServerUndoStep | undefined +): Promise<void> => { + 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<void>): Promise<void> => { + 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<void> +): Promise<void> => { + 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/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<ServerUndoStep> = 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') + }) +} 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..d1c3873e --- /dev/null +++ b/src/renderer/src/context/undo.zustand.helpers.ts @@ -0,0 +1,169 @@ +import { ServerRegisters } from '@shared' +import { ClientZustand } from './client.zustand.types' +import { + ClientField, + ClientFieldStepMap, + ClientFieldValues, + ClientUndoStep, + ServerUndoStep, + 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 = <Step>(): UndoStack<Step> => ({ + 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 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<string, Record<string, unknown>> => { + const structure: Record<string, Record<string, unknown>> = {} + for (const [registerType, entries] of Object.entries(registers ?? {})) { + const addresses: Record<string, unknown> = {} + 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. + * + * 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 = <Step>( + stack: UndoStack<Step>, + step: Step, + key: string | undefined +): UndoStack<Step> => { + 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 = <Step>( + stack: UndoStack<Step>, + direction: 'undo' | 'redo', + replayed: Step, + replaced: Step +): UndoStack<Step> => { + 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..89419c67 --- /dev/null +++ b/src/renderer/src/context/undo.zustand.ts @@ -0,0 +1,75 @@ +import { create } from 'zustand' +import { + clientStepKey, + emptyStack, + moveStep, + pushStep, + serverStepKey +} from './undo.zustand.helpers' +import { UndoOutcome, UndoRefusal, UndoStack, 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<UndoZustand>()((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 + // 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 }), + 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 <Step extends object>( + stack: { read: () => UndoStack<Step>; write: (stack: UndoStack<Step>) => void }, + direction: 'undo' | 'redo', + replay: (step: Step) => Promise<Step | UndoRefusal | undefined> +): Promise<UndoOutcome> => { + 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 | 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 new file mode 100644 index 00000000..df9ceadf --- /dev/null +++ b/src/renderer/src/context/undo.zustand.types.ts @@ -0,0 +1,185 @@ +import { + BooleanRegisters, + ConnectionConfig, + Parity, + RegisterConfig, + RegisterMapping, + RegisterMapValue, + RegisterType, + 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. + * + * `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<SerialPortOptions, 'parity'> & { + 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<Fields extends ClientField = ClientField> = { + [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 + +/** + * 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. + * + * `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<Step> { + past: Step[] + future: Step[] + openKey: string | undefined +} + +/** + * 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<ClientUndoStep> + server: UndoStack<ServerUndoStep> + /** + * 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<ClientUndoStep>) => void + recordServer: (step: ServerUndoStep) => void + setServer: (stack: UndoStack<ServerUndoStep>) => void + beginQuiet: () => void + endQuiet: () => void +} diff --git a/src/renderer/src/events/index.ts b/src/renderer/src/events/index.ts index 038cf8ff..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 = <E extends IpcEvent>(event: E, ...args: IpcEventPayloadMap[E]): void => { +export const sendEvent = <E extends EventToMain>( + 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 = <E extends IpcEvent>( +export const onEvent = <E extends EventToRenderer>( event: E, listener: (...args: IpcEventPayloadMap[E]) => void ): (() => void) => { @@ -28,20 +31,3 @@ export const onEvent = <E extends IpcEvent>( } 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 = <E extends IpcEvent>( - 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/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, diff --git a/src/shared/__tests__/clientOwner.test.ts b/src/shared/__tests__/clientOwner.test.ts new file mode 100644 index 00000000..af215657 --- /dev/null +++ b/src/shared/__tests__/clientOwner.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { clientOwner, readLoopOwner } from '../clientOwner' +import { defaultClientState } from '../default' +import type { ClientState } from '../types' +import { ClientStateSchema } from '../types' + +/** Connected and idle, which is every state below with one flag turned on. */ +const idle: ClientState = { ...defaultClientState, connectState: 'connected' } + +/** + * The one question about who may put a request on the wire. + * + * It was spelled out in four places and three of them were different subsets: + * `modbusClient` named five states, `readWhenMainCan` named the same five, + * `ReadButton` named the connect state plus three, and `RegisterConfig` named + * two. Nothing was wrong at the time, because a register scan draws its dialog + * over the top bar and a unit id scan draws a backdrop over the window, which + * is layout rather than a guard. + */ +describe('who owns the client', () => { + it('answers nothing when nothing is running', () => { + expect(clientOwner(idle)).toBeUndefined() + }) + + it.each([ + ['polling', 'a poll'], + ['scanningUnitIds', 'a unit id scan'], + ['scanningRegisters', 'a register scan'], + ['reading', 'another read'], + ['writing', 'another write'] + ] as const)('names %s as %s', (flag, name) => { + expect(clientOwner({ ...idle, [flag]: true })).toBe(name) + }) + + // Every boolean of `ClientState`, so a sixth one added later fails here + // rather than being let through in silence. + it('names every state the client reports but the connect state', () => { + const flags = Object.entries(ClientStateSchema.shape) + .filter(([key]) => key !== 'connectState') + .map(([key]) => key) + + expect(flags.length).toBeGreaterThan(0) + for (const flag of flags) { + expect(clientOwner({ ...idle, [flag]: true })).toBeDefined() + } + }) + + // A write holds the client from its own request to the end of the read back, + // so `writing` covers a stretch in which `reading` is set too. + it('names the write when a write is reading back', () => { + expect(clientOwner({ ...idle, writing: true, reading: true })).toBe('another write') + }) + + describe('asked without the poll, which is what the two scans ask', () => { + it('answers nothing during a poll, because a scan stops one', () => { + expect(clientOwner({ ...idle, polling: true }, { exceptPolling: true })).toBeUndefined() + }) + + it.each([ + ['scanningUnitIds', 'a unit id scan'], + ['scanningRegisters', 'a register scan'], + ['reading', 'another read'], + ['writing', 'another write'] + ] as const)('still names %s', (flag, name) => { + expect(clientOwner({ ...idle, [flag]: true }, { exceptPolling: true })).toBe(name) + }) + }) +}) + +/** + * The narrower question a write asks before its read back. + * + * A loop decides for itself when it is done, so a write that finds one reading + * lets that read stand rather than adding its own. + */ +describe('which read loop owns the client', () => { + it.each([ + ['polling', 'a poll'], + ['scanningUnitIds', 'a unit id scan'], + ['scanningRegisters', 'a register scan'] + ] as const)('names %s', (flag, name) => { + expect(readLoopOwner({ ...idle, [flag]: true })).toBe(name) + }) + + it.each(['reading', 'writing'] as const)('answers nothing for %s', (flag) => { + expect(readLoopOwner({ ...idle, [flag]: true })).toBeUndefined() + }) +}) 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__/encoding.test.ts b/src/shared/__tests__/encoding.test.ts new file mode 100644 index 00000000..64e1764f --- /dev/null +++ b/src/shared/__tests__/encoding.test.ts @@ -0,0 +1,480 @@ +import { describe, it, expect } from 'vitest' +import { + getBit, + bigEndian32, + littleEndian32, + bigEndian64, + littleEndian64, + createRegisters, + createStringRegisters, + encodeIEC870DateTime, + registerWidth, + getMinMaxValues +} from '../encoding' +import { parseIEC870DateTime } from '../conversion' + +// --------------------------------------------------------------------------- +// getBit +// --------------------------------------------------------------------------- +describe('getBit', () => { + it('returns correct bit from a word', () => { + // 0b1010 = 10 + expect(getBit(0b1010, 0)).toBe(false) + expect(getBit(0b1010, 1)).toBe(true) + expect(getBit(0b1010, 2)).toBe(false) + expect(getBit(0b1010, 3)).toBe(true) + }) + + it('returns false for bits beyond the value', () => { + expect(getBit(1, 15)).toBe(false) + }) + + it('handles 0xFFFF (all bits set)', () => { + for (let i = 0; i < 16; i++) { + expect(getBit(0xffff, i)).toBe(true) + } + }) + + it('handles 0 (no bits set)', () => { + for (let i = 0; i < 16; i++) { + expect(getBit(0, i)).toBe(false) + } + }) + + it('reads a bit past the width of a signed 32 bit AND', () => { + expect(getBit(2 ** 31, 31)).toBe(true) + expect(getBit(2 ** 32, 32)).toBe(true) + expect(getBit(2 ** 32, 31)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Endianness helpers (32-bit) +// --------------------------------------------------------------------------- +describe('bigEndian32', () => { + it('returns 4 bytes starting at offset', () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]) + const result = bigEndian32(buf, 0) + expect(result).toEqual(Buffer.from([0x00, 0x01, 0x02, 0x03])) + }) + + it('respects offset', () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]) + const result = bigEndian32(buf, 2) + expect(result).toEqual(Buffer.from([0x02, 0x03, 0x04, 0x05])) + }) +}) + +describe('littleEndian32', () => { + it('swaps the two 16-bit words', () => { + // Input: [W1_hi, W1_lo, W2_hi, W2_lo] at offset 0 + // Output: [W2_hi, W2_lo, W1_hi, W1_lo] + const buf = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]) + const result = littleEndian32(buf, 0) + expect(result).toEqual(Buffer.from([0xcc, 0xdd, 0xaa, 0xbb])) + }) +}) + +// --------------------------------------------------------------------------- +// Endianness helpers (64-bit) +// --------------------------------------------------------------------------- +describe('bigEndian64', () => { + it('returns 8 bytes starting at offset', () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) + const result = bigEndian64(buf, 0) + expect(result).toEqual(Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])) + }) +}) + +describe('littleEndian64', () => { + it('reverses the four 16-bit words', () => { + // Input: [W1, W2, W3, W4] → Output: [W4, W3, W2, W1] + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) + const result = littleEndian64(buf, 0) + expect(result).toEqual(Buffer.from([0x06, 0x07, 0x04, 0x05, 0x02, 0x03, 0x00, 0x01])) + }) +}) + +// --------------------------------------------------------------------------- +// createRegisters +// --------------------------------------------------------------------------- +describe('registerWidth', () => { + it('is 1 for the 16-bit types', () => { + expect(registerWidth('int16')).toBe(1) + expect(registerWidth('uint16')).toBe(1) + expect(registerWidth('bitmap')).toBe(1) + }) + + it('is 2 for the 32-bit types', () => { + expect(registerWidth('int32')).toBe(2) + expect(registerWidth('uint32')).toBe(2) + expect(registerWidth('float')).toBe(2) + expect(registerWidth('unix')).toBe(2) + }) + + it('is 4 for the 64-bit types', () => { + expect(registerWidth('int64')).toBe(4) + expect(registerWidth('uint64')).toBe(4) + expect(registerWidth('double')).toBe(4) + expect(registerWidth('datetime')).toBe(4) + }) + + it('is the length the user gave for a string', () => { + expect(registerWidth('utf8', 2)).toBe(2) + expect(registerWidth('utf8', 124)).toBe(124) + }) + + it('is 10 for a string with no length, which is what the dialog offers', () => { + expect(registerWidth('utf8')).toBe(10) + }) + + it('ignores a length on a type that does not have one', () => { + expect(registerWidth('int16', 7)).toBe(1) + expect(registerWidth('double', 7)).toBe(4) + }) + + it('is 1 for none, which occupies its address without holding a value', () => { + expect(registerWidth('none')).toBe(1) + }) +}) + +describe('createRegisters', () => { + describe('int16', () => { + it('converts positive value', () => { + const regs = createRegisters('int16', 1234, false) + expect(regs).toHaveLength(1) + expect(regs[0]).toBe(1234) + }) + + it('converts negative value', () => { + const regs = createRegisters('int16', -1, false) + expect(regs).toHaveLength(1) + // -1 in int16 big-endian = 0xFFFF = 65535 + expect(regs[0]).toBe(65535) + }) + + it('converts zero', () => { + expect(createRegisters('int16', 0, false)).toEqual([0]) + }) + + it('converts max value (32767)', () => { + const regs = createRegisters('int16', 32767, false) + expect(regs[0]).toBe(32767) + }) + + it('converts min value (-32768)', () => { + const regs = createRegisters('int16', -32768, false) + expect(regs[0]).toBe(32768) // 0x8000 + }) + + it('ignores littleEndian flag (only 1 register)', () => { + expect(createRegisters('int16', 100, true)).toEqual(createRegisters('int16', 100, false)) + }) + }) + + describe('uint16', () => { + it('converts positive value', () => { + expect(createRegisters('uint16', 65535, false)).toEqual([65535]) + }) + + it('converts zero', () => { + expect(createRegisters('uint16', 0, false)).toEqual([0]) + }) + }) + + describe('bitmap', () => { + it('produces a single register identical to uint16', () => { + expect(createRegisters('bitmap', 42, false)).toEqual([42]) + }) + + it('converts zero', () => { + expect(createRegisters('bitmap', 0, false)).toEqual([0]) + }) + + it('converts max value', () => { + expect(createRegisters('bitmap', 65535, false)).toEqual([65535]) + }) + }) + + describe('int32 (big endian)', () => { + it('converts positive value to 2 registers', () => { + const regs = createRegisters('int32', 70000, false) + expect(regs).toHaveLength(2) + // 70000 = 0x00011170 → registers: [0x0001, 0x1170] = [1, 4464] + expect(regs[0]).toBe(1) + expect(regs[1]).toBe(4464) + }) + + it('converts negative value', () => { + const regs = createRegisters('int32', -1, false) + // -1 = 0xFFFFFFFF → [0xFFFF, 0xFFFF] = [65535, 65535] + expect(regs).toEqual([65535, 65535]) + }) + }) + + describe('int32 (little endian)', () => { + it('swaps words compared to big endian', () => { + const be = createRegisters('int32', 70000, false) + const le = createRegisters('int32', 70000, true) + expect(le).toHaveLength(2) + // Little endian swaps the two 16-bit words + expect(le[0]).toBe(be[1]) + expect(le[1]).toBe(be[0]) + }) + }) + + describe('uint32', () => { + it('converts max uint32', () => { + const regs = createRegisters('uint32', 4294967295, false) + expect(regs).toEqual([65535, 65535]) + }) + + it('converts zero', () => { + expect(createRegisters('uint32', 0, false)).toEqual([0, 0]) + }) + }) + + describe('float', () => { + it('converts 1.0 to correct registers', () => { + const regs = createRegisters('float', 1.0, false) + expect(regs).toHaveLength(2) + // IEEE 754: 1.0f = 0x3F800000 → [0x3F80, 0x0000] = [16256, 0] + expect(regs[0]).toBe(16256) + expect(regs[1]).toBe(0) + }) + + it('converts -1.5 correctly', () => { + const regs = createRegisters('float', -1.5, false) + expect(regs).toHaveLength(2) + // -1.5f = 0xBFC00000 → [0xBFC0, 0x0000] + expect(regs[0]).toBe(0xbfc0) + expect(regs[1]).toBe(0) + }) + + it('handles little endian word swap', () => { + const be = createRegisters('float', 3.14, false) + const le = createRegisters('float', 3.14, true) + expect(le[0]).toBe(be[1]) + expect(le[1]).toBe(be[0]) + }) + }) + + describe('int64', () => { + it('converts to 4 registers', () => { + const regs = createRegisters('int64', 1, false) + expect(regs).toHaveLength(4) + expect(regs).toEqual([0, 0, 0, 1]) + }) + + it('converts negative value', () => { + const regs = createRegisters('int64', -1, false) + expect(regs).toEqual([65535, 65535, 65535, 65535]) + }) + + it('handles little endian word reversal', () => { + const be = createRegisters('int64', 1, false) + const le = createRegisters('int64', 1, true) + // LE reverses all 4 words + expect(le[0]).toBe(be[3]) + expect(le[1]).toBe(be[2]) + expect(le[2]).toBe(be[1]) + expect(le[3]).toBe(be[0]) + }) + }) + + describe('uint64', () => { + it('converts to 4 registers', () => { + expect(createRegisters('uint64', 0, false)).toEqual([0, 0, 0, 0]) + expect(createRegisters('uint64', 1, false)).toEqual([0, 0, 0, 1]) + }) + + it('applies little endian word swap', () => { + const be = createRegisters('uint64', 1, false) + const le = createRegisters('uint64', 1, true) + expect(le).toEqual([be[3], be[2], be[1], be[0]]) + }) + }) + + describe('double', () => { + it('converts 1.0 to 4 registers', () => { + const regs = createRegisters('double', 1.0, false) + expect(regs).toHaveLength(4) + // IEEE 754 double: 1.0 = 0x3FF0000000000000 + // → [0x3FF0, 0x0000, 0x0000, 0x0000] + expect(regs[0]).toBe(0x3ff0) + expect(regs[1]).toBe(0) + expect(regs[2]).toBe(0) + expect(regs[3]).toBe(0) + }) + + it('handles little endian word reversal', () => { + const be = createRegisters('double', 1.0, false) + const le = createRegisters('double', 1.0, true) + expect(le[0]).toBe(be[3]) + expect(le[1]).toBe(be[2]) + expect(le[2]).toBe(be[1]) + expect(le[3]).toBe(be[0]) + }) + }) + + /** The registers as the bytes they stand for, big endian. */ + const toBuffer = (registers: number[]): Buffer => { + const buffer = Buffer.alloc(registers.length * 2) + registers.forEach((register, i) => buffer.writeUInt16BE(register, i * 2)) + return buffer + } + + describe('round-trip consistency', () => { + it('int32: value → registers → buffer → value', () => { + const value = 123456 + const regs = createRegisters('int32', value, false) + expect(toBuffer(regs).readInt32BE(0)).toBe(value) + }) + + it('float: value → registers → buffer → value', () => { + const value = 3.14 + const regs = createRegisters('float', value, false) + expect(toBuffer(regs).readFloatBE(0)).toBeCloseTo(value, 2) + }) + + it('double: value → registers → buffer → value', () => { + const value = 3.141592653589793 + const regs = createRegisters('double', value, false) + expect(toBuffer(regs).readDoubleBE(0)).toBeCloseTo(value, 10) + }) + + it('uint32 little endian: value → registers → swapped buffer → value', () => { + const value = 70000 + const regs = createRegisters('uint32', value, true) + // The two words go back the other way round to be read + const swapped = littleEndian32(toBuffer(regs), 0) + expect(swapped.readUInt32BE(0)).toBe(value) + }) + }) +}) + +// --------------------------------------------------------------------------- +// getMinMaxValues +// --------------------------------------------------------------------------- +describe('getMinMaxValues', () => { + it('returns correct range for int16', () => { + expect(getMinMaxValues('int16')).toEqual({ min: -32768, max: 32767 }) + }) + + it('returns correct range for uint16', () => { + expect(getMinMaxValues('uint16')).toEqual({ min: 0, max: 65535 }) + }) + + it('returns correct range for bitmap (same as uint16)', () => { + expect(getMinMaxValues('bitmap')).toEqual({ min: 0, max: 65535 }) + }) + + it('returns correct range for int32', () => { + expect(getMinMaxValues('int32')).toEqual({ min: -2147483648, max: 2147483647 }) + }) + + it('returns correct range for uint32', () => { + expect(getMinMaxValues('uint32')).toEqual({ min: 0, max: 4294967295 }) + }) + + it('returns safe integer range for int64', () => { + expect(getMinMaxValues('int64')).toEqual({ + min: Number.MIN_SAFE_INTEGER, + max: Number.MAX_SAFE_INTEGER + }) + }) + + it('returns 0 to MAX_SAFE_INTEGER for uint64', () => { + expect(getMinMaxValues('uint64')).toEqual({ + min: 0, + max: Number.MAX_SAFE_INTEGER + }) + }) + + it('returns infinity range for float', () => { + const { min, max } = getMinMaxValues('float') + expect(min).toBe(Number.NEGATIVE_INFINITY) + expect(max).toBe(Number.POSITIVE_INFINITY) + }) + + it('returns infinity range for double', () => { + const { min, max } = getMinMaxValues('double') + expect(min).toBe(Number.NEGATIVE_INFINITY) + expect(max).toBe(Number.POSITIVE_INFINITY) + }) + + 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', () => { + const { min, max } = getMinMaxValues('unix') + expect(min).toBe(0) + expect(max).toBe(4294967295) + expect(createRegisters('unix', max, false)).toEqual([0xffff, 0xffff]) + }) + + it('states the datetime range in the milliseconds createRegisters writes', () => { + const { min, max } = getMinMaxValues('datetime') + expect(parseIEC870DateTime(encodeIEC870DateTime(min))).toBe('2000/01/01 00:00:00') + expect(parseIEC870DateTime(encodeIEC870DateTime(max))).toBe('2127/12/31 23:59:59') + }) +}) + +// --------------------------------------------------------------------------- +// encodeIEC870DateTime +// --------------------------------------------------------------------------- +describe('encodeIEC870DateTime', () => { + it('round trips a timestamp inside the range', () => { + const timestampMs = Date.UTC(2024, 5, 1, 12, 34, 56, 789) + expect(parseIEC870DateTime(encodeIEC870DateTime(timestampMs))).toBe('2024/06/01 12:34:56') + }) + + it('clamps a timestamp before 2000 to the first second of 2000', () => { + expect(parseIEC870DateTime(encodeIEC870DateTime(0))).toBe('2000/01/01 00:00:00') + }) + + it('clamps a timestamp past 2127 to the last second of 2127', () => { + const year2200 = Date.UTC(2200, 0, 1) + expect(parseIEC870DateTime(encodeIEC870DateTime(year2200))).toBe('2127/12/31 23:59:59') + }) + + it('keeps the four registers createRegisters splits it into', () => { + const timestampMs = Date.UTC(2024, 5, 1, 12, 34, 56, 789) + expect(createRegisters('datetime', timestampMs, false)).toEqual([ + 0x0018, 0x0601, 0x0c22, 0xddd5 + ]) + }) +}) + +// --------------------------------------------------------------------------- +describe('createStringRegisters', () => { + it('packs two characters into one register', () => { + expect(createStringRegisters('AB', 1)).toEqual([0x4142]) + }) + + it('pads the registers it was given with zeros', () => { + expect(createStringRegisters('A', 2)).toEqual([0x4100, 0x0000]) + }) + + it('answers zeros for no text at all', () => { + expect(createStringRegisters('', 2)).toEqual([0x0000, 0x0000]) + }) + + it('cuts text longer than the registers it was given', () => { + expect(createStringRegisters('ABCD', 1)).toEqual([0x4142]) + }) + + // A register is two bytes and a character need not be: the two bytes of the + // `e9` are `c3` and `a9`, and the register boundary falls between them. + it('carries a character across a register boundary', () => { + expect(createStringRegisters('a\u00e9', 2)).toEqual([0x61c3, 0xa900]) + }) + + // `Buffer.write` writes whole characters only. A character that does not fit + // leaves its bytes at zero rather than half of them in the register. + it('drops a character that does not fit rather than half of it', () => { + expect(createStringRegisters('a\u00e9', 1)).toEqual([0x6100]) + }) +}) diff --git a/src/shared/__tests__/helpers.test.ts b/src/shared/__tests__/helpers.test.ts new file mode 100644 index 00000000..099ca9c4 --- /dev/null +++ b/src/shared/__tests__/helpers.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { snakeToCamel } from '../types/helpers' + +// ─── snakeToCamel ──────────────────────────────────────────────────── + +describe('snakeToCamel', () => { + it('converts snake_case to camelCase', () => { + expect(snakeToCamel('update_register_config')).toBe('updateRegisterConfig') + }) + + it('returns single word unchanged', () => { + expect(snakeToCamel('connect')).toBe('connect') + }) + + it('handles multiple underscores', () => { + expect(snakeToCamel('stop_scanning_unit_ids')).toBe('stopScanningUnitIds') + }) + + it('handles two-part names', () => { + expect(snakeToCamel('start_polling')).toBe('startPolling') + }) + + it('converts all IPC_CHANNELS correctly', () => { + const cases: [string, string][] = [ + ['update_register_config', 'updateRegisterConfig'], + ['scan_unit_ids', 'scanUnitIds'], + ['add_replace_server_register', 'addReplaceServerRegister'], + ['set_bool', 'setBool'], + ['get_app_version', 'getAppVersion'], + ['list_serial_ports', 'listSerialPorts'], + ['validate_serial_port', 'validateSerialPort'] + ] + for (const [input, expected] of cases) { + expect(snakeToCamel(input)).toBe(expected) + } + }) +}) diff --git a/src/shared/__tests__/migrations.test.ts b/src/shared/__tests__/migrations.test.ts index 9e5459e2..0812df55 100644 --- a/src/shared/__tests__/migrations.test.ts +++ b/src/shared/__tests__/migrations.test.ts @@ -2,7 +2,13 @@ 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 type { SerialPortOptions } from '../types/serial' import { readFileSync } from 'fs' import { join } from 'path' @@ -192,9 +198,11 @@ describe('configMigration', () => { }) it('rewrites a 64 bit value a v2 file holds as a number', () => { - const register = (dataType: string, value: number): unknown => ({ + // The key and `params.address` are one address, and `ServerRegisterSchema` + // refuses an entry whose two disagree. + const register = (address: number, dataType: string, value: number): unknown => ({ value, - params: { address: 0, registerType: 'holding_registers', dataType, comment: '', value: 0 } + params: { address, registerType: 'holding_registers', dataType, comment: '', value: 0 } }) const result = migrateServerConfig( JSON.stringify({ @@ -208,8 +216,8 @@ describe('configMigration', () => { discrete_inputs: {}, input_registers: {}, holding_registers: { - '0': register('uint64', 72623859790382850), - '10': register('uint16', 7) + '0': register(0, 'uint64', 72623859790382850), + '10': register(10, 'uint16', 7) } } } @@ -241,11 +249,10 @@ describe('configMigration', () => { expect(result.futureVersion?.fields).toEqual([]) }) - // The branch cast the parsed JSON straight to `ServerConfig` and returned - // it, so this was the one door into the app no schema stood in, and the - // snackbar read as a compatibility notice rather than "this was not - // checked". Both fixtures the suite had were well formed, so it was green - // on the half that works. + // A file claiming a newer version is parsed rather than cast, so the + // fields it carries that this version cannot read are named rather than + // installed. A well formed future config is the half that works, and it + // is the one above. it('keeps the fields a future config still shares and names the rest', () => { const result = migrateServerConfig( JSON.stringify({ @@ -308,6 +315,45 @@ describe('configMigration', () => { expect(result.futureVersion?.fields).toEqual([]) }) + // `ServerRegisterEntrySchema` requires `params`, and the drop read + // `params` alone: an entry carrying none was taken for a boolean one and + // kept, so the field this walk exists to save was reset for it anyway. + it('drops a number register carrying no parameters and keeps the field', () => { + const result = migrateServerConfig( + JSON.stringify({ + version: 9, + modbuxVersion: '9.0.0', + name: 'Future', + littleEndian: false, + serverRegistersPerUnit: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + '0': { value: 1 }, + '20': { + value: 1, + params: { + address: 20, + registerType: 'holding_registers', + dataType: 'int32', + comment: '', + value: 1 + } + } + } + } + } + }) + ) + + expect( + Object.keys(result.config.serverRegistersPerUnit['1']?.holding_registers ?? {}) + ).toEqual(['20']) + expect(result.futureVersion?.fields).toEqual([]) + }) + it('says so in a sentence naming the field', () => { const result = migrateServerConfig( JSON.stringify({ @@ -390,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) }) @@ -490,7 +538,7 @@ describe('configMigration', () => { expect(message.length).toBeLessThan(200) }) - // Five lines is the budget, and one register used to spend all of it. + // Five lines is the budget, and one unit's registers can fill it. it('still reaches the unit id past a unit full of bad registers', () => { const message = refusal( v2Config({ @@ -836,5 +884,61 @@ 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. A copy that builds a new record per bool + // type instead turns `coils: 5` into `{}`, and the file loads. + 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/) + }) + + // 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', + 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 }) + + // `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/__tests__/repairPersisted.test.ts b/src/shared/__tests__/repairPersisted.test.ts index e7a20ae4..fbcb4525 100644 --- a/src/shared/__tests__/repairPersisted.test.ts +++ b/src/shared/__tests__/repairPersisted.test.ts @@ -96,11 +96,11 @@ describe('resetMessage', () => { it('lists several fields and agrees with itself about the verb', () => { const message = resetMessage('Server', { - fields: ['port', 'uuids', 'serialConfig'], + fields: ['port', 'servers', 'serialConfig'], savedByNewerVersion: false }) - expect(message).toContain('the ports, the server list and the serial settings') + expect(message).toContain('the ports, the servers and the serial settings') expect(message).toContain('were reset') }) @@ -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 410be37c..6c56ee27 100644 --- a/src/shared/__tests__/schemas.test.ts +++ b/src/shared/__tests__/schemas.test.ts @@ -2,16 +2,28 @@ 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' import { RegisterParamsSchema, RemoveRegisterParamsSchema } from '../types/server' import { DataBitsSchema, SerialPortOptionsSchema, StopBitsSchema } from '../types/serial' -import { MAX_UTF8_LENGTH } from '../utils' +import { MAX_UTF8_LENGTH } from '../encoding' import { ScanUnitIDParametersSchema } from '../types/scan' -import { MAX_READ_BITS, MAX_READ_REGISTERS, maxReadQuantity } from '../types/ranges' -import type { RegisterType } from '../types/register' +import { + MAX_READ_BITS, + MAX_READ_REGISTERS, + MAX_REGISTER_ADDRESS, + maxReadQuantity, + registersFrom +} from '../types/ranges' +import { + isBooleanRegister, + isNumberRegister, + RegisterTypeSchema, + type RegisterType +} from '../types/register' describe('RegisterMapObjectSchema', () => { it('accepts numeric string keys', () => { @@ -169,6 +181,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) }) @@ -452,9 +480,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<string, unknown>): unknown => ({ @@ -531,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) @@ -538,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. @@ -558,6 +642,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<string, unknown>): unknown => ({ baudRate: '9600', @@ -592,3 +689,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/__tests__/serverUtils.test.ts b/src/shared/__tests__/serverUtils.test.ts index 466c44c1..8aedd753 100644 --- a/src/shared/__tests__/serverUtils.test.ts +++ b/src/shared/__tests__/serverUtils.test.ts @@ -1,48 +1,8 @@ import { describe, it, expect } from 'vitest' -import { - findAvailablePort, - getUsedAddresses, - checkHasConfig, - getAddressFitError, - snakeToCamel -} from '../utils' +import { findAvailablePort, checkHasConfig } from '../utils' +import { getUsedAddresses, getAddressFitError } from '../encoding' import type { RegisterParams, ServerRegisters } from '../types' -// ─── snakeToCamel ──────────────────────────────────────────────────── - -describe('snakeToCamel', () => { - it('converts snake_case to camelCase', () => { - expect(snakeToCamel('update_register_config')).toBe('updateRegisterConfig') - }) - - it('returns single word unchanged', () => { - expect(snakeToCamel('connect')).toBe('connect') - }) - - it('handles multiple underscores', () => { - expect(snakeToCamel('stop_scanning_unit_ids')).toBe('stopScanningUnitIds') - }) - - it('handles two-part names', () => { - expect(snakeToCamel('start_polling')).toBe('startPolling') - }) - - it('converts all IPC_CHANNELS correctly', () => { - const cases: [string, string][] = [ - ['update_register_config', 'updateRegisterConfig'], - ['scan_unit_ids', 'scanUnitIds'], - ['add_replace_server_register', 'addReplaceServerRegister'], - ['set_bool', 'setBool'], - ['get_app_version', 'getAppVersion'], - ['list_serial_ports', 'listSerialPorts'], - ['validate_serial_port', 'validateSerialPort'] - ] - for (const [input, expected] of cases) { - expect(snakeToCamel(input)).toBe(expected) - } - }) -}) - // ─── findAvailablePort ─────────────────────────────────────────────── describe('findAvailablePort', () => { diff --git a/src/shared/__tests__/utils.test.ts b/src/shared/__tests__/utils.test.ts index 9359030a..78e909d7 100644 --- a/src/shared/__tests__/utils.test.ts +++ b/src/shared/__tests__/utils.test.ts @@ -1,454 +1,7 @@ import { describe, it, expect } from 'vitest' -import { - getBit, - bigEndian32, - littleEndian32, - bigEndian64, - littleEndian64, - createRegisters, - encodeIEC870DateTime, - registerWidth, - getMinMaxValues, - notEmpty, - humanizeSerialError -} from '../utils' -import { parseIEC870DateTime } from '../conversion' +import { notEmpty, humanizeSerialError } from '../utils' import { getDummyRegisterData, dummyWords, MAIN_SERVER_UUID } from '../default' -// --------------------------------------------------------------------------- -// getBit -// --------------------------------------------------------------------------- -describe('getBit', () => { - it('returns correct bit from a word', () => { - // 0b1010 = 10 - expect(getBit(0b1010, 0)).toBe(false) - expect(getBit(0b1010, 1)).toBe(true) - expect(getBit(0b1010, 2)).toBe(false) - expect(getBit(0b1010, 3)).toBe(true) - }) - - it('returns false for bits beyond the value', () => { - expect(getBit(1, 15)).toBe(false) - }) - - it('handles 0xFFFF (all bits set)', () => { - for (let i = 0; i < 16; i++) { - expect(getBit(0xffff, i)).toBe(true) - } - }) - - it('handles 0 (no bits set)', () => { - for (let i = 0; i < 16; i++) { - expect(getBit(0, i)).toBe(false) - } - }) - - it('reads a bit past the width of a signed 32 bit AND', () => { - expect(getBit(2 ** 31, 31)).toBe(true) - expect(getBit(2 ** 32, 32)).toBe(true) - expect(getBit(2 ** 32, 31)).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// Endianness helpers (32-bit) -// --------------------------------------------------------------------------- -describe('bigEndian32', () => { - it('returns 4 bytes starting at offset', () => { - const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]) - const result = bigEndian32(buf, 0) - expect(result).toEqual(Buffer.from([0x00, 0x01, 0x02, 0x03])) - }) - - it('respects offset', () => { - const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]) - const result = bigEndian32(buf, 2) - expect(result).toEqual(Buffer.from([0x02, 0x03, 0x04, 0x05])) - }) -}) - -describe('littleEndian32', () => { - it('swaps the two 16-bit words', () => { - // Input: [W1_hi, W1_lo, W2_hi, W2_lo] at offset 0 - // Output: [W2_hi, W2_lo, W1_hi, W1_lo] - const buf = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]) - const result = littleEndian32(buf, 0) - expect(result).toEqual(Buffer.from([0xcc, 0xdd, 0xaa, 0xbb])) - }) -}) - -// --------------------------------------------------------------------------- -// Endianness helpers (64-bit) -// --------------------------------------------------------------------------- -describe('bigEndian64', () => { - it('returns 8 bytes starting at offset', () => { - const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) - const result = bigEndian64(buf, 0) - expect(result).toEqual(Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])) - }) -}) - -describe('littleEndian64', () => { - it('reverses the four 16-bit words', () => { - // Input: [W1, W2, W3, W4] → Output: [W4, W3, W2, W1] - const buf = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) - const result = littleEndian64(buf, 0) - expect(result).toEqual(Buffer.from([0x06, 0x07, 0x04, 0x05, 0x02, 0x03, 0x00, 0x01])) - }) -}) - -// --------------------------------------------------------------------------- -// createRegisters -// --------------------------------------------------------------------------- -describe('registerWidth', () => { - it('is 1 for the 16-bit types', () => { - expect(registerWidth('int16')).toBe(1) - expect(registerWidth('uint16')).toBe(1) - expect(registerWidth('bitmap')).toBe(1) - }) - - it('is 2 for the 32-bit types', () => { - expect(registerWidth('int32')).toBe(2) - expect(registerWidth('uint32')).toBe(2) - expect(registerWidth('float')).toBe(2) - expect(registerWidth('unix')).toBe(2) - }) - - it('is 4 for the 64-bit types', () => { - expect(registerWidth('int64')).toBe(4) - expect(registerWidth('uint64')).toBe(4) - expect(registerWidth('double')).toBe(4) - expect(registerWidth('datetime')).toBe(4) - }) - - it('is the length the user gave for a string', () => { - expect(registerWidth('utf8', 2)).toBe(2) - expect(registerWidth('utf8', 124)).toBe(124) - }) - - it('is 10 for a string with no length, which is what the dialog offers', () => { - expect(registerWidth('utf8')).toBe(10) - }) - - it('ignores a length on a type that does not have one', () => { - expect(registerWidth('int16', 7)).toBe(1) - expect(registerWidth('double', 7)).toBe(4) - }) - - it('is 1 for none, which occupies its address without holding a value', () => { - expect(registerWidth('none')).toBe(1) - }) -}) - -describe('createRegisters', () => { - describe('int16', () => { - it('converts positive value', () => { - const regs = createRegisters('int16', 1234, false) - expect(regs).toHaveLength(1) - expect(regs[0]).toBe(1234) - }) - - it('converts negative value', () => { - const regs = createRegisters('int16', -1, false) - expect(regs).toHaveLength(1) - // -1 in int16 big-endian = 0xFFFF = 65535 - expect(regs[0]).toBe(65535) - }) - - it('converts zero', () => { - expect(createRegisters('int16', 0, false)).toEqual([0]) - }) - - it('converts max value (32767)', () => { - const regs = createRegisters('int16', 32767, false) - expect(regs[0]).toBe(32767) - }) - - it('converts min value (-32768)', () => { - const regs = createRegisters('int16', -32768, false) - expect(regs[0]).toBe(32768) // 0x8000 - }) - - it('ignores littleEndian flag (only 1 register)', () => { - expect(createRegisters('int16', 100, true)).toEqual(createRegisters('int16', 100, false)) - }) - }) - - describe('uint16', () => { - it('converts positive value', () => { - expect(createRegisters('uint16', 65535, false)).toEqual([65535]) - }) - - it('converts zero', () => { - expect(createRegisters('uint16', 0, false)).toEqual([0]) - }) - }) - - describe('bitmap', () => { - it('produces a single register identical to uint16', () => { - expect(createRegisters('bitmap', 42, false)).toEqual([42]) - }) - - it('converts zero', () => { - expect(createRegisters('bitmap', 0, false)).toEqual([0]) - }) - - it('converts max value', () => { - expect(createRegisters('bitmap', 65535, false)).toEqual([65535]) - }) - }) - - describe('int32 (big endian)', () => { - it('converts positive value to 2 registers', () => { - const regs = createRegisters('int32', 70000, false) - expect(regs).toHaveLength(2) - // 70000 = 0x00011170 → registers: [0x0001, 0x1170] = [1, 4464] - expect(regs[0]).toBe(1) - expect(regs[1]).toBe(4464) - }) - - it('converts negative value', () => { - const regs = createRegisters('int32', -1, false) - // -1 = 0xFFFFFFFF → [0xFFFF, 0xFFFF] = [65535, 65535] - expect(regs).toEqual([65535, 65535]) - }) - }) - - describe('int32 (little endian)', () => { - it('swaps words compared to big endian', () => { - const be = createRegisters('int32', 70000, false) - const le = createRegisters('int32', 70000, true) - expect(le).toHaveLength(2) - // Little endian swaps the two 16-bit words - expect(le[0]).toBe(be[1]) - expect(le[1]).toBe(be[0]) - }) - }) - - describe('uint32', () => { - it('converts max uint32', () => { - const regs = createRegisters('uint32', 4294967295, false) - expect(regs).toEqual([65535, 65535]) - }) - - it('converts zero', () => { - expect(createRegisters('uint32', 0, false)).toEqual([0, 0]) - }) - }) - - describe('float', () => { - it('converts 1.0 to correct registers', () => { - const regs = createRegisters('float', 1.0, false) - expect(regs).toHaveLength(2) - // IEEE 754: 1.0f = 0x3F800000 → [0x3F80, 0x0000] = [16256, 0] - expect(regs[0]).toBe(16256) - expect(regs[1]).toBe(0) - }) - - it('converts -1.5 correctly', () => { - const regs = createRegisters('float', -1.5, false) - expect(regs).toHaveLength(2) - // -1.5f = 0xBFC00000 → [0xBFC0, 0x0000] - expect(regs[0]).toBe(0xbfc0) - expect(regs[1]).toBe(0) - }) - - it('handles little endian word swap', () => { - const be = createRegisters('float', 3.14, false) - const le = createRegisters('float', 3.14, true) - expect(le[0]).toBe(be[1]) - expect(le[1]).toBe(be[0]) - }) - }) - - describe('int64', () => { - it('converts to 4 registers', () => { - const regs = createRegisters('int64', 1, false) - expect(regs).toHaveLength(4) - expect(regs).toEqual([0, 0, 0, 1]) - }) - - it('converts negative value', () => { - const regs = createRegisters('int64', -1, false) - expect(regs).toEqual([65535, 65535, 65535, 65535]) - }) - - it('handles little endian word reversal', () => { - const be = createRegisters('int64', 1, false) - const le = createRegisters('int64', 1, true) - // LE reverses all 4 words - expect(le[0]).toBe(be[3]) - expect(le[1]).toBe(be[2]) - expect(le[2]).toBe(be[1]) - expect(le[3]).toBe(be[0]) - }) - }) - - describe('uint64', () => { - it('converts to 4 registers', () => { - expect(createRegisters('uint64', 0, false)).toEqual([0, 0, 0, 0]) - expect(createRegisters('uint64', 1, false)).toEqual([0, 0, 0, 1]) - }) - - it('applies little endian word swap', () => { - const be = createRegisters('uint64', 1, false) - const le = createRegisters('uint64', 1, true) - expect(le).toEqual([be[3], be[2], be[1], be[0]]) - }) - }) - - describe('double', () => { - it('converts 1.0 to 4 registers', () => { - const regs = createRegisters('double', 1.0, false) - expect(regs).toHaveLength(4) - // IEEE 754 double: 1.0 = 0x3FF0000000000000 - // → [0x3FF0, 0x0000, 0x0000, 0x0000] - expect(regs[0]).toBe(0x3ff0) - expect(regs[1]).toBe(0) - expect(regs[2]).toBe(0) - expect(regs[3]).toBe(0) - }) - - it('handles little endian word reversal', () => { - const be = createRegisters('double', 1.0, false) - const le = createRegisters('double', 1.0, true) - expect(le[0]).toBe(be[3]) - expect(le[1]).toBe(be[2]) - expect(le[2]).toBe(be[1]) - expect(le[3]).toBe(be[0]) - }) - }) - - /** The registers as the bytes they stand for, big endian. */ - const toBuffer = (registers: number[]): Buffer => { - const buffer = Buffer.alloc(registers.length * 2) - registers.forEach((register, i) => buffer.writeUInt16BE(register, i * 2)) - return buffer - } - - describe('round-trip consistency', () => { - it('int32: value → registers → buffer → value', () => { - const value = 123456 - const regs = createRegisters('int32', value, false) - expect(toBuffer(regs).readInt32BE(0)).toBe(value) - }) - - it('float: value → registers → buffer → value', () => { - const value = 3.14 - const regs = createRegisters('float', value, false) - expect(toBuffer(regs).readFloatBE(0)).toBeCloseTo(value, 2) - }) - - it('double: value → registers → buffer → value', () => { - const value = 3.141592653589793 - const regs = createRegisters('double', value, false) - expect(toBuffer(regs).readDoubleBE(0)).toBeCloseTo(value, 10) - }) - - it('uint32 little endian: value → registers → swapped buffer → value', () => { - const value = 70000 - const regs = createRegisters('uint32', value, true) - // The two words go back the other way round to be read - const swapped = littleEndian32(toBuffer(regs), 0) - expect(swapped.readUInt32BE(0)).toBe(value) - }) - }) -}) - -// --------------------------------------------------------------------------- -// getMinMaxValues -// --------------------------------------------------------------------------- -describe('getMinMaxValues', () => { - it('returns correct range for int16', () => { - expect(getMinMaxValues('int16')).toEqual({ min: -32768, max: 32767 }) - }) - - it('returns correct range for uint16', () => { - expect(getMinMaxValues('uint16')).toEqual({ min: 0, max: 65535 }) - }) - - it('returns correct range for bitmap (same as uint16)', () => { - expect(getMinMaxValues('bitmap')).toEqual({ min: 0, max: 65535 }) - }) - - it('returns correct range for int32', () => { - expect(getMinMaxValues('int32')).toEqual({ min: -2147483648, max: 2147483647 }) - }) - - it('returns correct range for uint32', () => { - expect(getMinMaxValues('uint32')).toEqual({ min: 0, max: 4294967295 }) - }) - - it('returns safe integer range for int64', () => { - expect(getMinMaxValues('int64')).toEqual({ - min: Number.MIN_SAFE_INTEGER, - max: Number.MAX_SAFE_INTEGER - }) - }) - - it('returns 0 to MAX_SAFE_INTEGER for uint64', () => { - expect(getMinMaxValues('uint64')).toEqual({ - min: 0, - max: Number.MAX_SAFE_INTEGER - }) - }) - - it('returns infinity range for float', () => { - const { min, max } = getMinMaxValues('float') - expect(min).toBe(Number.NEGATIVE_INFINITY) - expect(max).toBe(Number.POSITIVE_INFINITY) - }) - - it('returns infinity range for double', () => { - const { min, max } = getMinMaxValues('double') - expect(min).toBe(Number.NEGATIVE_INFINITY) - expect(max).toBe(Number.POSITIVE_INFINITY) - }) - - it('returns { min: 0, max: 0 } for unknown types', () => { - expect(getMinMaxValues('none')).toEqual({ min: 0, max: 0 }) - }) - - it('states the unix range in the seconds createRegisters writes', () => { - const { min, max } = getMinMaxValues('unix') - expect(min).toBe(0) - expect(max).toBe(4294967295) - expect(createRegisters('unix', max, false)).toEqual([0xffff, 0xffff]) - }) - - it('states the datetime range in the milliseconds createRegisters writes', () => { - const { min, max } = getMinMaxValues('datetime') - expect(parseIEC870DateTime(encodeIEC870DateTime(min))).toBe('2000/01/01 00:00:00') - expect(parseIEC870DateTime(encodeIEC870DateTime(max))).toBe('2127/12/31 23:59:59') - }) -}) - -// --------------------------------------------------------------------------- -// encodeIEC870DateTime -// --------------------------------------------------------------------------- -describe('encodeIEC870DateTime', () => { - it('round trips a timestamp inside the range', () => { - const timestampMs = Date.UTC(2024, 5, 1, 12, 34, 56, 789) - expect(parseIEC870DateTime(encodeIEC870DateTime(timestampMs))).toBe('2024/06/01 12:34:56') - }) - - it('clamps a timestamp before 2000 to the first second of 2000', () => { - expect(parseIEC870DateTime(encodeIEC870DateTime(0))).toBe('2000/01/01 00:00:00') - }) - - it('clamps a timestamp past 2127 to the last second of 2127', () => { - const year2200 = Date.UTC(2200, 0, 1) - expect(parseIEC870DateTime(encodeIEC870DateTime(year2200))).toBe('2127/12/31 23:59:59') - }) - - it('keeps the four registers createRegisters splits it into', () => { - const timestampMs = Date.UTC(2024, 5, 1, 12, 34, 56, 789) - expect(createRegisters('datetime', timestampMs, false)).toEqual([ - 0x0018, 0x0601, 0x0c22, 0xddd5 - ]) - }) -}) - // --------------------------------------------------------------------------- // notEmpty // --------------------------------------------------------------------------- @@ -517,9 +70,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/addressGrouping.ts b/src/shared/addressGrouping.ts index 980c697e..800a1233 100644 --- a/src/shared/addressGrouping.ts +++ b/src/shared/addressGrouping.ts @@ -1,5 +1,13 @@ -import type { AddressGroup, DataType, RegisterMapObject, RegisterMapValue } from './types' -import { registerWidth } from './utils' +import type { + AddressGroup, + DataType, + RegisterMapObject, + RegisterMapping, + RegisterMapValue, + RegisterType +} from './types' +import { isNumberRegister } from './types' +import { registerWidth } from './encoding' /** How far a string is read when nothing in the mapping says where it ends. */ const MAX_UTF8_READ_REGISTERS = 24 @@ -111,3 +119,30 @@ export const groupAddressInfos = ( return groups } + +/** + * The groups a read takes out of the mapping, and nothing at all where a read + * takes the toolbar's own group instead. + * + * `_read` asks three things before it reads a mapping: read configuration is + * on, the type is one the mapping configures, and the mapping has a group + * under it. Falling through on any of the three is a raw read of the toolbar's + * address and length, which is the right answer for a read and the wrong one + * for a caller asking whether the mapping is what comes back. + * + * A bit type is not one the mapping configures: the grid mounts the data type + * column for input and holding registers alone, so a comment is all it writes + * into a coil, and a data type a config file puts on a coil address is ignored + * here the way the toolbar button refuses it. + * + * Here rather than in `_read`, because the renderer asks the same question. + * `clearRegisterDataWhenIdle` redraws the mapping and asks main to fill it, + * and where main would answer out of the toolbar group instead, what comes + * back is not what was drawn. + */ +export const configuredReadGroups = ( + readConfiguration: boolean, + type: RegisterType, + registerMapping: RegisterMapping | undefined +): Array<AddressGroup> => + readConfiguration && isNumberRegister(type) ? groupAddressInfos(registerMapping?.[type]) : [] diff --git a/src/shared/clientOwner.ts b/src/shared/clientOwner.ts new file mode 100644 index 00000000..d0c0ac82 --- /dev/null +++ b/src/shared/clientOwner.ts @@ -0,0 +1,59 @@ +import type { ClientState } from './types' + +/** How a caller asks about the poll a scan stops rather than is refused by. */ +export interface ClientOwnerQuestion { + /** + * Leave a poll out of the answer. + * + * `scanUnitIds` and `scanRegisters` stop a poll on their way in, so a poll + * is not what refuses them, and the two Start buttons say Start rather than + * greying while one runs. + */ + exceptPolling?: boolean +} + +/** + * Whatever owns the Modbus client, named, or nothing. + * + * One request at a time is what the client can promise, and every caller that + * puts one on the wire asks this first. A write holds the client from its own + * request to the end of the read back, so `writing` covers a stretch in which + * `reading` is set too, and a caller arriving in it is told about the write. + * + * Here rather than in `modbusClient`, because the question was spelled out in + * four places and three of them were different subsets: main named five + * states, `readWhenMainCan` named the same five, `ReadButton` named the + * connect state plus three, and `RegisterConfig` named two. Nothing was wrong + * at the time, because a register scan draws its dialog over the config bar + * and a unit id scan draws a backdrop over the window, which is layout rather + * than a guard. Main refuses what it cannot allow; this is what lets a control + * grey for the same reason rather than for a subset of it. + * + * The name is read into a sentence, `Cannot ${verb} during ${owner}`, so it + * carries its own article. + */ +export const clientOwner = ( + clientState: ClientState, + { exceptPolling = false }: ClientOwnerQuestion = {} +): string | undefined => { + if (clientState.writing) return 'another write' + if (!exceptPolling && clientState.polling) return 'a poll' + if (clientState.scanningUnitIds) return 'a unit id scan' + if (clientState.scanningRegisters) return 'a register scan' + if (clientState.reading) return 'another read' + return undefined +} + +/** + * The read loop that owns the client, named, or nothing. + * + * Each of the three loops decides for itself when it is done, which is what + * separates them from a single read: a write reads back what it wrote unless a + * loop is already reading, and that is this question rather than `clientOwner`. + */ +export const readLoopOwner = (clientState: ClientState): string | undefined => { + if (clientState.polling) return 'a poll' + if (clientState.scanningUnitIds) return 'a unit id scan' + if (clientState.scanningRegisters) return 'a register scan' + return undefined +} diff --git a/src/shared/conversion.ts b/src/shared/conversion.ts index cb28076c..128b1166 100644 --- a/src/shared/conversion.ts +++ b/src/shared/conversion.ts @@ -1,4 +1,4 @@ -import { bigEndian32, bigEndian64, littleEndian32, littleEndian64 } from './utils' +import { bigEndian32, bigEndian64, littleEndian32, littleEndian64 } from './encoding' import type { RegisterData } from './types' import round from 'lodash/round' import { DateTime } from 'luxon' @@ -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 @@ -151,8 +155,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/default.ts b/src/shared/default.ts index 095c33ae..2532597f 100644 --- a/src/shared/default.ts +++ b/src/shared/default.ts @@ -66,7 +66,8 @@ export const defaultClientState: ClientState = { polling: false, scanningUnitIds: false, scanningRegisters: false, - reading: false + reading: false, + writing: false } export const dummyWords: RegisterDataWords = { diff --git a/src/shared/encoding.ts b/src/shared/encoding.ts new file mode 100644 index 00000000..c1e04ccf --- /dev/null +++ b/src/shared/encoding.ts @@ -0,0 +1,359 @@ +// `types/server.ts` imports `getAddressFitError`, `getValueRangeError` and +// `MAX_UTF8_LENGTH` from here, so this reads the barrel no longer: it would be a +// cycle through `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 { DataTypeSchema, type DataType, type EncodableDataType } from './types/datatype' +import type { RegisterParams } from './types/server' + +/** + * Whether bit `bit` of `word` is set. + * + * `&` is a 32 bit signed operation, so `word & (2 ** bit)` answered false for + * bit 31 of 2 ** 31 and for every bit above it. Both callers ask for bits 0 to + * 15 of a one register bitmap, which `BitMapConfigSchema` and `registerWidth` + * hold them to, so the arithmetic answers a question nothing asks yet. + */ +export const getBit = (word: number, bit: number): boolean => Math.floor(word / 2 ** bit) % 2 === 1 + +/** The width the add dialog offers for a string when the field is left alone. */ +export const DEFAULT_UTF8_LENGTH = 10 + +/** + * The widest string a register map holds, which is the add dialog's own mask. + * + * The protocol puts a ceiling under it either way: FC3 and FC4 answer at most + * 125 registers, so a string wider than that cannot be read in one request. + * The number here is the one `RegisterLengthInput` has always masked to, and + * `RegisterParamsBasePartSchema` states it too, because a config file is the + * other door and the map is the only thing that bounded it there: one string of + * 65536 made `addRegister` send 65536 `register_value` messages in one + * synchronous loop. + */ +export const MAX_UTF8_LENGTH = 124 + +/** + * How many registers a value of this type occupies. + * + * One answer, because this was stated seven times and the copies disagreed on + * `utf8`, so deleting a string erased registers belonging to its neighbours. + * The switch is exhaustive: a new DataType is a type error here. + */ +export const registerWidth = (dataType: DataType, length?: number): number => { + switch (dataType) { + case 'utf8': + return length ?? DEFAULT_UTF8_LENGTH + + case 'int32': + case 'uint32': + case 'float': + case 'unix': + return 2 + + case 'int64': + case 'uint64': + case 'double': + case 'datetime': + return 4 + + case 'none': + case 'int16': + case 'uint16': + case 'bitmap': + return 1 + } +} + +/** + * 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 + * 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). + * + * `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<ArrayBuffer> => { + return Buffer.concat([ + buffer.subarray(offset + 2, offset + 4) as Uint8Array, + buffer.subarray(offset, offset + 2) as Uint8Array + ]) +} + +// Regular most significant word first (big endian) +export const bigEndian64 = (buffer: Buffer, offset: number): Buffer => { + return buffer.subarray(offset, offset + 8) +} + +// Uncommon least significant word first (little endian) +export const littleEndian64 = (buffer: Buffer, offset: number): Buffer<ArrayBuffer> => { + return Buffer.concat([ + buffer.subarray(offset + 6, offset + 8) as Uint8Array, + buffer.subarray(offset + 4, offset + 6) as Uint8Array, + buffer.subarray(offset + 2, offset + 4) as Uint8Array, + buffer.subarray(offset, offset + 2) as Uint8Array + ]) +} + +export const createRegisters = ( + dataType: EncodableDataType, + value: number, + littleEndian: boolean +): [number, ...number[]] => { + let buffer = Buffer.alloc(registerWidth(dataType) * 2) + + switch (dataType) { + case 'int16': + buffer.writeInt16BE(value, 0) + break + case 'uint16': + case 'bitmap': + buffer.writeUInt16BE(value, 0) + break + case 'int32': + buffer.writeInt32BE(value, 0) + if (littleEndian) buffer = littleEndian32(buffer, 0) + break + case 'uint32': + buffer.writeUInt32BE(value, 0) + if (littleEndian) buffer = littleEndian32(buffer, 0) + break + case 'float': + buffer.writeFloatBE(value, 0) + if (littleEndian) buffer = littleEndian32(buffer, 0) + break + case 'int64': + buffer.writeBigInt64BE(BigInt(value), 0) + if (littleEndian) buffer = littleEndian64(buffer, 0) + break + case 'uint64': + buffer.writeBigUInt64BE(BigInt(value), 0) + if (littleEndian) buffer = littleEndian64(buffer, 0) + break + case 'double': + buffer.writeDoubleBE(value, 0) + if (littleEndian) buffer = littleEndian64(buffer, 0) + break + case 'unix': + buffer.writeUInt32BE(value >>> 0, 0) + if (littleEndian) buffer = littleEndian32(buffer, 0) + break + case 'datetime': + buffer = encodeIEC870DateTime(value) + if (littleEndian) buffer = littleEndian64(buffer, 0) + break + } + + // Convert bytes to array of 16-bit words. `registerWidth` is one at its + // smallest over this type, so the first word is always there and the return + // type says so: `_writeRegister` sends registers[0] to FC6. + const registers: [number, ...number[]] = [buffer.readUInt16BE(0)] + for (let i = 2; i < buffer.length; i += 2) { + registers.push(buffer.readUInt16BE(i)) + } + + return registers +} + +/** + * The window an IEC 870-5 datetime can carry, in milliseconds. + * + * The format holds the year as a seven bit offset from 2000, so 2000 through + * 2127 is the whole of it and a year outside has no encoding at all. + * `parseIEC870DateTime` cannot say so, because the same seven bits mask a wrong + * year into a right-looking one on the way back, which is why this is the + * encoder's question. Both ends are stated once: `getMinMaxValues` hands them to + * the mask and `encodeIEC870DateTime` clamps to them, and a mask wider than the + * clamp accepts a date the register does not get. + */ +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). + * + * Only the low end clamped, and the decoder masks the year to seven bits, so + * 2200 came back as 2072 with nothing to say it had moved. + */ +export const encodeIEC870DateTime = (timestampMs: number): Buffer<ArrayBuffer> => { + const dt = new Date(Math.min(Math.max(timestampMs, IEC870_MIN_MS), IEC870_MAX_MS)) + const buf = Buffer.alloc(8) + buf.writeUInt16BE(dt.getUTCFullYear() - 2000, 0) + buf.writeUInt16BE(((dt.getUTCMonth() + 1) << 8) | dt.getUTCDate(), 2) + buf.writeUInt16BE((dt.getUTCHours() << 8) | dt.getUTCMinutes(), 4) + buf.writeUInt16BE(dt.getUTCSeconds() * 1000 + dt.getUTCMilliseconds(), 6) + return buf +} + +/** + * Encode a UTF-8 string to Modbus registers (null-padded). + */ +export const createStringRegisters = (text: string, registerCount: number): number[] => { + const byteLength = registerCount * 2 + const buf = Buffer.alloc(byteLength, 0) + buf.write(text, 'utf-8') + const registers: number[] = [] + for (let i = 0; i < byteLength; i += 2) { + registers.push(buf.readUInt16BE(i)) + } + return registers +} + +export const getMinMaxValues = (dataType: DataType): { min: number; max: number } => { + switch (dataType) { + case 'int16': + return { min: -32768, max: 32767 } + case 'uint16': + case 'bitmap': + return { min: 0, max: 65535 } + case 'int32': + return { min: -2147483648, max: 2147483647 } + case 'uint32': + return { min: 0, max: 4294967295 } + case 'int64': + return { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } // JavaScript safe integer range + case 'uint64': + return { min: 0, max: Number.MAX_SAFE_INTEGER } // Max safe integer in JavaScript for unsigned 64-bit + case 'float': + return { min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY } // Closest approximation for float + case 'double': + return { min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY } // Double in JS is the same as float + case 'unix': + return { min: 0, max: 4294967295 } // uint32 range (seconds since epoch) + case 'datetime': + // 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 } // Neither holds a number + } +} + +/** + * What a server register entry holds, which is not always a number. + * + * A decimal string for the three types whose composite fills 64 bits as an + * integer. It was `z.number()` for all of them: `applyRegisterValue` read the + * composite back with `getBigUint64` and stored `Number(...)`, so four words of + * 0xFFFF came out 18446744073709552000 rather than 18446744073709551615, and + * `setBigUint64` takes its argument modulo 2 ** 64 rather than throwing, so the + * next single word write on that entry collapsed it to the low word alone. + * Measured: one more word after the flush left the entry holding 1. + * + * A string rather than a `bigint` because the store is persisted through + * `JSON.stringify`, which refuses a bigint outright. + */ +export type ServerRegisterValue = number | string + +/** + * The types whose composite fills 64 bits as an integer. + * + * `applyRegisterValue` reads these three back with `getBigInt64` or + * `getBigUint64`. `double` is the fourth type four registers wide and is not + * here: its composite is a float64, which is exactly what a JS number is. + */ +export const holdsExact64Bits = (dataType: DataType): boolean => + dataType === 'int64' || dataType === 'uint64' || dataType === 'datetime' + +/** + * The exact composite a stored value stands for, or nothing when it stands for + * none. + * + * Takes a `bigint` as well, because `ServerDelayedSetter` holds the composite + * it last folded and that is the value the next word write reads first. + * + * `undefined` rather than `0n`, so a caller merging one word into a composite + * aborts rather than rebuilding it from zero: the other three registers are + * what zero would cost. `BigInt` throws on a fraction and on anything that is + * not digits, and `ServerRegisterEntrySchema` takes a fractional number for a + * 64 bit type, so a hand-edited config reaches this. + */ +export const toExact64Bits = (value: ServerRegisterValue | bigint): bigint | undefined => { + try { + return BigInt(value) + } catch { + return undefined + } +} + +/** + * The types `createRegisters` throws on for a value outside their range. + * + * Measured against the writers it calls. `writeUInt16BE(1.5)` and + * `writeInt32BE(1.5)` truncate, `writeFloatBE(1e300)` and `writeDoubleBE` take + * anything, `unix` is written through `value >>> 0` and `datetime` through + * `encodeIEC870DateTime`, which clamps to the format's own window. What is left + * is the seven below, where `writeUInt32BE(-1)` and + * `writeBigUInt64BE(2n ** 64n)` both answer ERR_OUT_OF_RANGE. `none` and `utf8` + * are not `EncodableDataType` and never reach the encoder at all. + */ +const RANGE_CHECKED_TYPES: readonly DataType[] = [ + 'int16', + 'uint16', + 'bitmap', + 'int32', + 'uint32', + 'int64', + 'uint64' +] + +/** + * Why `createRegisters` cannot encode `value` as `dataType`, or nothing. + * + * `RegisterParamsSchema` bounded the address and left the value bare, so a + * config file carrying `{ dataType: 'uint16', value: 70000 }` reached + * `writeUInt16BE` and threw out of `addRegister`. The ranges are + * `getMinMaxValues`, which the add dialog's mask already reads, so the file and + * the field now answer the same question. + * + * A 64 bit value also has to be a whole number, because `BigInt(1.5)` throws + * where `writeUInt16BE(1.5)` truncates. + */ +export const getValueRangeError = (dataType: DataType, value: number): string | undefined => { + if (!RANGE_CHECKED_TYPES.includes(dataType)) return undefined + + if ((dataType === 'int64' || dataType === 'uint64') && !Number.isInteger(value)) { + return `A ${dataType} value has to be a whole number` + } + + const { min, max } = getMinMaxValues(dataType) + if (value < min || value > max) { + return `A ${dataType} value has to be between ${min} and ${max}` + } + + return undefined +} + +export const getUsedAddresses = (registers: RegisterParams[]): number[] => { + const addressSet = new Set<number>() + registers.forEach((p) => { + const size = registerWidth(p.dataType, p.length) + + for (let i = 0; i < size; i++) { + addressSet.add(p.address + i) + } + }) + return Array.from(addressSet) +} + +export function getAddressFitError(dataType: DataType, address: number, length?: number): boolean { + return address + registerWidth(dataType, length) - 1 > 65535 +} diff --git a/src/shared/index.ts b/src/shared/index.ts index e0e4aa89..52293f63 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,7 +1,9 @@ export * from './types' export * from './default' export * from './utils' +export * from './encoding' export * from './conversion' export * from './addressGrouping' +export * from './clientOwner' export * from './repairPersisted' export * from './migrations' 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/__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/__tests__/registerAddress.test.ts b/src/shared/migrations/__tests__/registerAddress.test.ts index e566b6a1..26278b56 100644 --- a/src/shared/migrations/__tests__/registerAddress.test.ts +++ b/src/shared/migrations/__tests__/registerAddress.test.ts @@ -3,17 +3,20 @@ import { AddRegisterParamsSchema, RemoveRegisterParamsSchema, ServerConfigSchema, + ServerRegistersSchema, SyncRegisterValueParamsSchema } from '../../types/server' import { CURRENT_SERVER_ZUSTAND_VERSION, migrateServerState } from '../server/zustand' +import { migrateServerConfig } from '../server/config' import { CURRENT_CLIENT_ZUSTAND_VERSION, migrateClientState } from '../client/zustand' import { dropUnmappableRegisters, dropUnservableRegisters } from '../shared' -/** The last store version whose blobs can carry a register outside the map. */ -const LAST_VERSION_ACCEPTING_ANY_ADDRESS = 4 - -/** The last store version whose blobs can carry a generator interval of 0. */ -const LAST_VERSION_ACCEPTING_ANY_INTERVAL = 5 +/** + * What 2.3.0 wrote, and so the highest version a blob off this branch carries. + * Every step below runs on it, because the steps between were collapsed into + * one and never reached a release. + */ +const SHIPPED_SERVER_VERSION = 3 /** A register the add, sync and config paths all take, at `address`. */ const params = (address: number): Record<string, unknown> => ({ @@ -67,27 +70,56 @@ const configAccepts = (address: number): boolean => /** A persisted blob with one register per address given, on one server. */ const persistedWith = (addresses: number[]): Record<string, unknown> => ({ - serverRegisters: { + servers: { u: { - '1': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: Object.fromEntries( - addresses.map((address) => [String(address), { value: 1, params: params(address) }]) - ) + registers: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: Object.fromEntries( + addresses.map((address) => [String(address), { value: 1, params: params(address) }]) + ) + } } } } }) +/** + * The same registers as a shipped blob carried them: six records keyed by uuid. + * + * `migrateServerState` folds those into one record per server for any version + * below 4, so a test driving the migration hands it the shape 2.3.0 wrote and + * reads the result back through `unitsOf`. + */ +const asShipped = (state: Record<string, unknown>): Record<string, unknown> => { + const servers = state.servers as Record<string, Record<string, unknown>> + return { + uuids: Object.keys(servers), + serverRegisters: Object.fromEntries( + Object.entries(servers).map(([uuid, server]) => [uuid, server.registers]) + ), + usedAddresses: Object.fromEntries( + Object.entries(servers).map(([uuid, server]) => [uuid, server.usedAddresses ?? {}]) + ) + } +} + +/** The units of the server keyed `u`, in a blob the drop or the migration walked. */ +const unitsOf = (state: Record<string, unknown>): Record<string, unknown> => { + const servers = state.servers as Record<string, Record<string, unknown>> | undefined + return (servers?.u?.registers ?? {}) as Record<string, unknown> +} + /** The holding registers of a migrated blob, or a failure naming what is missing. */ const migratedHoldingRegisters = (state: Record<string, unknown>): Record<string, unknown> => { - const perUuid = state.serverRegisters as Record<string, unknown> | undefined - const perUnit = perUuid?.u as Record<string, unknown> | undefined + const servers = state.servers as Record<string, unknown> | undefined + const server = servers?.u as Record<string, unknown> | undefined + const perUnit = server?.registers as Record<string, unknown> | undefined const registers = perUnit?.['1'] as Record<string, unknown> | undefined const holding = registers?.holding_registers as Record<string, unknown> | undefined - if (!holding) throw new Error('the migrated blob has no serverRegisters.u.1.holding_registers') + if (!holding) throw new Error('the migrated blob has no servers.u.registers.1.holding_registers') return holding } @@ -166,15 +198,12 @@ describe('a persisted register outside the map', () => { }) it('is behind a version the store has moved past', () => { - expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(LAST_VERSION_ACCEPTING_ANY_ADDRESS) + expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(SHIPPED_SERVER_VERSION) }) // The drop on its own is not the store's behaviour; the step in `migrate` is. - it('is dropped by the migration a v4 blob runs', () => { - const state = migrateServerState( - persistedWith([100, 70000]), - LAST_VERSION_ACCEPTING_ANY_ADDRESS - ) + it('is dropped by the migration a shipped blob runs', () => { + const state = migrateServerState(asShipped(persistedWith([100, 70000])), SHIPPED_SERVER_VERSION) expect(Object.keys(migratedHoldingRegisters(state))).toEqual(['100']) }) @@ -190,16 +219,32 @@ describe('the drop on its own', () => { // A hand-edited store is where a null in the middle of the walk comes from, // and reading a field off it throws rather than failing a schema. + // + // A unit the walk does reach comes back with the four register types, which + // is what keeps one missing key off the whole field. The `coils: null` is + // the shape that is left where it is, because replacing it is throwing + // something away rather than naming what was never there. it('walks past a null where a server, a unit or a register type should be', () => { const state: Record<string, unknown> = { - serverRegisters: { u: null, v: { '1': null }, w: { '1': { coils: null } } } + servers: { + u: null, + v: { registers: null }, + w: { registers: { '1': null } }, + x: { registers: { '1': { coils: null } } } + } } dropUnservableRegisters(state) - expect(state.serverRegisters).toEqual({ + expect(state.servers).toEqual({ u: null, - v: { '1': null }, - w: { '1': { coils: null } } + v: { registers: null, usedAddresses: {} }, + w: { registers: { '1': null }, usedAddresses: {} }, + x: { + registers: { + '1': { coils: null, discrete_inputs: {}, input_registers: {}, holding_registers: {} } + }, + usedAddresses: { '1': { input_registers: [], holding_registers: [] } } + } }) }) @@ -207,25 +252,244 @@ describe('the drop on its own', () => { // skipped rather than failed for it. it('keeps a coil that has no parameters', () => { const state: Record<string, unknown> = { - serverRegisters: { u: { '1': { coils: { '3': { value: true }, '70000': { value: true } } } } } + servers: { + u: { registers: { '1': { coils: { '3': { value: true }, '70000': { value: true } } } } } + } } dropUnservableRegisters(state) - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const unit = perUuid.u?.['1'] as Record<string, unknown> + const unit = unitsOf(state)['1'] as Record<string, unknown> expect(Object.keys(unit.coils as Record<string, unknown>)).toEqual(['3']) }) it('drops an entry that is not an object', () => { const state: Record<string, unknown> = { - serverRegisters: { u: { '1': { coils: { '3': 'true' } } } } + servers: { u: { registers: { '1': { coils: { '3': 'true' } } } } } } dropUnservableRegisters(state) - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const unit = perUuid.u?.['1'] as Record<string, unknown> + const unit = unitsOf(state)['1'] as Record<string, unknown> expect(Object.keys(unit.coils as Record<string, unknown>)).toEqual([]) }) + + // A key of a register type the entry schemas do not name. `ServerRegisters` + // is a `z.object`, which strips a key it does not declare, so the field + // survives it either way and the drop has nothing to buy here. + // Every entry under it, whatever the key says. The walk is over the four + // types this version names, so a fifth from a newer Modbux is never read, + // and `ServerRegistersSchema` strips the key it does not declare. + it('leaves a register type it does not know alone', () => { + const state: Record<string, unknown> = { + servers: { u: { registers: { '1': { file_records: { '3': { value: 1 }, '70000': {} } } } } } + } + dropUnservableRegisters(state) + + const unit = unitsOf(state)['1'] as Record<string, unknown> + expect(Object.keys(unit.file_records as Record<string, unknown>)).toEqual(['3', '70000']) + }) + + // `ServerRegistersSchema` is a `z.object` naming all four, so a unit that + // carries three of them fails the whole `serverRegistersPerUnit` field, and + // a walk over the keys that are there never sees the one that is missing. + it('gives a unit the register types it is missing', () => { + const state: Record<string, unknown> = { + servers: { u: { registers: { '1': { holding_registers: {} } } } } + } + dropUnservableRegisters(state) + + expect(ServerRegistersSchema.safeParse(unitsOf(state)['1']).success).toBe(true) + }) + + it('leaves a register type holding something that is not a map where it is', () => { + const state: Record<string, unknown> = { + servers: { u: { registers: { '1': { coils: 'not a map' } } } } + } + dropUnservableRegisters(state) + + const unit = unitsOf(state)['1'] as Record<string, unknown> + expect(unit.coils).toBe('not a map') + }) +}) + +/** + * A number register with no parameters, or none the schema takes. + * + * `ServerRegisterEntrySchema` requires both `value` and `params`, and the drop + * read `params` alone: an entry that carried none was taken for a boolean one + * and kept. So the field the drop exists to save was reset for exactly the + * register it was walking to save. + */ +describe('a persisted number register the entry schema refuses', () => { + /** The holding and coil maps of a blob, after the drop has walked it. */ + const dropped = ( + holding: Record<string, unknown>, + coils: Record<string, unknown> = {} + ): { holding: string[]; coils: string[] } => { + const state: Record<string, unknown> = { + servers: { + u: { + registers: { + '1': { coils, discrete_inputs: {}, input_registers: {}, holding_registers: holding } + } + } + } + } + dropUnservableRegisters(state) + + const unit = migratedHoldingRegisters(state) + const registers = unitsOf(state)['1'] as Record<string, unknown> + return { + holding: Object.keys(unit), + coils: Object.keys(registers.coils as Record<string, unknown>) + } + } + + it('goes when it carries no parameters at all', () => { + expect(dropped({ '0': { value: 1 }, '1': { value: 1, params: params(1) } }).holding).toEqual([ + '1' + ]) + }) + + it('goes when it carries parameters and no value', () => { + expect( + dropped({ '0': { params: params(0) }, '1': { value: 1, params: params(1) } }).holding + ).toEqual(['1']) + }) + + it('goes when its value is a string that is not a decimal integer', () => { + expect(dropped({ '0': { value: 'a lot', params: params(0) } }).holding).toEqual([]) + }) + + it('keeps a value written as a decimal string, which is how 64 bits is stored', () => { + expect(dropped({ '0': { value: '9007199254740993', params: params(0) } }).holding).toEqual([ + '0' + ]) + }) + + // The other half of the same question: a boolean entry is judged by its own + // schema, so a coil holding a number is not a coil. + it('goes for a coil whose value is not a boolean', () => { + expect(dropped({}, { '3': { value: 1 }, '4': { value: true } }).coils).toEqual(['4']) + }) + + it('keeps a coil carrying a comment beside its value', () => { + expect(dropped({}, { '3': { value: true, comment: 'pump' } }).coils).toEqual(['3']) + }) + + // What the drop is for: the field survives the register. + it('leaves a field the schema takes', () => { + const state: Record<string, unknown> = { + servers: { + u: { + registers: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { '0': { value: 1 }, '1': { value: 1, params: params(1) } } + } + } + } + } + } + dropUnservableRegisters(state) + + expect(ServerRegistersSchema.safeParse(unitsOf(state)['1']).success).toBe(true) + }) +}) + +/** + * The key and `params.address` are one address written twice. + * + * `ServerRegisters` draws `params.address` and `syncRegistersWithBackend` sends + * it; `setRegisterValue` and `removeRegister` look the entry up by key. A file + * or a blob where the two disagree serves the register at one address and + * answers the grid at the other. + */ +describe('a register whose key and parameters disagree', () => { + /** The blob of `persistedWith`, with the entry at `key` naming `address`. */ + const persistedSaying = (key: string, address: number): Record<string, unknown> => { + const state = persistedWith([]) + migratedHoldingRegisters(state)[key] = { value: 1, params: params(address) } + return state + } + + it('is refused by name in a config file', () => { + const result = ServerConfigSchema.safeParse({ + version: 2, + modbuxVersion: '2.3.0', + name: 'bench', + littleEndian: false, + serverRegistersPerUnit: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { '5': { value: 1, params: params(9) } } + } + } + }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual([ + 'serverRegistersPerUnit', + '1', + 'holding_registers', + '5', + 'params', + 'address' + ]) + }) + + it('is taken by the config path when the two agree', () => { + expect(configAccepts(9)).toBe(true) + }) + + // The old version branch migrates and then parses, with no drop in between, + // so the file is refused rather than salvaged. That is what every other rule + // on that branch does with a register this version cannot serve. + it('is refused by name in a v2 file the migration walks', () => { + const v2 = JSON.stringify({ + version: 2, + modbuxVersion: '2.3.0', + name: 'bench', + littleEndian: false, + serverRegistersPerUnit: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { '5': { value: 1, params: params(9) } } + } + } + }) + + expect(() => migrateServerConfig(v2)).toThrowError(/holding_registers\.5\.params\.address/) + }) + + it('goes from a persisted blob, and the registers beside it stay', () => { + const state = persistedWith([100]) + migratedHoldingRegisters(state)['5'] = { value: 1, params: params(9) } + dropUnservableRegisters(state) + + expect(Object.keys(migratedHoldingRegisters(state))).toEqual(['100']) + }) + + // `RegisterAddressKeySchema` takes a key of digits, and `String(7)` is not + // `'007'`, so the lookups the store does find nothing under it. + it('goes for a key that is the same number written differently', () => { + const state = persistedSaying('007', 7) + dropUnservableRegisters(state) + + expect(Object.keys(migratedHoldingRegisters(state))).toEqual([]) + }) + + it('stays when the two say the same address', () => { + const state = persistedSaying('7', 7) + dropUnservableRegisters(state) + + expect(Object.keys(migratedHoldingRegisters(state))).toEqual(['7']) + }) }) describe('a persisted generator the interval floor refuses', () => { @@ -241,23 +505,24 @@ describe('a persisted generator the interval floor refuses', () => { }) const persistedGenerators = (intervals: number[]): Record<string, unknown> => ({ - serverRegisters: { + servers: { u: { - '1': { - holding_registers: Object.fromEntries( - intervals.map((interval, index) => [ - String(index), - { value: 1, params: { ...generator(interval), address: index } } - ]) - ) + registers: { + '1': { + holding_registers: Object.fromEntries( + intervals.map((interval, index) => [ + String(index), + { value: 1, params: { ...generator(interval), address: index } } + ]) + ) + } } } } }) const holdingRegisters = (state: Record<string, unknown>): Record<string, unknown> => { - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const unit = perUuid.u?.['1'] as Record<string, unknown> + const unit = unitsOf(state)['1'] as Record<string, unknown> return unit.holding_registers as Record<string, unknown> } @@ -281,7 +546,7 @@ describe('a persisted generator the interval floor refuses', () => { }) it('is behind a version the store has moved past', () => { - expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(LAST_VERSION_ACCEPTING_ANY_INTERVAL) + expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(SHIPPED_SERVER_VERSION) }) }) @@ -304,36 +569,40 @@ describe('the used addresses the drop writes back', () => { }) const persisted = (): Record<string, unknown> => ({ - usedAddresses: { - u: { - '1': { input_registers: [], holding_registers: [100, 101, 200] }, - '2': { input_registers: [], holding_registers: [300, 400] } - } - }, - serverRegisters: { + servers: { u: { - '1': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: { - '100': { value: 1, params: wideRegister(100) }, - '200': { value: 1, params: refusedGenerator(200) } - } + usedAddresses: { + '1': { input_registers: [], holding_registers: [100, 101, 200] }, + '2': { input_registers: [], holding_registers: [300, 400] } }, - '2': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: { '300': { value: 1, params: params(300) } } + registers: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + '100': { value: 1, params: wideRegister(100) }, + '200': { value: 1, params: refusedGenerator(200) } + } + }, + '2': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { '300': { value: 1, params: params(300) } } + } } } } }) + /** The server the blob holds, so a test can take a field off it. */ + const serverU = (state: Record<string, unknown>): Record<string, unknown> => + (state.servers as Record<string, Record<string, unknown>>).u as Record<string, unknown> + const usedHolding = (state: Record<string, unknown>, unitId: string): unknown => { - const perUuid = state.usedAddresses as Record<string, Record<string, unknown>> - const unit = perUuid.u?.[unitId] as Record<string, unknown> + const perUnit = serverU(state).usedAddresses as Record<string, Record<string, unknown>> + const unit = perUnit[unitId] as Record<string, unknown> return unit.holding_registers } @@ -360,7 +629,7 @@ describe('the used addresses the drop writes back', () => { // reading a unit off a missing map throws rather than failing a schema. it('writes the map a blob carries no field for', () => { const state = persisted() - delete state.usedAddresses + delete serverU(state).usedAddresses dropUnservableRegisters(state) expect(usedHolding(state, '1')).toEqual([100, 101]) @@ -370,23 +639,22 @@ describe('the used addresses the drop writes back', () => { // told, and it cannot name a value that was replaced before it looked. it('leaves a map that is not an object for the repair to name', () => { const state = persisted() - state.usedAddresses = 'nonsense' + serverU(state).usedAddresses = 'nonsense' dropUnservableRegisters(state) - expect(state.usedAddresses).toBe('nonsense') + expect(serverU(state).usedAddresses).toBe('nonsense') }) - // The map is one persisted field, so a key its schema refuses costs every - // unit in it. The same key costs `serverRegisters` alone. + // The map is one field of one server, so a key its schema refuses costs + // every unit in it. The same key costs that server's `registers` alone. it('writes nothing for a unit id outside the map', () => { const state = persisted() - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const registers = perUuid.u as Record<string, unknown> + const registers = serverU(state).registers as Record<string, unknown> registers['300'] = registers['2'] dropUnservableRegisters(state) - const used = state.usedAddresses as Record<string, Record<string, unknown>> - expect(Object.keys(used.u ?? {})).toEqual(['1', '2']) + const used = serverU(state).usedAddresses as Record<string, unknown> + expect(Object.keys(used)).toEqual(['1', '2']) }) }) @@ -403,21 +671,22 @@ describe('a persisted register the encoder cannot serve', () => { const persisted = ( registers: Record<string, Record<string, unknown>> ): Record<string, unknown> => ({ - serverRegisters: { + servers: { u: { - '1': { - coils: {}, - discrete_inputs: {}, - input_registers: {}, - holding_registers: registers + registers: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: registers + } } } } }) const holding = (state: Record<string, unknown>): Record<string, unknown> => { - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const unit = perUuid.u?.['1'] as Record<string, unknown> + const unit = unitsOf(state)['1'] as Record<string, unknown> return unit.holding_registers as Record<string, unknown> } @@ -456,17 +725,17 @@ describe('a persisted register the encoder cannot serve', () => { }) // The drop on its own is not the store's behaviour; the step in `migrate` is. - it('is dropped by the migration a v6 blob runs', () => { + it('is dropped by the migration a shipped blob runs', () => { const state = migrateServerState( - persisted({ '0': register(0, { value: 70000 }), '10': register(10, {}) }), - 6 + asShipped(persisted({ '0': register(0, { value: 70000 }), '10': register(10, {}) })), + SHIPPED_SERVER_VERSION ) expect(Object.keys(holding(state))).toEqual(['10']) }) it('is behind a version the store has moved past', () => { - expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(6) + expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(SHIPPED_SERVER_VERSION) }) }) @@ -497,20 +766,19 @@ describe('a persisted 64 bit value', () => { }) const heldValue = (state: Record<string, unknown>): unknown => { - const perUuid = state.serverRegisters as Record<string, Record<string, unknown>> - const unit = perUuid.u?.['1'] as Record<string, unknown> + const unit = unitsOf(state)['1'] as Record<string, unknown> const holding = unit.holding_registers as Record<string, Record<string, unknown>> return holding['10']?.value } it.each(['uint64', 'int64', 'datetime'])('becomes a string for %s', (dataType) => { - const state = migrateServerState(persisted(dataType, 72623859790382850), 7) + const state = migrateServerState(persisted(dataType, 72623859790382850), SHIPPED_SERVER_VERSION) expect(heldValue(state)).toBe('72623859790382850') }) it.each(['uint16', 'int32', 'double', 'float', 'unix'])('stays a number for %s', (dataType) => { - const state = migrateServerState(persisted(dataType, 1234), 7) + const state = migrateServerState(persisted(dataType, 1234), SHIPPED_SERVER_VERSION) expect(heldValue(state)).toBe(1234) }) @@ -522,25 +790,28 @@ describe('a persisted 64 bit value', () => { // schema takes either as a number and neither as a string, so neither can be // written back as it is. it.each([0.5, 1e21, -0.25])('repairs a value of %p that no composite reads', (value) => { - const state = migrateServerState(persisted('uint64', value), 7) + const state = migrateServerState(persisted('uint64', value), SHIPPED_SERVER_VERSION) expect(heldValue(state)).toBe('0') }) it('leaves a value already stored as a string alone', () => { - const state = migrateServerState(persisted('uint64', '18446744073709551615'), 7) + const state = migrateServerState( + persisted('uint64', '18446744073709551615'), + SHIPPED_SERVER_VERSION + ) expect(heldValue(state)).toBe('18446744073709551615') }) it('is behind a version the store has moved past', () => { - expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(7) + expect(CURRENT_SERVER_ZUSTAND_VERSION).toBeGreaterThan(SHIPPED_SERVER_VERSION) }) }) describe('a persisted mapping entry outside the map', () => { - /** The last client store version whose blobs can carry any numeric key. */ - const LAST_VERSION_ACCEPTING_ANY_KEY = 3 + /** What 2.3.0 wrote, under the name `CURRENT_ROOT_ZUSTAND_VERSION`. */ + const SHIPPED_CLIENT_VERSION = 2 const persistedMapping = (addresses: string[]): Record<string, unknown> => ({ registerMapping: { @@ -581,14 +852,24 @@ describe('a persisted mapping entry outside the map', () => { }) it('is behind a version the store has moved past', () => { - expect(CURRENT_CLIENT_ZUSTAND_VERSION).toBeGreaterThan(LAST_VERSION_ACCEPTING_ANY_KEY) + expect(CURRENT_CLIENT_ZUSTAND_VERSION).toBeGreaterThan(SHIPPED_CLIENT_VERSION) }) // The drop on its own is not the store's behaviour; the step in `migrate` is. - it('is dropped by the migration a v3 blob runs', () => { + it('is dropped by the migration a shipped blob runs', () => { + const state = migrateClientState(persistedMapping(['100', '70000']), SHIPPED_CLIENT_VERSION) + + expect(Object.keys(holdingRegisters(state))).toEqual(['100']) + }) + + // persist calls `migrate` for a version above the current one as well, and + // a blob from a newer Modbux is where an entry this build's schema refuses + // comes from. The step ran for the versions below alone, so one such entry + // cost `repairPersisted` the whole mapping. + it('is dropped by the migration a blob from a newer Modbux runs', () => { const state = migrateClientState( persistedMapping(['100', '70000']), - LAST_VERSION_ACCEPTING_ANY_KEY + CURRENT_CLIENT_ZUSTAND_VERSION + 5 ) expect(Object.keys(holdingRegisters(state))).toEqual(['100']) diff --git a/src/shared/migrations/__tests__/serverState.test.ts b/src/shared/migrations/__tests__/serverState.test.ts index 31839631..380f01c1 100644 --- a/src/shared/migrations/__tests__/serverState.test.ts +++ b/src/shared/migrations/__tests__/serverState.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { migrateServerState } from '../server/zustand' +import { CURRENT_SERVER_ZUSTAND_VERSION, migrateServerState } from '../server/zustand' /** * The steps `migrateServerState` runs, asked of the function rather than of the @@ -36,20 +36,65 @@ describe('the migration a server store blob runs', () => { } }) + /** One server out of a migrated blob, which keys them by uuid. */ + const server = (state: Record<string, unknown>, uuid = 'server-1'): Record<string, unknown> => { + const servers = state.servers as Record<string, Record<string, unknown>> | undefined + const found = servers?.[uuid] + if (!found) throw new Error(`the migrated blob has no server ${uuid}`) + return found + } + + /** The unit map of that server: unit id, register type, address, entry. */ + type Units = Record<string, Record<string, Record<string, { params: Record<string, unknown> }>>> + + const units = (state: Record<string, unknown>): Units => server(state).registers as Units + it('lifts the byte order off each register onto its server', () => { const state = migrateServerState(v1(), 1) - const byUuid = state.littleEndian as Record<string, boolean> - expect(byUuid['server-1']).toBe(true) + expect(server(state).littleEndian).toBe(true) - const servers = state.serverRegisters as Record< - string, - Record<string, Record<string, Record<string, { params: Record<string, unknown> }>>> - > - const params = servers['server-1']?.['1']?.holding_registers?.['0']?.params + const params = units(state)['1']?.holding_registers?.['0']?.params expect(params && 'littleEndian' in params).toBe(false) }) + // Six records keyed by uuid, with `uuids` beside them as the list that was + // supposed to agree with all six. A uuid the list did not carry was never on + // screen, so the fold takes the list and the rest goes. + it('folds the six records a shipped blob keeps into one record of servers', () => { + const state = migrateServerState( + { + uuids: ['server-1'], + port: { 'server-1': '5020', 'never-listed': '503' }, + unitId: { 'server-1': '7' }, + name: { 'server-1': 'bench' }, + littleEndian: { 'server-1': true }, + serverRegisters: { 'server-1': {} }, + usedAddresses: { 'server-1': {} } + }, + 3 + ) + + expect(Object.keys(state.servers as object)).toEqual(['server-1']) + expect(server(state)).toEqual({ + port: '5020', + unitId: '7', + name: 'bench', + littleEndian: true, + registers: {}, + usedAddresses: {} + }) + expect(Object.keys(state)).toEqual(['servers']) + }) + + // The list is what is lost then, and the servers are what is left. + it('keys off the six records where the list is not a list of uuids', () => { + const state = migrateServerState({ uuids: 'nope', port: { 'server-1': '5020' } }, 3) + + expect(Object.keys(state.servers as object)).toEqual(['server-1']) + expect(server(state).port).toBe('5020') + }) + it('gives a blob with no server mode the TCP one and a serial config', () => { const state = migrateServerState({}, 2) @@ -65,17 +110,66 @@ describe('the migration a server store blob runs', () => { 2 ) - const servers = state.serverRegisters as Record< - string, - Record<string, Record<string, Record<string, { value: boolean }>>> - > - expect(servers['server-1']?.['1']?.coils?.['3']).toEqual({ value: true }) + expect(units(state)['1']?.coils?.['3']).toEqual({ value: true }) }) // Nothing above runs for a blob already at the current version. it('leaves a current blob alone', () => { - const state = migrateServerState({ serverMode: 'rtu' }, 6) + const state = migrateServerState({ serverMode: 'rtu' }, CURRENT_SERVER_ZUSTAND_VERSION) expect(state).toEqual({ serverMode: 'rtu' }) }) + + /** + * A blob from a newer Modbux, which persist hands this the same way. + * + * `repairPersisted` reads it field by field with `savedByNewerVersion` set, + * so a newer blob is where a value this build's schemas refuse comes from, + * and the steps that save a field from one such value ran for the versions + * below this one alone. `migrateServerConfig` has had its own + * `detectedVersion > CURRENT` branch since the file migration was written. + */ + describe('a blob claiming a version above this one', () => { + const future = (): Record<string, unknown> => ({ + serialConfig: { options: { parity: 'mark', baudRate: '9600' } }, + servers: { + 'server-1': { + registers: { + '1': { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + '0': { value: 1, params: { address: 70000 } }, + '5': { + value: 1, + params: { + address: 5, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } + } + } + } + } + } + } + }) + + it('drops the register this build cannot serve and keeps the one beside it', () => { + const state = migrateServerState(future(), CURRENT_SERVER_ZUSTAND_VERSION + 5) + + expect(Object.keys(units(state)['1']?.holding_registers ?? {})).toEqual(['5']) + }) + + it('replaces a parity the serial binding refuses', () => { + const state = migrateServerState(future(), CURRENT_SERVER_ZUSTAND_VERSION + 5) + + const serialConfig = state.serialConfig as Record<string, Record<string, unknown>> + expect(serialConfig.options?.parity).toBe('none') + expect(serialConfig.options?.baudRate).toBe('9600') + }) + }) }) diff --git a/src/shared/migrations/client/config.ts b/src/shared/migrations/client/config.ts index 0604b08e..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) { @@ -67,12 +69,11 @@ export function migrateClientConfig(raw: string): MigrationResult<RegisterMapCon } // A config from a newer Modbux keeps the fields it still shares with this - // one. The branch cast the parsed JSON straight to `RegisterMapConfig` and - // returned it, and `LoadButton` calls + // one. It is repaired rather than cast: `LoadButton` calls // `replaceRegisterMapping(config.registerMapping)` with no gate at all, so - // `{ version: 9, registerMapping: 'nope' }` was written into the persisted - // store and flushed to main. The same answer a persisted store gets, and - // `reset` is what the warning now says. + // `{ version: 9, registerMapping: 'nope' }` would reach the persisted store + // and main. The same answer a persisted store gets, and `reset` is what the + // warning says. if (detectedVersion > CURRENT_CLIENT_CONFIG_VERSION) { // Entry by entry first. `registerMapping` is one field, and it is the one // thing in the client store built by hand, so one address this version diff --git a/src/shared/migrations/client/zustand.ts b/src/shared/migrations/client/zustand.ts index cf4a8b44..79d7f843 100644 --- a/src/shared/migrations/client/zustand.ts +++ b/src/shared/migrations/client/zustand.ts @@ -1,6 +1,10 @@ import { dropUnmappableRegisters, repairPersistedParity } from '../shared' -export const CURRENT_CLIENT_ZUSTAND_VERSION = 4 +/** + * One number above what the last release wrote. 2.3.0 shipped 2, under the name + * `CURRENT_ROOT_ZUSTAND_VERSION` and the key `root.zustand`. + */ +export const CURRENT_CLIENT_ZUSTAND_VERSION = 3 /** Where the client store keeps its state. */ export const CLIENT_ZUSTAND_STORAGE_KEY = 'client.zustand' @@ -18,17 +22,22 @@ export function migrateClientState( ): Record<string, unknown> { const state = persistedState as Record<string, unknown> - 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) { + // v2→v3, one step because 2 is what the last release wrote: the RTU parity + // the serial binding refuses, and mapping entries at an address outside the + // 16 bit map. + // + // Any version but this one, rather than the ones below it, which is the + // reason `migrateServerState` gives for the same shape: persist calls this + // for a version above the current one too, and a blob from a newer Modbux is + // where a parity this enum does not name comes from. Without it one such + // value costs `repairPersisted` the whole field, and `registerMapping` is + // the one thing in this store built by hand. + if (version !== CURRENT_CLIENT_ZUSTAND_VERSION) { repairPersistedParity(state, 'connectionConfig', 'rtu', 'options') - } - - // v3→v4: mapping entries at an address outside the 16 bit map - if (version < 4) { dropUnmappableRegisters(state) } diff --git a/src/shared/migrations/server/config.ts b/src/shared/migrations/server/config.ts index bfeb4aea..3405ac57 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 @@ -12,11 +11,13 @@ import { migrateBoolShapeForUnit, objectValues, dropUnservableConfigRegisters, + isRecord, parseConfigFile, + refuseOtherSidesConfig, renameLegacyRegisterTypeKeys, stringifyExact64BitValues } from '../shared' -import { V1ServerConfig, extractGlobalEndianness } from './shared' +import { V1ServerConfig, V1ServerRegisters, extractGlobalEndianness } from './v1' import { repairPersisted } from '../../repairPersisted' /** @@ -44,28 +45,38 @@ 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 + // type turns a `coils` holding a number into an empty one and passes it, + // where the helper leaves the number for `ServerConfigSchema` to refuse. + migrateBoolShapeInConfig(v1Registers) const { endianness, wasMixed } = extractGlobalEndianness(v1Registers) const migratedRegisters: ServerRegistersPerUnit = {} - for (const [unitId, serverRegisters] of Object.entries(v1Registers)) { - if (!serverRegisters) continue - - // Convert old boolean shape to { value: boolean } entries - const migratedCoils: Record<string, ServerBoolEntry> = {} - for (const [address, value] of Object.entries(serverRegisters.coils ?? {})) { - migratedCoils[address] = typeof value === 'boolean' ? { value } : (value as ServerBoolEntry) - } - const migratedDiscreteInputs: Record<string, ServerBoolEntry> = {} - for (const [address, value] of Object.entries(serverRegisters.discrete_inputs ?? {})) { - migratedDiscreteInputs[address] = - typeof value === 'boolean' ? { value } : (value as ServerBoolEntry) + // 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<unknown>(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: migratedCoils, - discrete_inputs: migratedDiscreteInputs, + coils: serverRegisters.coils ?? {}, + discrete_inputs: serverRegisters.discrete_inputs ?? {}, input_registers: {}, holding_registers: {} } @@ -118,9 +129,10 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian * `detectedVersion === CURRENT` branch, fails `ServerConfigSchema` on * `z.number()`, and refuses the whole file rather than the one register. * - * `stringifyExact64BitValues` walks a persisted store, `serverRegisters[uuid]`, - * and a config is keyed one level up, so the blob is wrapped in the shape that - * helper reads. That is the same difference `migrateBoolShapeForUnit` carries. + * `stringifyExact64BitValues` walks a persisted store, + * `servers[uuid].registers`, and a config holds the units alone, so the blob is + * wrapped in the shape that helper reads. That is the same difference + * `migrateBoolShapeForUnit` carries. * * The bool shape goes with it. `migrateBoolShapeInConfig` ran on the * `detectedVersion === CURRENT` branch alone, and a v2 file carrying the old @@ -128,17 +140,17 @@ function migrateServerV1toV2(v1Config: unknown): ServerConfig & { wasMixedEndian */ function migrateServerV2toV3(v2Config: unknown): ServerConfig { const config = v2Config as ServerConfig & Record<string, unknown> - migrateBoolShapeInConfig(config) - stringifyExact64BitValues({ serverRegisters: { config: config.serverRegistersPerUnit } }) + migrateBoolShapeInConfig(config.serverRegistersPerUnit) + stringifyExact64BitValues({ servers: { config: { registers: 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<string, unknown>): void { - for (const unitRegisters of objectValues(config.serverRegistersPerUnit)) { +function migrateBoolShapeInConfig(serverRegistersPerUnit: unknown): void { + for (const unitRegisters of objectValues(serverRegistersPerUnit)) { migrateBoolShapeForUnit(unitRegisters) } } @@ -148,17 +160,18 @@ function migrateBoolShapeInConfig(config: Record<string, 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) { - 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 // does not run on a config load, so without this the first single word // write after opening such a file folds from the rounded value, which is // the defect the v2 to v3 step removes for a v2 file. - stringifyExact64BitValues({ serverRegisters: { config: parsed.serverRegistersPerUnit } }) + stringifyExact64BitValues({ servers: { config: { registers: parsed.serverRegistersPerUnit } } }) const result = ServerConfigSchema.safeParse(parsed) if (!result.success) { throw new Error( diff --git a/src/shared/migrations/server/shared.ts b/src/shared/migrations/server/v1.ts similarity index 80% rename from src/shared/migrations/server/shared.ts rename to src/shared/migrations/server/v1.ts index 6af5cccb..4c888324 100644 --- a/src/shared/migrations/server/shared.ts +++ b/src/shared/migrations/server/v1.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<string, V1ServerRegisterEntry> +/** + * 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<string, boolean> - discrete_inputs?: Record<string, boolean> + coils?: Record<string, ServerBoolEntry> + discrete_inputs?: Record<string, ServerBoolEntry> input_registers?: V1ServerRegister holding_registers?: V1ServerRegister } @@ -27,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/server/zustand.ts b/src/shared/migrations/server/zustand.ts index 59068d4c..1db1160c 100644 --- a/src/shared/migrations/server/zustand.ts +++ b/src/shared/migrations/server/zustand.ts @@ -1,12 +1,21 @@ -import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } from './shared' +import { defaultSerialPortOptions } from '../../default' +import { V1RegisterParams, V1ServerRegistersPerUnit, extractGlobalEndianness } from './v1' import { dropUnservableRegisters, + isRecord, migrateBoolShapeForUnit, repairPersistedParity, stringifyExact64BitValues } from '../shared' -export const CURRENT_SERVER_ZUSTAND_VERSION = 8 +/** + * One number above what the last release wrote, whatever this branch changes. + * + * 2.3.0 shipped 3, and nobody outside this branch has a store carrying 4 + * through 8. A number per change was five migration steps no launch will ever + * run, so the steps between are one step from 3. + */ +export const CURRENT_SERVER_ZUSTAND_VERSION = 4 /** Where the server store keeps its state. */ export const SERVER_ZUSTAND_STORAGE_KEY = 'server.zustand' @@ -34,11 +43,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. */ @@ -48,10 +52,10 @@ export function migrateServerModeState(state: Record<string, unknown>): Record<s migrated.serverMode = 'tcp' } if (!migrated.serialConfig) { - migrated.serialConfig = { - com: '', - options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } - } + // The same four options as `getDefaultSerialConfig`, which spreads this + // too. They were a literal here, so a store with no serial config was the + // one of the three that agreed by coincidence. + migrated.serialConfig = { com: '', options: { ...defaultSerialPortOptions } } } return migrated } @@ -116,12 +120,62 @@ export function migrateServerRegistersState( return migrated } +/** The six records a server was spread over, and the field each becomes. */ +const SERVER_FIELDS = { + port: 'port', + unitId: 'unitId', + name: 'name', + littleEndian: 'littleEndian', + serverRegisters: 'registers', + usedAddresses: 'usedAddresses' +} as const + +/** + * Folds the six records keyed by uuid into one record of servers. + * + * Nothing is validated here. A field is carried across whatever it holds, and + * `repairServers` reads each server back through `PersistedServerSchema` + * afterwards, so a field this step cannot read is one the user is told about + * by name. + * + * The key set is `uuids` where that is a list of strings, because that is the + * list the app drew the toggle group from: a uuid in none of the six records + * arrives as an empty server and a record entry under no uuid was never on + * screen. Where `uuids` holds something else it is the list that is lost, so + * the key set falls back to the union of the six. + */ +export function foldServersIntoOneRecord(state: Record<string, unknown>): Record<string, unknown> { + const folded = { ...state } + const sources = Object.keys(SERVER_FIELDS).map((field) => state[field]) + + const listed = state.uuids + const uuids = + Array.isArray(listed) && listed.every((uuid) => typeof uuid === 'string') + ? (listed as string[]) + : sources.flatMap((source) => (isRecord(source) ? Object.keys(source) : [])) + + const servers: Record<string, Record<string, unknown>> = {} + for (const uuid of uuids) { + const server: Record<string, unknown> = {} + for (const [oldField, newField] of Object.entries(SERVER_FIELDS)) { + const source = state[oldField] + if (isRecord(source) && uuid in source) server[newField] = source[uuid] + } + servers[uuid] = server + } + + folded.servers = servers + for (const field of ['uuids', ...Object.keys(SERVER_FIELDS)]) delete folded[field] + return folded +} + /** * Migrate server Zustand state to the current version. * Used by Zustand persist middleware. * - * This stood inline in `server.zustand.ts`, where a test could reach every step - * it calls and none of the calls. Dropping a whole `if` left the suite green. + * Here rather than inline in `server.zustand.ts`, where a test reaches every + * step it calls and none of the calls: dropping a whole `if` there left the + * suite green. */ export function migrateServerState( persistedState: unknown, @@ -143,22 +197,32 @@ export function migrateServerState( ) } - // v3→v4: the RTU parity the serial binding refuses - if (version < 4) { - repairPersistedParity(state, 'serialConfig', 'options') + // v3→v4, which is one step because 3 is what the last release wrote. The + // six records keyed by uuid fold into one record of servers, and then: the + // parity the serial binding refuses, registers outside the 16 bit map, a + // string width, a fixed value or an interval the encoder, the map or + // `setInterval` cannot take, and a 64 bit composite that has to be a decimal + // string for the first word write after a launch to read it exactly. + // + // The fold runs below 4 alone. Every version above it already writes + // `servers`, and folding again would find the six fields gone and replace + // the record with an empty one. + if (version < CURRENT_SERVER_ZUSTAND_VERSION) { + state = foldServersIntoOneRecord(state) } - // v4→v5: registers at an address outside the 16 bit map - // v5→v6: and generators the interval floor now refuses - // v6→v7: and a string width, a fixed value or an interval outside what the - // encoder, the map or `setInterval` can take - if (version < 7) { + // + // Any version but this one, rather than the ones below it. persist calls + // this for every version that is not the current one, and `repairPersisted` + // then reads a blob from a newer Modbux field by field with + // `savedByNewerVersion` set, so a newer blob is exactly where a parity this + // enum does not name and a register this schema refuses come from, and + // without these steps one of either costs every register on every server. + // `migrateServerConfig` answers the same question with its own + // `detectedVersion > CURRENT` branch; the store had none. + if (version !== CURRENT_SERVER_ZUSTAND_VERSION) { + repairPersistedParity(state, 'serialConfig', 'options') dropUnservableRegisters(state) - } - - // v7→v8: a 64 bit composite is a decimal string, so the first word write - // after a launch reads it exactly rather than rounded - if (version < 8) { stringifyExact64BitValues(state) } diff --git a/src/shared/migrations/shared.ts b/src/shared/migrations/shared.ts index dd27823c..5f0a3550 100644 --- a/src/shared/migrations/shared.ts +++ b/src/shared/migrations/shared.ts @@ -1,9 +1,22 @@ import type { ZodError, ZodIssue } from 'zod' import { RegisterAddressKeySchema } from '../types/ranges' import { RegisterMapValueSchema } from '../types/client' -import { RegisterParamsSchema } from '../types/server' -import { DataType, NumberRegistersSchema, ParitySchema, UnitIdStringSchema } from '../types' -import { getUsedAddresses, holdsExact64Bits } from '../utils' +import { + RegisterParamsSchema, + ServerBoolEntrySchema, + ServerRegisterEntrySchema +} from '../types/server' +import { + BooleanRegistersSchema, + DataType, + isBooleanRegister, + NumberRegistersSchema, + ParitySchema, + RegisterType, + RegisterTypeSchema, + UnitIdStringSchema +} from '../types' +import { getUsedAddresses, holdsExact64Bits } from '../encoding' /** * Replace a stored parity that `ParitySchema` no longer names, at `path` from @@ -29,7 +42,7 @@ export function repairPersistedParity(state: Record<string, unknown>, ...path: s serialOptions.parity = 'none' } -const isRecord = (value: unknown): value is Record<string, unknown> => +export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null /** @@ -73,8 +86,34 @@ 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. */ -const recordEntries = (value: unknown): [string, Record<string, unknown>][] => +export const recordEntries = (value: unknown): [string, Record<string, unknown>][] => isRecord(value) ? Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]) @@ -131,9 +170,9 @@ const usedAddressesOfUnit = ( * could ask for `Buffer.alloc(2e12)`, and a fixed `value` was bare, so a * `uint16` could carry 70000 and throw out of `createRegisters`. A register * running past address 65535 is the pair of `address` and `length` and was - * refused by neither. `repairPersisted` works a top level field at a time, and - * without this one such register costs every register on every server and every - * unit. + * refused by neither. A server is read back field by field, so without this + * one such register costs every register on every unit of the server that + * carries it. * * `usedAddresses` is persisted beside the registers, `isAddressInUse` refuses an * address against it, and the only thing recomputing it on launch is @@ -142,26 +181,23 @@ const usedAddressesOfUnit = ( * and so did every unit of a server whose port `createServer` refuses. * * Every unit walked, rather than the ones something went from, because a blob - * can arrive with the register gone and the address still marked: store version - * 5 ran the address drop and not the interval one, between `5fc739d` and - * `5211399`, and sits below this step's gate. + * can arrive with the register gone and the address still marked. The store + * versions that did that ran on this branch alone and were collapsed into one + * step, so what is left is a hand-edited blob: a register deleted by hand + * leaves its addresses behind the same way. * - * A unit id `UnitIdStringSchema` refuses is skipped, because the whole map is - * one persisted field: writing `'300'` into it would cost the addresses of every - * unit beside it, where the same key costs `serverRegisters` alone today. + * A unit id `UnitIdStringSchema` refuses is skipped, because `usedAddresses` + * is one field of that server: writing `'300'` into it would cost the + * addresses of every unit beside it, where the same key costs that server's + * `registers` alone today. */ export function dropUnservableRegisters(state: Record<string, unknown>): void { - for (const [uuid, registersPerUnit] of recordEntries(state.serverRegisters)) { - const usedAddresses = recordAt(state, 'usedAddresses') - const usedPerUnit = usedAddresses && recordAt(usedAddresses, uuid) + for (const [, server] of recordEntries(state.servers)) { + const registersPerUnit = recordAt(server, 'registers') + const usedPerUnit = recordAt(server, 'usedAddresses') for (const [unitId, registersByType] of recordEntries(registersPerUnit)) { - for (const entriesByAddress of objectValues(registersByType)) { - for (const [address, entry] of Object.entries(entriesByAddress)) { - if (isRecord(entry) && isServable(address, entry)) continue - delete entriesByAddress[address] - } - } + dropUnservableEntries(registersByType) if (!usedPerUnit) continue if (!UnitIdStringSchema.safeParse(unitId).success) continue @@ -171,15 +207,66 @@ export function dropUnservableRegisters(state: Record<string, unknown>): void { } /** - * A register map is keyed by address, and a register entry repeats its whole - * parameter set, so both have to hold for the entry to be servable. A boolean - * entry carries the key alone. + * Whether one entry can be served under the type and the address it is filed + * at. + * + * The schema that will judge the entry is the schema that answers here, so a + * register the drop keeps is a register `ServerRegistersSchema` takes. Reading + * `params` alone did not do that: the branch keeping a boolean entry was + * `if (!isRecord(params)) return true`, and a holding register of + * `{ value: 1 }` took it, so the drop kept an entry `ServerRegisterSchema` + * refuses and the whole `serverRegistersPerUnit` field reset for it. An entry + * carrying valid `params` and no `value` cost the field the same way. + * + * A register map is keyed by address and a number entry repeats that address + * in its parameters, so the two have to agree. `ServerRegisterSchema` asks + * that of a blob or a file a whole field at a time: what the two callers here + * buy is the register rather than the field, which is the reason written above + * each of them. */ -const isServable = (address: string, entry: Record<string, unknown>): boolean => { +const isServable = (registerType: RegisterType, address: string, entry: unknown): boolean => { if (!RegisterAddressKeySchema.safeParse(address).success) return false - const params = entry.params - if (!isRecord(params)) return true - return RegisterParamsSchema.safeParse(params).success + if (isBooleanRegister(registerType)) return ServerBoolEntrySchema.safeParse(entry).success + + const parsed = ServerRegisterEntrySchema.safeParse(entry) + return parsed.success && String(parsed.data.params.address) === address +} + +/** + * Drop the entries of one unit that cannot be served, and give the unit the + * four register types it is missing. + * + * `ServerRegistersSchema` is a `z.object` naming all four, so a unit carrying + * three of them fails the whole `serverRegistersPerUnit` field: measured on a + * v9 config whose unit had no `coils` key, which came back with every register + * on every unit gone and `serverRegistersPerUnit` named as reset. That is the + * cost this walk exists to avoid, so the walk is over the four types rather + * than over the keys that happen to be there. `recordAt` writes the empty map + * for a key holding nothing and leaves a key holding something else alone, + * which is the one shape still able to fail the field. + * + * Walking the four also leaves a fifth register type from a newer Modbux + * untouched, and that key costs nothing: a `z.object` strips what it does not + * declare. + * + * Two callers reach a unit by different routes, a config file through + * `serverRegistersPerUnit[unit]` and the persisted store through + * `servers[uuid].registers[unit]`. Only that walk differs, so each caller owns + * it and the work below it is here once, the way `migrateBoolShapeForUnit` + * already is. + */ +const dropUnservableEntries = (registersByType: unknown): void => { + if (!isRecord(registersByType)) return + + for (const registerType of RegisterTypeSchema.options) { + const entriesByAddress = recordAt(registersByType, registerType) + if (!entriesByAddress) continue + + for (const [address, entry] of Object.entries(entriesByAddress)) { + if (isServable(registerType, address, entry)) continue + delete entriesByAddress[address] + } + } } /** @@ -194,17 +281,12 @@ const isServable = (address: string, entry: Record<string, unknown>): boolean => * actually comes from. * * Walks `serverRegistersPerUnit[unit]` where the store walks - * `serverRegisters[uuid][unit]`, which is the same difference + * `servers[uuid].registers[unit]`, which is the same difference * `migrateBoolShapeForUnit` already carries. */ export function dropUnservableConfigRegisters(parsed: Record<string, unknown>): void { for (const [, registersByType] of recordEntries(parsed.serverRegistersPerUnit)) { - for (const entriesByAddress of objectValues(registersByType)) { - for (const [address, entry] of Object.entries(entriesByAddress)) { - if (isRecord(entry) && isServable(address, entry)) continue - delete entriesByAddress[address] - } - } + dropUnservableEntries(registersByType) } } @@ -241,8 +323,8 @@ export function dropUnreadableConfigMapping(parsed: Record<string, unknown>): vo * than what the device holds. */ export function stringifyExact64BitValues(state: Record<string, unknown>): void { - for (const [, registersPerUnit] of recordEntries(state.serverRegisters)) { - for (const [, registersByType] of recordEntries(registersPerUnit)) { + for (const [, server] of recordEntries(state.servers)) { + for (const [, registersByType] of recordEntries(recordAt(server, 'registers'))) { for (const entriesByAddress of objectValues(registersByType)) { for (const entry of Object.values(entriesByAddress)) { if (!isRecord(entry)) continue @@ -256,10 +338,10 @@ export function stringifyExact64BitValues(state: Record<string, unknown>): void // The digits, or `'0'` where there are none. `String(0.5)` is // `"0.5"` and `String(1e21)` is `"1e+21"`, and the union takes // either as a number and neither as a string, so writing one back - // would turn a blob that parsed into one that does not, and - // `repairPersisted` reads `serverRegisters` whole: every server, - // every unit. An integer above 2 ** 53 is the case this step is for, - // so the test is the digits rather than `Number.isSafeInteger`. + // would turn a blob that parsed into one that does not, and a + // server's `registers` is read whole: every unit of it. An integer + // above 2 ** 53 is the case this step is for, so the test is the + // digits rather than `Number.isSafeInteger`. // // `'0'` rather than left alone, because `toExact64Bits` answers // nothing for a value no composite can be read out of, and @@ -299,13 +381,13 @@ export function dropUnmappableRegisters(state: Record<string, unknown>): void { * * Two callers walk to a unit's registers by different routes: a config file * through `serverRegistersPerUnit[unit]`, the persisted store through - * `serverRegisters[uuid][unit]`. Only that walk differed, and the work below it - * was written out twice. + * `servers[uuid].registers[unit]`. Only that walk differs, so each caller owns + * it and the work below it is here once. */ 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/migrations/types.ts b/src/shared/migrations/types.ts index b7d53d59..a2d19df6 100644 --- a/src/shared/migrations/types.ts +++ b/src/shared/migrations/types.ts @@ -11,12 +11,12 @@ export interface MigrationResult<T> { * what it did not bring across. * * One field rather than a `warning` of `'FUTURE_VERSION'` beside a `reset` - * that is only ever set with it: the pair made the caller ask twice, and the + * that is only ever set with it: the pair makes the caller ask twice, and the * second question is one no input answers no. * - * That branch used to cast the parsed JSON straight to the config type, so it - * was the one door into the app no schema stood in, and the warning the user - * read was a compatibility notice for something that had not been checked. + * The branch that sets it parses the file rather than casting it, so what the + * user reads names the fields that did not come across instead of promising a + * compatibility nothing checked. */ futureVersion?: ConfigReset } diff --git a/src/shared/repairPersisted.ts b/src/shared/repairPersisted.ts index d1c56afd..e3b371ae 100644 --- a/src/shared/repairPersisted.ts +++ b/src/shared/repairPersisted.ts @@ -65,10 +65,10 @@ export function repairPersisted<Shape extends z.ZodRawShape>( * What each field is called on screen. A field with no entry is named as it is * stored, which is worth more in a bug report than a guess. * - * Both a persisted store and a config file from a newer Modbux are read - * through here, and the two name some of the same things differently: the store - * keeps `serverRegisters`, the file `serverRegistersPerUnit`, and a file also - * carries the two version fields. + * Three things are read through here and they name some of the same things + * differently: a server in the persisted store keeps `registers`, a config + * file keeps `serverRegistersPerUnit`, and a file also carries the two version + * fields. */ const FIELD_LABELS: Record<string, string> = { serverRegistersPerUnit: 'the registers', @@ -79,8 +79,8 @@ const FIELD_LABELS: Record<string, string> = { name: 'the name', port: 'the ports', selectedUuid: 'the selected server', - uuids: 'the server list', - serverRegisters: 'the registers', + servers: 'the servers', + registers: 'the registers', usedAddresses: 'the used addresses', unitId: 'the unit ids', littleEndian: 'the endianness', @@ -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<Storage, 'getItem' | 'setItem'>, - key: string, - now: () => number = Date.now -): void { +export function keepCorrupt(storage: Pick<Storage, 'getItem' | 'setItem'>, 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 c081a4bc..e984dc80 100644 --- a/src/shared/types/bitmap.ts +++ b/src/shared/types/bitmap.ts @@ -9,14 +9,18 @@ export const BitMapEntrySchema = z.object({ color: BitColorSchema.optional(), invert: z.boolean().optional() }) -export type BitMapEntry = z.infer<typeof BitMapEntrySchema> -/** 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<typeof BitMapConfigSchema> diff --git a/src/shared/types/client.ts b/src/shared/types/client.ts index b9f77c02..4f222a58 100644 --- a/src/shared/types/client.ts +++ b/src/shared/types/client.ts @@ -1,7 +1,15 @@ import z from 'zod' import { BaseDataTypeSchema, DataTypeSchema } from './datatype' import { BitMapConfigSchema } from './bitmap' -import { PortSchema, RegisterAddressKeySchema, RegisterAddressSchema, UnitIdSchema } from './ranges' +import { registerWidth } from '../encoding' +import { + MAX_WRITE_BITS, + PortSchema, + RegisterAddressKeySchema, + RegisterAddressSchema, + UnitIdSchema, + registersFrom +} from './ranges' import { RegisterType, RegisterTypeSchema } from './register' import { SerialPortOptionsSchema } from './serial' @@ -68,10 +76,10 @@ export const TransactionSchema = z.object({ * The data address the request asked for, and undefined where it cannot be * read. * - * `_requestedAddress` takes it off bytes 2 and 3 of the request frame, which + * `requestedAddress` takes it off bytes 2 and 3 of the request frame, which * every function code Modbux sends puts it at, and falls back on * `nextDataAddress`, which modbus-serial files for three of them. Both are - * gone only for a frame that never went out, and `_columns.tsx` binds + * gone only for a frame that never went out, and `columns.tsx` binds * `field: 'address'` with no formatter, so the cell is blank there. */ address: RegisterAddressSchema.optional(), @@ -119,13 +127,11 @@ export const ConnectionConfigTcpSchema = z.object({ host: z.string(), options: TcpPortOptionsSchema }) -export type ConnectionConfigTcp = z.infer<typeof ConnectionConfigTcpSchema> export const ConnectionConfigRtuSchema = z.object({ com: z.string(), options: SerialPortOptionsSchema }) -export type ConnectionConfigRtu = z.infer<typeof ConnectionConfigRtuSchema> /** * The unit id and the port the client sends, on the range the protocol fixes. @@ -159,7 +165,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({ @@ -169,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> // @@ -180,14 +202,37 @@ export const ConnectStateSchema = z.enum([ 'connecting', 'disconnecting' ]) -export type ConnectState = z.infer<typeof ConnectStateSchema> + +/** + * Whether a host or a COM port names somewhere to connect. + * + * `ConnectionConfigSchema` types both as a bare string and takes a blank one, + * so the boundary has nothing to refuse and main held a connection config + * naming no port. `setHost` and `setCom` carry a flag instead, and this is the + * one test behind it: `HostInput` and the COM field each spelled it out, and + * `init` had to answer it again for a blob coming off disk, because + * `partialize` persists `connectionConfig` and not `valid`. + */ +export const isConnectionAddressGiven = (value: string): boolean => value.trim().length > 0 + +/** + * Whether a read length asks for any registers. + * + * `RegisterConfigSchema` takes 0, which its own docblock calls a shipped state: + * `setLength` keeps a cleared field in the store and marks it invalid rather + * than sending it. So the flag is what says the field is empty, and `init` has + * to answer it for a blob coming off disk the way it does for the two + * addresses. + */ +export const isReadLengthGiven = (length: number): boolean => length > 0 export const ClientStateSchema = z.object({ connectState: ConnectStateSchema, polling: z.boolean(), scanningUnitIds: z.boolean(), scanningRegisters: z.boolean(), - reading: z.boolean() + reading: z.boolean(), + writing: z.boolean() }) export type ClientState = z.infer<typeof ClientStateSchema> @@ -264,7 +309,7 @@ export interface RawTransaction { * `writeFC4` at index.js:880 and `writeFC6` at 983 set it, and `writeFC1` * delegates to `writeFC2` and `writeFC3` to `writeFC4`, so FC3, FC4 and FC6 * carry one and FC1, FC2, FC5, FC15 and FC16 do not. This said `number` and - * `_logTransaction` read it, so the Addr cell was blank for every coil read, + * the transaction log read it, so the Addr cell was blank for every coil read, * every discrete input read and every write Modbux sends. */ nextDataAddress?: number diff --git a/src/shared/types/utils.ts b/src/shared/types/helpers.ts similarity index 53% rename from src/shared/types/utils.ts rename to src/shared/types/helpers.ts index cef55526..01606b32 100644 --- a/src/shared/types/utils.ts +++ b/src/shared/types/helpers.ts @@ -15,4 +15,15 @@ export type CamelCase<S extends string> = S extends `${infer Head}_${infer Tail} ? `${Head}${Capitalize<CamelCase<Tail>>}` : S -// ? type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] } +/** + * The camelCase method name a snake_case channel becomes on `window.api`. + * + * The cast is what no compiler can do for a string replace, and it sits here + * rather than at the call site so every caller is checked against it. It holds + * for names of lowercase segments, which is the shape the conformance suite + * requires of `IPC_CHANNELS`: `_([a-z])` leaves a digit or a capital where it + * stands, and `CamelCase` would capitalise it. + */ +export function snakeToCamel<S extends string>(str: S): CamelCase<S> { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) as CamelCase<S> +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 5a4c6ecf..8f5d8a0c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,11 +1,12 @@ export * from './datatype' export * from './server' -export * from './utils' +export * from './helpers' export * from './ranges' 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 00a00c1e..7eed0603 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. @@ -141,10 +146,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 +164,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 +328,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: [] @@ -372,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', @@ -383,12 +396,15 @@ export const IPC_EVENTS = [ 'scan_progress', 'register_value', 'window_update', - 'open_server_window', 'address_groups', 'rtu_server_status' ] as const -export type IpcEvent = (typeof IPC_EVENTS)[number] +/** The events a window pushes to main. `sendEvent` there, `onIpcEvent` here. */ +export const EVENTS_TO_MAIN = ['open_server_window'] as const + +export type EventToRenderer = (typeof EVENTS_TO_RENDERER)[number] +export type EventToMain = (typeof EVENTS_TO_MAIN)[number] export interface IpcEventPayloadMap { ['backend_message']: [BackendMessage] @@ -401,7 +417,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 { 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/ranges.ts b/src/shared/types/ranges.ts index 9941649e..422a699f 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. @@ -10,15 +10,21 @@ import { NumberRegistersSchema, RegisterType } from './register' * part is not refused here. A TCP port is 16 bit too, and shares no meaning * with a register address beyond the width. * - * `PortSchema` takes 0 while `modbusServer.isPort` refuses it, and that is two + * `PortSchema` takes 0 while `isPort` in `modbusServer/tcp.ts` refuses it, and that is two * questions rather than one rule written twice. 0 is the width's floor and the * value a store from before `createServer` checked still carries, which * `createServer` answers by starting on 502. `setPort` answers it with "A * 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 + +/** Modbus TCP's registered port, which is where a walk for a free one starts. */ +export const DEFAULT_MODBUS_PORT = 502 + +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) /** @@ -30,11 +36,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. @@ -48,9 +57,18 @@ 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 + +/** + * 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. @@ -62,4 +80,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}` + }) diff --git a/src/shared/types/register.ts b/src/shared/types/register.ts index 9e6c0571..60ec9659 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<typeof RegisterTypeSchema> + +const NUMBER_REGISTERS = new Set<string>(NumberRegistersSchema.options) +const BOOLEAN_REGISTERS = new Set<string>(BooleanRegistersSchema.options) + +/** + * Whether `type` is one of the two register types holding 16 bit words. + * + * One place rather than a membership test per site, because a hand-written one + * 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) diff --git a/src/shared/types/scan.ts b/src/shared/types/scan.ts index 00964585..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> @@ -57,7 +69,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/serial.ts b/src/shared/types/serial.ts index a6ce452c..4f5fa7d2 100644 --- a/src/shared/types/serial.ts +++ b/src/shared/types/serial.ts @@ -27,7 +27,7 @@ export type Parity = z.infer<typeof ParitySchema> /** * The frame widths the selects offer and the binding is handed. * - * `ModbusServer.startRtuServer` casts both to exactly these literals on the way + * `RtuServer.start` casts both to exactly these literals on the way * into `ServerSerialPortOptions`, and `bindings-cpp` merges its options into * defaults and hands them to the native binding with no check of its own on * either. Spelling the literals here is what lets those casts go. 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 90271429..b989f64d 100644 --- a/src/shared/types/server.ts +++ b/src/shared/types/server.ts @@ -4,7 +4,7 @@ import { BitMapConfigSchema } from './bitmap' import { PortSchema, RegisterAddressKeySchema, RegisterAddressSchema } from './ranges' import { SerialPortOptionsSchema } from './serial' import { UnitIdString, UnitIdStringSchema } from './unitid' -import { getAddressFitError, getValueRangeError, MAX_UTF8_LENGTH } from '../utils' +import { getAddressFitError, getValueRangeError, MAX_UTF8_LENGTH } from '../encoding' import { BooleanRegisters, BooleanRegistersSchema, @@ -48,16 +48,16 @@ export type StartRtuServerParams = z.infer<typeof StartRtuServerParamsSchema> * it to `setInterval`, and an interval of 0 fired 78 times in 100 ms when * measured. The ceiling is Node's rather than the mask's: above 2147483647 it * warns `TimeoutOverflowWarning` and sets the duration to 1, so an interval of - * 1e12 fires every millisecond, which is the flood the floor was written to - * prevent. + * 1e12 fires every millisecond, which is the flood the floor prevents. * * `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 @@ -65,7 +65,7 @@ export type StartRtuServerParams = z.infer<typeof StartRtuServerParamsSchema> * `getMinMaxValues(dataType)` over the same seven types, so switching a * generator from `uint32` to `uint16` rewrites a max of 100000 to 65535 and * reports it valid. And one register the schema refuses costs that register - * now, because store version 7 drops it and keeps the rest. + * now, because the store's v3 to v4 step drops it and keeps the rest. * * A min above a max is not a rule here. The dialog lets one through, and * `Math.random() * (max - min) + min` covers the same range either way: ten @@ -85,7 +85,6 @@ const RegisterParamsStaticPartSchema = z.object({ max: z.undefined(), interval: z.undefined() }) -export type RegisterParamsStaticPart = z.infer<typeof RegisterParamsStaticPartSchema> export type RegisterValue<K extends RegisterType = RegisterType> = { [P in K]: { @@ -93,7 +92,7 @@ export type RegisterValue<K extends RegisterType = RegisterType> = { unitId: UnitIdString registerType: P address: number - value: ServerData[P][number] + value: ServerDataValue<P> } }[K] @@ -194,8 +193,34 @@ export const ServerRegisterEntrySchema = z.object({ }) export type ServerRegisterEntry = z.infer<typeof ServerRegisterEntrySchema> -// Schema for a dictionary of register entries keyed by address -export const ServerRegisterSchema = z.record(RegisterAddressKeySchema, ServerRegisterEntrySchema) +/** + * A dictionary of register entries keyed by address, each naming its address + * once. + * + * The key and `params.address` are one address written twice, and both are + * read: `ServerRegisters` draws `params.address` and `syncRegistersWithBackend` + * sends it, while `setRegisterValue` and `removeRegister` look the entry up by + * key. Where the two disagree the register is served at one address and + * answered for at the other, so a generator's words arrive at a key holding + * nothing and are dropped, and Delete takes the register out of main and leaves + * the row in the grid. + * + * Compared as strings, because that is how the store spells a lookup. `'007'` + * passes `RegisterAddressKeySchema`, and `registers[String(7)]` finds nothing + * under it. + */ +export const ServerRegisterSchema = z + .record(RegisterAddressKeySchema, ServerRegisterEntrySchema) + .superRefine((registers, context) => { + for (const [address, entry] of Object.entries(registers)) { + if (String(entry.params.address) === address) continue + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [address, 'params', 'address'], + message: `Register keyed ${address} names address ${entry.params.address}` + }) + } + }) export type ServerRegister = z.infer<typeof ServerRegisterSchema> // Schema representing all register types for a server @@ -322,12 +347,35 @@ export const CreateServerParamsSchema = z.object({ }) export type CreateServerParams = z.infer<typeof CreateServerParamsSchema> -export interface SetUnitIdParams { - uuid: string - unitID: UnitIdString +/** Which of the two values each register type holds. */ +type ServerDataValues = { + coils: boolean + discrete_inputs: boolean + input_registers: number + holding_registers: number } -export type ServerData = { [K in RegisterType]: K extends BooleanRegisters ? boolean[] : number[] } +/** A bit for the two boolean register types, a word for the other two. */ +export type ServerDataValue<K extends RegisterType> = ServerDataValues[K] + +/** + * What one unit holds, per register type, keyed by address. + * + * A map rather than four arrays of 65536. A unit exists the first time a + * mutator names one, and the arrays cost 2.00 MB per unit id whether or not an + * address under them is used, measured over ten unit ids with `--expose-gc`. + * + * The trade inverts where every address is written. A remote client may write + * any coil and any holding register on a unit the server hosts, and those two + * maps full cost 3.50 MB per unit id against the arrays' 2.00 MB, measured the + * same way. + * + * An address with no entry reads as the fallback the accessor carries, which is + * the zero and the false the arrays answered. The arrays also answered + * `undefined` past their last index, and that is the one thing a map cannot + * say, so `_get` tests the address against `MAX_REGISTER_ADDRESS` itself. + */ +export type ServerData = { [K in RegisterType]: Map<number, ServerDataValues[K]> } /** * What the server needs of a running generator, which is only the teardown. diff --git a/src/shared/utils.ts b/src/shared/utils.ts index f31097d7..f54306c8 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,327 +1,4 @@ -// `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' -import type { RegisterParams, ServerRegisters } from './types/server' - -/** - * Whether bit `bit` of `word` is set. - * - * `&` is a 32 bit signed operation, so `word & (2 ** bit)` answered false for - * bit 31 of 2 ** 31 and for every bit above it. Both callers ask for bits 0 to - * 15 of a one register bitmap, which `BitMapConfigSchema` and `registerWidth` - * hold them to, so the arithmetic answers a question nothing asks yet. - */ -export const getBit = (word: number, bit: number): boolean => Math.floor(word / 2 ** bit) % 2 === 1 - -/** The width the add dialog offers for a string when the field is left alone. */ -export const DEFAULT_UTF8_LENGTH = 10 - -/** - * The widest string a register map holds, which is the add dialog's own mask. - * - * The protocol puts a ceiling under it either way: FC3 and FC4 answer at most - * 125 registers, so a string wider than that cannot be read in one request. - * The number here is the one `RegisterLengthInput` has always masked to, and - * `RegisterParamsBasePartSchema` states it too, because a config file is the - * other door and the map is the only thing that bounded it there: one string of - * 65536 made `addRegister` send 65536 `register_value` messages in one - * synchronous loop. - */ -export const MAX_UTF8_LENGTH = 124 - -/** - * How many registers a value of this type occupies. - * - * One answer, because this was stated seven times and the copies disagreed on - * `utf8`, so deleting a string erased registers belonging to its neighbours. - * The switch is exhaustive: a new DataType is a type error here. - */ -export const registerWidth = (dataType: DataType, length?: number): number => { - switch (dataType) { - case 'utf8': - return length ?? DEFAULT_UTF8_LENGTH - - case 'int32': - case 'uint32': - case 'float': - case 'unix': - return 2 - - case 'int64': - case 'uint64': - case 'double': - case 'datetime': - return 4 - - case 'none': - case 'int16': - case 'uint16': - case 'bitmap': - return 1 - } -} - -// 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) -export const littleEndian32 = (buffer: Buffer, offset: number): Buffer<ArrayBuffer> => { - return Buffer.concat([ - buffer.subarray(offset + 2, offset + 4) as Uint8Array, - buffer.subarray(offset, offset + 2) as Uint8Array - ]) -} - -// Regular most significant word first (big endian) -export const bigEndian64 = (buffer: Buffer, offset: number): Buffer => { - return buffer.subarray(offset, offset + 8) -} - -// Uncommon least significant word first (little endian) -export const littleEndian64 = (buffer: Buffer, offset: number): Buffer<ArrayBuffer> => { - return Buffer.concat([ - buffer.subarray(offset + 6, offset + 8) as Uint8Array, - buffer.subarray(offset + 4, offset + 6) as Uint8Array, - buffer.subarray(offset + 2, offset + 4) as Uint8Array, - buffer.subarray(offset, offset + 2) as Uint8Array - ]) -} - -export const createRegisters = ( - dataType: EncodableDataType, - value: number, - littleEndian: boolean -): [number, ...number[]] => { - let buffer = Buffer.alloc(registerWidth(dataType) * 2) - - switch (dataType) { - case 'int16': - buffer.writeInt16BE(value, 0) - break - case 'uint16': - case 'bitmap': - buffer.writeUInt16BE(value, 0) - break - case 'int32': - buffer.writeInt32BE(value, 0) - if (littleEndian) buffer = littleEndian32(buffer, 0) - break - case 'uint32': - buffer.writeUInt32BE(value, 0) - if (littleEndian) buffer = littleEndian32(buffer, 0) - break - case 'float': - buffer.writeFloatBE(value, 0) - if (littleEndian) buffer = littleEndian32(buffer, 0) - break - case 'int64': - buffer.writeBigInt64BE(BigInt(value), 0) - if (littleEndian) buffer = littleEndian64(buffer, 0) - break - case 'uint64': - buffer.writeBigUInt64BE(BigInt(value), 0) - if (littleEndian) buffer = littleEndian64(buffer, 0) - break - case 'double': - buffer.writeDoubleBE(value, 0) - if (littleEndian) buffer = littleEndian64(buffer, 0) - break - case 'unix': - buffer.writeUInt32BE(value >>> 0, 0) - if (littleEndian) buffer = littleEndian32(buffer, 0) - break - case 'datetime': - buffer = encodeIEC870DateTime(value) - if (littleEndian) buffer = littleEndian64(buffer, 0) - break - } - - // Convert bytes to array of 16-bit words. `registerWidth` is one at its - // smallest over this type, so the first word is always there and the return - // type says so: `_writeRegister` sends registers[0] to FC6. - const registers: [number, ...number[]] = [buffer.readUInt16BE(0)] - for (let i = 2; i < buffer.length; i += 2) { - registers.push(buffer.readUInt16BE(i)) - } - - return registers -} - -/** - * The window an IEC 870-5 datetime can carry, in milliseconds. - * - * The format holds the year as a seven bit offset from 2000, so 2000 through - * 2127 is the whole of it and a year outside has no encoding at all. - * `parseIEC870DateTime` cannot say so, because the same seven bits mask a wrong - * year into a right-looking one on the way back, which is why this is the - * encoder's question. Both ends are stated once: `getMinMaxValues` hands them to - * 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) - -/** - * Encode a timestamp (milliseconds) to IEC 870-5 datetime format (8 bytes / 4 registers). - * - * Only the low end clamped, and the decoder masks the year to seven bits, so - * 2200 came back as 2072 with nothing to say it had moved. - */ -export const encodeIEC870DateTime = (timestampMs: number): Buffer<ArrayBuffer> => { - const dt = new Date(Math.min(Math.max(timestampMs, IEC870_MIN_MS), IEC870_MAX_MS)) - const buf = Buffer.alloc(8) - buf.writeUInt16BE(dt.getUTCFullYear() - 2000, 0) - buf.writeUInt16BE(((dt.getUTCMonth() + 1) << 8) | dt.getUTCDate(), 2) - buf.writeUInt16BE((dt.getUTCHours() << 8) | dt.getUTCMinutes(), 4) - buf.writeUInt16BE(dt.getUTCSeconds() * 1000 + dt.getUTCMilliseconds(), 6) - return buf -} - -/** - * Encode a UTF-8 string to Modbus registers (null-padded). - */ -export const createStringRegisters = (text: string, registerCount: number): number[] => { - const byteLength = registerCount * 2 - const buf = Buffer.alloc(byteLength, 0) - buf.write(text, 'utf-8') - const registers: number[] = [] - for (let i = 0; i < byteLength; i += 2) { - registers.push(buf.readUInt16BE(i)) - } - return registers -} - -export const getMinMaxValues = (dataType: DataType): { min: number; max: number } => { - switch (dataType) { - case 'int16': - return { min: -32768, max: 32767 } - case 'uint16': - case 'bitmap': - return { min: 0, max: 65535 } - case 'int32': - return { min: -2147483648, max: 2147483647 } - case 'uint32': - return { min: 0, max: 4294967295 } - case 'int64': - return { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } // JavaScript safe integer range - case 'uint64': - return { min: 0, max: Number.MAX_SAFE_INTEGER } // Max safe integer in JavaScript for unsigned 64-bit - case 'float': - return { min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY } // Closest approximation for float - case 'double': - return { min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY } // Double in JS is the same as float - case 'unix': - return { min: 0, max: 4294967295 } // uint32 range (seconds since epoch) - case 'datetime': - // 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 'utf8': - return { min: 0, max: 0 } // N/A for strings - default: - return { min: 0, max: 0 } - } -} - -/** - * What a server register entry holds, which is not always a number. - * - * A decimal string for the three types whose composite fills 64 bits as an - * integer. It was `z.number()` for all of them: `applyRegisterValue` read the - * composite back with `getBigUint64` and stored `Number(...)`, so four words of - * 0xFFFF came out 18446744073709552000 rather than 18446744073709551615, and - * `setBigUint64` takes its argument modulo 2 ** 64 rather than throwing, so the - * next single word write on that entry collapsed it to the low word alone. - * Measured: one more word after the flush left the entry holding 1. - * - * A string rather than a `bigint` because the store is persisted through - * `JSON.stringify`, which refuses a bigint outright. - */ -export type ServerRegisterValue = number | string - -/** - * The types whose composite fills 64 bits as an integer. - * - * `applyRegisterValue` reads these three back with `getBigInt64` or - * `getBigUint64`. `double` is the fourth type four registers wide and is not - * here: its composite is a float64, which is exactly what a JS number is. - */ -export const holdsExact64Bits = (dataType: DataType): boolean => - dataType === 'int64' || dataType === 'uint64' || dataType === 'datetime' - -/** - * The exact composite a stored value stands for, or nothing when it stands for - * none. - * - * Takes a `bigint` as well, because `ServerDelayedSetter` holds the composite - * it last folded and that is the value the next word write reads first. - * - * `undefined` rather than `0n`, so a caller merging one word into a composite - * aborts rather than rebuilding it from zero: the other three registers are - * what zero would cost. `BigInt` throws on a fraction and on anything that is - * not digits, and `ServerRegisterEntrySchema` takes a fractional number for a - * 64 bit type, so a hand-edited config reaches this. - */ -export const toExact64Bits = (value: ServerRegisterValue | bigint): bigint | undefined => { - try { - return BigInt(value) - } catch { - return undefined - } -} - -/** - * The types `createRegisters` throws on for a value outside their range. - * - * Measured against the writers it calls. `writeUInt16BE(1.5)` and - * `writeInt32BE(1.5)` truncate, `writeFloatBE(1e300)` and `writeDoubleBE` take - * anything, `unix` is written through `value >>> 0` and `datetime` through - * `encodeIEC870DateTime`, which clamps to the format's own window. What is left - * is the seven below, where `writeUInt32BE(-1)` and - * `writeBigUInt64BE(2n ** 64n)` both answer ERR_OUT_OF_RANGE. `none` and `utf8` - * are not `EncodableDataType` and never reach the encoder at all. - */ -const RANGE_CHECKED_TYPES: readonly DataType[] = [ - 'int16', - 'uint16', - 'bitmap', - 'int32', - 'uint32', - 'int64', - 'uint64' -] - -/** - * Why `createRegisters` cannot encode `value` as `dataType`, or nothing. - * - * `RegisterParamsSchema` bounded the address and left the value bare, so a - * config file carrying `{ dataType: 'uint16', value: 70000 }` reached - * `writeUInt16BE` and threw out of `addRegister`. The ranges are - * `getMinMaxValues`, which the add dialog's mask already reads, so the file and - * the field now answer the same question. - * - * A 64 bit value also has to be a whole number, because `BigInt(1.5)` throws - * where `writeUInt16BE(1.5)` truncates. - */ -export const getValueRangeError = (dataType: DataType, value: number): string | undefined => { - if (!RANGE_CHECKED_TYPES.includes(dataType)) return undefined - - if ((dataType === 'int64' || dataType === 'uint64') && !Number.isInteger(value)) { - return `A ${dataType} value has to be a whole number` - } - - const { min, max } = getMinMaxValues(dataType) - if (value < min || value > max) { - return `A ${dataType} value has to be between ${min} and ${max}` - } - - return undefined -} +import type { ServerRegisters } from './types/server' /** * Whether the mask has taken anything a number could be made of. @@ -332,25 +9,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']})` : ''}` -} - -export const getUsedAddresses = (registers: RegisterParams[]): number[] => { - const addressSet = new Set<number>() - registers.forEach((p) => { - const size = registerWidth(p.dataType, p.length) - - for (let i = 0; i < size; i++) { - addressSet.add(p.address + i) - } - }) - return Array.from(addressSet) + return `${prefix}${error.message || `Connection failed${error['code'] ? ` (${error['code']})` : ''}`}` } /** @@ -360,18 +26,16 @@ export const getUsedAddresses = (registers: RegisterParams[]): number[] => { * `{ value: false }`, so the coil you add and leave off is an entry like any * other, and reading the value instead dropped exactly that unit from a saved * config. + * + * One expression, so the first type holding something is where it stops. + * `UnitIdMenuItem` asks it inside a selector, which answers again for every + * unit id the menu has drawn on every flush of the server store. */ -export const checkHasConfig = (reg: ServerRegisters | undefined): boolean => { - const hasCoils = Object.keys(reg?.coils ?? {}).length > 0 - const hasDiscrete = Object.keys(reg?.discrete_inputs ?? {}).length > 0 - const hasInput = Object.keys(reg?.input_registers ?? {}).length > 0 - const hasHolding = Object.keys(reg?.holding_registers ?? {}).length > 0 - return hasCoils || hasDiscrete || hasInput || hasHolding -} - -export function getAddressFitError(dataType: DataType, address: number, length?: number): boolean { - return address + registerWidth(dataType, length) - 1 > 65535 -} +export const checkHasConfig = (reg: ServerRegisters | undefined): boolean => + Object.keys(reg?.coils ?? {}).length > 0 || + Object.keys(reg?.discrete_inputs ?? {}).length > 0 || + Object.keys(reg?.input_registers ?? {}).length > 0 || + Object.keys(reg?.holding_registers ?? {}).length > 0 export const findAvailablePort = (usedPorts: number[]): number | undefined => { const MIN_PORT = 502 @@ -391,16 +55,3 @@ export const findAvailablePort = (usedPorts: number[]): number | undefined => { return undefined } - -/** - * The camelCase method name a snake_case channel becomes on `window.api`. - * - * The cast is what no compiler can do for a string replace, and it sits here - * rather than at the call site so every caller is checked against it. It holds - * for names of lowercase segments, which is the shape the conformance suite - * requires of `IPC_CHANNELS`: `_([a-z])` leaves a digit or a capital where it - * stands, and `CamelCase` would capitalise it. - */ -export function snakeToCamel<S extends string>(str: S): CamelCase<S> { - return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) as CamelCase<S> -} 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"