diff --git a/.claude/hooks/__tests__/guards.test.mjs b/.claude/hooks/__tests__/guards.test.mjs new file mode 100644 index 0000000..23de63e --- /dev/null +++ b/.claude/hooks/__tests__/guards.test.mjs @@ -0,0 +1,145 @@ +/** + * The three Bash guards, both directions. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case names what must fire and what must not. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOKS = dirname(fileURLToPath(import.meta.url)) +const hook = (name) => join(HOOKS, '..', `${name}.mjs`) + +const fire = (name, toolInput, extra = {}) => { + const payload = JSON.stringify({ + session_id: `test-${Math.random()}`, + ...extra, + ...(toolInput ? { tool_input: toolInput } : {}) + }) + const out = execFileSync('node', [hook(name)], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('precommit-trigger', () => { + it('fires on the whole-project commands the checklist names', () => { + for (const command of [ + 'yarn lint', + 'yarn typecheck', + 'yarn test', + 'yarn verify', + 'yarn test:e2e', + 'yarn test:all:mac' + ]) { + expect(fire('precommit-trigger', { command }), command).not.toBe('') + } + }) + + it('fires when one follows another command', () => + expect(fire('precommit-trigger', { command: 'yarn lint && yarn typecheck' })).not.toBe('')) + it('fires with no whitespace before the separator', () => + expect(fire('precommit-trigger', { command: 'yarn lint; echo done' })).not.toBe('')) + + it('stays quiet on watch mode, which is how you check one change', () => + expect(fire('precommit-trigger', { command: 'yarn test:watch' })).toBe('')) + it('stays quiet on a measurement rather than a check', () => + expect(fire('precommit-trigger', { command: 'yarn test:e2e:scan-perf' })).toBe('')) + it('stays quiet on a single spec or file', () => { + expect(fire('precommit-trigger', { command: 'npx vitest run src/shared/a.test.ts' })).toBe('') + expect(fire('precommit-trigger', { command: 'npx playwright test e2e/a.spec.ts' })).toBe('') + }) + it('stays quiet on prose quoting the command', () => + expect(fire('precommit-trigger', { command: "grep -rn 'yarn lint' CONTRIBUTING.md" })).toBe('')) + + it('states the rule once, then asks the question', () => { + const session = `same-${Math.random()}` + const again = (command) => { + const payload = JSON.stringify({ session_id: session, tool_input: { command } }) + const out = execFileSync('node', [hook('precommit-trigger')], { + input: payload, + encoding: 'utf8' + }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' + } + expect(again('yarn lint')).toContain('start at step 1') + expect(again('yarn test')).toBe('precommit: finishing work, or checking one change?') + }) +}) + +describe('bulk-edit-guard', () => { + it('fires on an in-place sed', () => + expect(fire('bulk-edit-guard', { command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires on an in-place perl', () => + expect(fire('bulk-edit-guard', { command: "perl -i -pe 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires after a separator', () => + expect(fire('bulk-edit-guard', { command: "yarn lint && sed -i.bak 's/a/b/' a.ts" })).not.toBe( + '' + )) + it('stays quiet on a sed that only reads', () => + expect(fire('bulk-edit-guard', { command: "sed -n '1,20p' src/a.ts" })).toBe('')) + it('stays quiet on a heredoc, which the prose trigger owns', () => + expect(fire('bulk-edit-guard', { command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe('')) +}) + +describe('git-restore-guard', () => { + /** A repo with one unstaged change, so the guard has something to warn about. */ + const dirtyRepo = () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-')) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-q') + git('config', 'user.email', 'a@b.c') + git('config', 'user.name', 'test') + writeFileSync(join(dir, 'a.ts'), 'const x = 1\n') + git('add', 'a.ts') + git('commit', '-qm', 'first') + writeFileSync(join(dir, 'a.ts'), 'const x = 2\n') + return dir + } + + it('fires on a checkout naming a path that exists', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain('a.ts') + }) + it('fires on git restore', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git restore a.ts' }, { cwd })).toContain( + 'git restore' + ) + }) + it('fires on any git stash', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git stash' }, { cwd })).toContain('stash pop') + }) + it('stays quiet on a branch switch, which carries the work along', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout main' }, { cwd })).toBe('') + }) + it('stays quiet on checkout -b', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -b feature/x' }, { cwd })).toBe('') + }) + it('stays quiet on checkout -b whose branch name is also a file', () => { + // Only the -b exclusion separates this from a restore: the path check sees + // a name that exists and would say yes. + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git checkout -b a.ts' }, { cwd })).toBe('') + }) + it('stays quiet on --staged alone, the undo of git add', () => { + const cwd = dirtyRepo() + expect(fire('git-restore-guard', { command: 'git restore --staged a.ts' }, { cwd })).toBe('') + }) + it('stays quiet when nothing is unstaged', () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-clean-')) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-q') + git('config', 'user.email', 'a@b.c') + git('config', 'user.name', 'test') + writeFileSync(join(dir, 'a.ts'), 'const x = 1\n') + git('add', 'a.ts') + git('commit', '-qm', 'first') + expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd: dir })).toBe('') + }) +}) diff --git a/.claude/hooks/__tests__/prose-trigger.test.mjs b/.claude/hooks/__tests__/prose-trigger.test.mjs new file mode 100644 index 0000000..ae65dcb --- /dev/null +++ b/.claude/hooks/__tests__/prose-trigger.test.mjs @@ -0,0 +1,117 @@ +/** + * Both directions, in one run. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case here names what must fire and what must not. The payloads + * are built rather than typed: a hand-escaped one in a shell went through as + * unparseable, and the hook read that as nothing to say — which looks exactly + * like a matcher declining. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'prose-trigger.mjs') + +/** What the hook says, or '' when it declined. Throws when it exits non-zero. */ +const fire = (toolInput, sessionId = `test-${Math.random()}`) => { + const payload = JSON.stringify({ session_id: sessionId, ...(toolInput ? { tool_input: toolInput } : {}) }) + const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('prose-trigger fires on every write and edit', () => { + it('a markdown write', () => expect(fire({ file_path: 'a.md', content: 'x' })).not.toBe('')) + it('an edit adding a line comment', () => + expect(fire({ file_path: 'a.ts', new_string: ' // why\nconst x = 1' })).not.toBe('')) + it('an edit adding a block comment', () => + expect(fire({ file_path: 'a.tsx', new_string: '/* why */\nconst y = 2' })).not.toBe('')) + + // The classification these three used to fail is what let a refactor's worth + // of comments through: an edit that reads as code today carries a comment in + // the next call, and the hook has no way to know which is which. + it('code with no comment in it', () => + expect(fire({ file_path: 'a.ts', new_string: 'const x = 1' })).not.toBe('')) + it('a file that is neither markdown nor source', () => + expect(fire({ file_path: 'a.json', content: '{"a":1}' })).not.toBe('')) + it('a write that names a file and no content at all', () => + expect(fire({ file_path: 'a.ts' })).not.toBe('')) +}) + +describe('prose-trigger stays quiet on', () => { + it('a payload with no tool_input', () => expect(fire(null)).toBe('')) + it('a tool that names no file and runs no command', () => + expect(fire({ pattern: 'foo', path: 'src' })).toBe('')) + it('an empty file path', () => expect(fire({ file_path: '' })).toBe('')) +}) + +describe('prose-trigger reaches an edit made through Bash', () => { + // Every one of these wrote a TypeScript comment during the C1 refactor and + // the hook said nothing, because the text sat in the command rather than in + // content or new_string. + it('fires on a heredoc', () => + expect(fire({ command: "python3 - <<'PYEOF'\nprint(1)\nPYEOF" })).not.toBe('')) + it('fires on an unquoted heredoc', () => + expect(fire({ command: 'cat > a.ts < + expect(fire({ command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe('')) + it('fires on tee', () => expect(fire({ command: 'echo x | tee src/a.ts' })).not.toBe('')) + it('fires on a redirect into a file', () => + expect(fire({ command: 'echo x > src/a.ts' })).not.toBe('')) + + it('stays quiet on a command that only reads', () => { + expect(fire({ command: 'yarn test' })).toBe('') + expect(fire({ command: "grep -rn 'utf8' src/" })).toBe('') + expect(fire({ command: 'git status --porcelain' })).toBe('') + }) + it('stays quiet on output thrown away', () => + expect(fire({ command: 'yarn lint > /dev/null 2>&1' })).toBe('')) + it('stays quiet on a `>` that is not a redirect', () => { + // The anchor before the `>` is what separates these from a write. + expect(fire({ command: "awk 'NF>4 { print }' src/a.ts" })).toBe('') + expect(fire({ command: "grep -n '\\-\\->' src/a.ts" })).toBe('') + }) + it('stays quiet on a pipe, which writes no file', () => + expect(fire({ command: 'yarn test 2>&1 | tail -5' })).toBe('')) +}) + +describe('prose-trigger says the whole rule', () => { + it('every time, in the same session', () => { + const session = `same-${Math.random()}` + const first = fire({ file_path: 'a.md', content: 'x' }, session) + const second = fire({ file_path: 'b.md', content: 'y' }, session) + expect(second).toBe(first) + expect(first).toContain('claim, an order, or a measurement') + }) +}) + +describe('prose-trigger reaches a commit message', () => { + it('fires on git commit, which is written through Bash and not a Write', () => + expect(fire({ command: 'git commit -F -' })).not.toBe('')) + it('fires on git merge for the same reason', () => + expect(fire({ command: 'git merge --no-ff feature' })).not.toBe('')) + it('stays quiet on other git commands', () => { + expect(fire({ command: 'git status --porcelain' })).toBe('') + expect(fire({ command: 'git diff --staged' })).toBe('') + expect(fire({ command: 'git log --oneline -5' })).toBe('') + }) + it('fires when a commit follows another command', () => + expect(fire({ command: 'yarn test && git commit -F -' })).not.toBe('')) + // A command that mentions a commit and writes nothing is what keeps this + // matcher honest. A heredoc mentioning one used to be here too, and now + // fires as the write it is. + it('stays quiet on a command that merely mentions the word', () => { + expect(fire({ command: "grep -rn 'commit' docs/" })).toBe('') + expect(fire({ command: "rg 'git commit' .claude/" })).toBe('') + }) +}) + +describe('prose-trigger never interrupts', () => { + it('exits 0 on unparseable stdin', () => { + expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe('') + }) + it('exits 0 on the JSON null that reaches the try and not the catch', () => { + expect(execFileSync('node', [HOOK], { input: 'null', encoding: 'utf8' })).toBe('') + }) +}) diff --git a/.claude/hooks/__tests__/test-trigger.test.mjs b/.claude/hooks/__tests__/test-trigger.test.mjs new file mode 100644 index 0000000..dea379a --- /dev/null +++ b/.claude/hooks/__tests__/test-trigger.test.mjs @@ -0,0 +1,74 @@ +/** + * Both directions, in one run. + * + * A matcher narrowed to kill a false positive is how the false negatives get + * made, so every case here names what must fire and what must not. + */ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'test-trigger.mjs') + +/** What the hook says, or '' when it declined. Throws when it exits non-zero. */ +const fire = (toolInput, sessionId = `test-${Math.random()}`) => { + const payload = JSON.stringify({ + session_id: sessionId, + ...(toolInput ? { tool_input: toolInput } : {}) + }) + const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' }) + return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : '' +} + +describe('test-trigger fires on a test written through Write or Edit', () => { + it('a unit test by its path', () => + expect(fire({ file_path: 'src/shared/__tests__/utils.test.ts', content: 'x' })).not.toBe('')) + it('an e2e spec by its path', () => + expect(fire({ file_path: 'e2e/specs/01-main/01-home.spec.ts', content: 'x' })).not.toBe('')) + it('a source file that grows a describe', () => + expect(fire({ file_path: 'src/a.ts', new_string: "describe('x', () => {})" })).not.toBe('')) + it('a source file that grows an it', () => + expect(fire({ file_path: 'src/a.tsx', new_string: " it('does', async () => {})" })).not.toBe( + '' + )) +}) + +describe('test-trigger stays quiet on', () => { + it('source with no test call in it', () => + expect(fire({ file_path: 'src/a.ts', new_string: 'const x = 1' })).toBe('')) + it('a markdown file that talks about tests', () => + expect(fire({ file_path: 'CONTRIBUTING.md', content: "describe('x', () => {})" })).toBe('')) + it('a payload with no tool_input', () => expect(fire(null)).toBe('')) + it('a command that only runs the suite', () => + expect(fire({ command: 'npx vitest run src/shared/__tests__/utils.test.ts' })).toBe('')) + it('a command that only reads a spec', () => + expect(fire({ command: 'cat e2e/specs/01-main/01-home.spec.ts' })).toBe('')) +}) + +describe('test-trigger reaches a test written through Bash', () => { + it('fires on a heredoc naming a test path', () => + expect( + fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" }) + ).not.toBe('')) + it('fires on a heredoc carrying a test call', () => + expect(fire({ command: "python3 - <<'PY'\ns = \"it('works', () => {})\"\nPY" })).not.toBe('')) + it('stays quiet on a heredoc that writes neither', () => + expect(fire({ command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe('')) +}) + +describe('test-trigger states the rule once, then asks', () => { + it('gives the whole rule first and the questions after', () => { + const session = `same-${Math.random()}` + const first = fire({ file_path: 'a.test.ts', content: 'x' }, session) + const second = fire({ file_path: 'b.test.ts', content: 'y' }, session) + expect(first).toContain('A test you have not seen fail proves nothing') + expect(second).toContain('seen it fail') + expect(second).not.toBe(first) + }) +}) + +describe('test-trigger never interrupts', () => { + it('exits 0 on unparseable stdin', () => + expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe('')) +}) diff --git a/.claude/hooks/__tests__/wired.test.mjs b/.claude/hooks/__tests__/wired.test.mjs new file mode 100644 index 0000000..6d4edc2 --- /dev/null +++ b/.claude/hooks/__tests__/wired.test.mjs @@ -0,0 +1,66 @@ +/** + * The rule this pins is one sentence: **a hook exits 0 or it is broken.** + * + * The harness reads a non-zero code as something to show the user, and 2 as a + * refusal: on `PreToolUse` that blocks the tool call. None of these may reach + * it, on input none of them was written against. + * + * Ported from `scripts/hooks/hooks.test.ts` in the ploxc repo, which pins the + * same rule for every hook its settings wire. + */ +import { describe, it, expect } from 'vitest' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const HOOKS = join(dirname(fileURLToPath(import.meta.url)), '..') + +/** + * Every wired hook, by the filename `.claude/settings.json` names. + * + * This list has to be the wiring's, or a hook added there and not here sits + * outside every claim below. + */ +const WIRED = [ + 'bulk-edit-guard.mjs', + 'git-restore-guard.mjs', + 'precommit-trigger.mjs', + 'prose-trigger.mjs', + 'test-trigger.mjs' +] + +describe('every wired hook', () => { + it('the list here is the list in .claude/settings.json', () => { + const settings = JSON.parse(readFileSync(join(HOOKS, '..', 'settings.json'), 'utf8')) + const wired = new Set() + for (const groups of Object.values(settings.hooks)) { + for (const group of groups) { + for (const hook of group.hooks ?? []) { + // Both documented shapes: `command` plus `args`, and the whole line + // in `command`. Reading one lets a hook wired in the other stay + // outside every claim below. + for (const field of [hook.command, ...(hook.args ?? [])]) { + const name = field?.match(/hooks\/([\w-]+\.mjs)\b/)?.[1] + if (name !== undefined) wired.add(name) + } + } + } + } + expect([...wired].sort()).toEqual([...WIRED].sort()) + }) + + it.each(WIRED)('%s exits 0 on a payload it was not written for', (name) => { + for (const payload of ['', '{not json', '{}', '[]', 'null', '{"tool_input":42}']) { + const run = spawnSync('node', [join(HOOKS, name)], { input: payload, encoding: 'utf8' }) + expect(run.status, `${name} on ${JSON.stringify(payload)}: ${run.stderr}`).toBe(0) + } + }) + + it.each(WIRED)('%s says nothing on a payload it was not written for', (name) => { + for (const payload of ['{}', 'null', '{"tool_input":42}']) { + const run = spawnSync('node', [join(HOOKS, name)], { input: payload, encoding: 'utf8' }) + expect(run.stdout.trim(), `${name} on ${payload}`).toBe('') + } + }) +}) diff --git a/.claude/hooks/bash-target.mjs b/.claude/hooks/bash-target.mjs new file mode 100644 index 0000000..50ac97d --- /dev/null +++ b/.claude/hooks/bash-target.mjs @@ -0,0 +1,32 @@ +/** + * A heredoc puts the bytes in `command`, where `file_path` and `new_string` + * never look. + * + * Ported from `scripts/hooks/bash-target.ts` in the ploxc repo so the two stay + * one rule. Every hook here that has to ask what a shell command is about to do + * asks these three. + */ + +/** Anchored, so a grep for `git commit` is not one. */ +export const IS_COMMIT = /(?:^|[;&|]\s*|&&\s*|\|\|\s*)git\s+(?:commit|merge)\b/ + +/** Whitespace before the `>`, or `NF>4` and `-->` read as writes. */ +export const WRITES_A_FILE = new RegExp( + [ + '<<-?\\s*[\'"]?\\w', // heredoc + '(?:^|[;&|]\\s*)sed\\s+(?:-[^\\s]+\\s+)*-i', // in-place sed + '(?:^|[;&|]\\s*)tee\\b', // tee + '(?:^|[\\s;&|])>>?\\s*(?!/dev/null)[.~$\\w/-]+' // redirect to a path + ].join('|') +) + +const PATH_TOKEN = /[\w./~-]*\.[A-Za-z]\w*/g + +/** A glob names nothing rather than the wrong file. Reads and writes both. */ +export function pathsIn(command) { + const seen = new Set() + for (const token of command.replace(/['"]/g, ' ').match(PATH_TOKEN) ?? []) { + if (token.length > 0) seen.add(token) + } + return [...seen] +} diff --git a/.claude/hooks/bulk-edit-guard.mjs b/.claude/hooks/bulk-edit-guard.mjs new file mode 100644 index 0000000..c38c4e4 --- /dev/null +++ b/.claude/hooks/bulk-edit-guard.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Fires before an in-place scripted edit, and names the reads that catch what + * it did wrong. + * + * A scripted substitution fails in three directions and every one is quiet: it + * removes more than you named, it eats half a sentence in prose and leaves no + * symbol behind, or it raises before writing and changes nothing at all. A green + * suite looks the same after each. + * + * It reminds and never blocks, and it fires once per session. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** In-place editors, in any position a shell would run one. */ +const IN_PLACE = /(^|&&|\|\||\||;|\(|\n)\s*(perl\s+-[a-zA-Z]*i|sed\s+-[a-zA-Z]*i)/ + +const REMINDER = + 'A scripted edit fails quietly in three directions: it removes what you did not name, it ' + + 'eats half a sentence in prose and leaves no symbol behind, or it raises before writing and ' + + 'changes nothing. After it runs: `git diff --stat -- ` (empty means ' + + 'nothing happened), `git diff | grep \'^-\' | grep -E \'const |function |export \'` (what ' + + 'left, by name), and for prose `git diff | grep -E \'^[-+][[:space:]]*(//|\\*)\'`. Not ' + + '`--word-diff`, which prefixes every line with a space so those filters return nothing.' + +/** Every firing after it asks the question instead of repeating the rule. */ +const SHORT = 'bulk edit: did more leave than you named? did anything happen at all?' + +const payload = await readPayload() + +if (!IN_PLACE.test(payload.tool_input?.command ?? '')) process.exit(0) +const first = firstThisSession('bulk-edit-guard', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? REMINDER : SHORT } + }) +) diff --git a/.claude/hooks/git-restore-guard.mjs b/.claude/hooks/git-restore-guard.mjs new file mode 100644 index 0000000..2839c0e --- /dev/null +++ b/.claude/hooks/git-restore-guard.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Fires before a git command that can silently destroy uncommitted work. + * + * `git checkout` and `git restore` naming a path restore from the *index*, and + * on an unstaged file the index is HEAD. `git stash` with nothing to stash is a + * no-op that still succeeds, so the `git stash pop` after it takes whatever was + * already on the stack. + * + * **It matches the verb, not a spelling.** A prose rule naming one spelling, + * `git checkout -- `, is a rule the next differently spelled command walks + * past. It reminds, never blocks, and is silent when nothing is unstaged. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { readPayload } from './payload.mjs' + +/** `git checkout` or `git restore` where a shell would run one, and not `checkout -b`. */ +const RESTORING = /(^|&&|\|\||\||;|\(|\n)\s*git\s+(checkout\s+(?!-b\b|--orphan\b)|restore\s+)/ + +/** Any `git stash`, because the empty-stash trap does not depend on the subcommand. */ +const STASH = /(^|&&|\|\||\||;|\(|\n)\s*git\s+stash\b/ + +/** + * A checkout naming a path restores files; one naming only a ref switches + * branch and carries the work along. Asked relative to `cwd`, the directory the + * command will run in, because a bare name is a path there and not here. + */ +function restoresPaths(text, cwd) { + const match = text.match(/git\s+(?:checkout|restore)\s+([^;&|\n]*)/) + if (match?.[1] === undefined) return false + const args = match[1].trim().split(/\s+/) + // `--staged` alone writes the index from HEAD and leaves the worktree, so it + // is the undo of `git add`. With `--worktree` beside it, it destroys again. + if (args.includes('--staged') && !args.includes('--worktree')) return false + if (args.includes('--')) return true + return args.some((a) => a.length > 0 && !a.startsWith('-') && existsSync(join(cwd, a))) +} + +/** The verb the user actually typed, so the reminder names their command. */ +function verb(text) { + return /git\s+restore\b/.test(text) ? 'git restore' : 'git checkout' +} + +/** + * The files git would not restore from, which is what this command can take + * away. An empty list on failure: `execFileSync` throws on a non-zero exit and + * when it cannot start the process at all, so the catch makes that one answer. + */ +function unstaged(cwd) { + try { + return execFileSync('git', ['diff', '--name-only'], { cwd, encoding: 'utf8' }) + .split('\n') + .filter((line) => line.length > 0) + } catch { + return [] + } +} + +const payload = await readPayload() + +const command = payload.tool_input?.command ?? '' +const isStash = STASH.test(command) +const cwd = payload.cwd ?? process.cwd() +const isCheckout = RESTORING.test(command) && restoresPaths(command, cwd) +if (!isCheckout && !isStash) process.exit(0) + +const atRisk = unstaged(cwd) +if (atRisk.length === 0) process.exit(0) + +const listed = atRisk.slice(0, 10).join(', ') +const rest = atRisk.length > 10 ? ', and more' : '' + +const REMINDER = isStash + ? `These files hold unstaged changes: ${listed}${rest}. \`git stash\` with nothing to stash ` + + 'succeeds anyway, so a later `git stash pop` takes whatever was already on the stack, ' + + 'possibly another branch\'s work. To carry work to another branch, `git checkout ` ' + + 'brings it along when nothing conflicts. To measure another commit, use a worktree.' + : `These files hold unstaged changes: ${listed}${rest}. \`${verb(command)}\` naming a path ` + + 'restores from the index, and for an unstaged file the index is HEAD, so it deletes ' + + 'everything else written in that file with no warning. `git add` first if you mean to ' + + 'keep it. To measure another commit, use a worktree, never the working tree.' + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: REMINDER } + }) +) diff --git a/.claude/hooks/payload.mjs b/.claude/hooks/payload.mjs new file mode 100644 index 0000000..4be2012 --- /dev/null +++ b/.claude/hooks/payload.mjs @@ -0,0 +1,24 @@ +/** + * Reading the hook payload off stdin. + * + * `JSON.parse` returns `null` for the valid JSON `null`, reaching the `try` and + * not the `catch`, so the shape is checked rather than assumed. + * + * **A hook exits 0 or it is broken.** The harness reads a non-zero code as + * something to show the user, and 2 as a refusal. + */ + +/** The payload, or `{}` when stdin holds anything else. */ +export async function readPayload() { + if (process.stdin.isTTY) return {} + try { + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + return parsed + } catch { + // A payload this cannot read is not a reason to interrupt anyone. + return {} + } +} diff --git a/.claude/hooks/precommit-trigger.mjs b/.claude/hooks/precommit-trigger.mjs new file mode 100644 index 0000000..d76b329 --- /dev/null +++ b/.claude/hooks/precommit-trigger.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * The trigger for `/precommit` that does not depend on anyone remembering it. + * + * The checklist hangs on `git commit`, and the shape that keeps recurring is + * earlier than a commit: a whole-project command is run on its own, as "is my + * work finished". By the time the checklist is opened it reads as a repetition + * of work already done, and step 1 is skipped again. So the trigger is the + * *command*. Running one of these is being in the checklist, whether or not it + * was opened. + * + * It is a reminder, never a block, and it fires once per session. These + * commands are legitimate mid-work too, and a hook that argues with you is a + * hook you learn to ignore. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** + * The whole-project commands, longest first so the lookahead below cannot cut + * `test:e2e` down to `test`. + * + * **The test each entry passes: a numbered step of the checklist names it.** + * `test:watch` fails it, because watch mode is how you check one change while + * writing it. So do `npx vitest run ` and `npx playwright test `, + * which name what they run. `test:e2e:scan-perf` fails it too: CONTRIBUTING + * calls it a measurement rather than a check. + */ +const WATCHED = [ + 'test:all:windows', + 'test:all:linux', + 'test:all:mac', + 'test:e2e:packaged', + 'test:e2e', + 'typecheck', + 'verify', + 'lint', + 'test' +] + +/** + * Where a shell would actually run one of them: at the start or after a + * separator, and ending where the script name ends. + * + * **Anchored, not a substring**, or it fires on prose quoting the command. + * A `grep -rn 'yarn lint' CONTRIBUTING.md` would spend the session's one + * reminder. + * + * **The right-hand side is a lookahead, not a space.** A separator can follow + * with no whitespace, as in `yarn lint; echo` or `(yarn lint)`, and what must + * still not match is a longer script name. + */ +const RUNS_IT = new RegExp( + String.raw`(^|&&|\|\||\||;|\(|\n)\s*yarn (${WATCHED.join('|')})(?![A-Za-z0-9:_-])` +) + +const REMINDER = + 'This command is a step of the `/precommit` checklist. Running it means you are in the ' + + 'checklist, so if this is you finishing work rather than checking one change, invoke ' + + '`/precommit` and start at step 1, reading the diff, rather than in the middle. Doing the ' + + 'substance of a step is not doing the step.' + +/** Every firing after it asks the question instead of repeating the rule. */ +const SHORT = 'precommit: finishing work, or checking one change?' + +const payload = await readPayload() + +const command = payload.tool_input?.command ?? '' +if (!RUNS_IT.test(command)) process.exit(0) +const first = firstThisSession('precommit-trigger', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? REMINDER : SHORT } + }) +) diff --git a/.claude/hooks/prose-trigger.mjs b/.claude/hooks/prose-trigger.mjs new file mode 100755 index 0000000..6205175 --- /dev/null +++ b/.claude/hooks/prose-trigger.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * The trigger for `/prose`, on every write rather than the prose-looking ones. + * + * A heredoc puts the sentence in `command`, so a matcher reading `content` and + * `new_string` never sees it. Both halves classify as little as possible, it + * reminds and never blocks, and it says the whole rule every time: a short form + * gets read past. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { IS_COMMIT, WRITES_A_FILE } from './bash-target.mjs' +import { readPayload } from './payload.mjs' + +const RULE = + 'This write is prose, not code. Every sentence is a claim, an order, or a measurement — ' + + 'anything else is narration, so cut it. Then read back what you wrote: for every sentence ' + + 'that quotes a message, states a number, names a file or symbol, asserts a cause, or dates ' + + 'an event, run the command for that shape in `/prose` and paste what it returned. A claim ' + + 'you did not measure does not stay. Then read the whole block you are writing into, not the ' + + 'sentence alone: a correction supersedes what it corrects, and a comment nobody reads end ' + + 'to end only ever grows. No em dash in anything a person reads.' + +const payload = await readPayload() +const input = payload.tool_input ?? {} +const command = input.command ?? '' + +const namesAFile = typeof input.file_path === 'string' && input.file_path.length > 0 + +if (!namesAFile && !IS_COMMIT.test(command) && !WRITES_A_FILE.test(command)) process.exit(0) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: RULE } + }) +) diff --git a/.claude/hooks/session-marker.mjs b/.claude/hooks/session-marker.mjs new file mode 100644 index 0000000..757766e --- /dev/null +++ b/.claude/hooks/session-marker.mjs @@ -0,0 +1,57 @@ +/** + * The once-per-session rule the reminder hooks share. + * + * A hook that says the same thing on every tool call becomes wallpaper, so each + * states its rule on the first firing and asks a short question after that. The + * marker is an empty file in the temp directory, named after the session. + */ + +import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * A day is too short: a session left open over a weekend would lose its marker + * and fire twice. A week is longer than any session and still cleans up. + */ +const WEEK = 7 * 24 * 60 * 60 * 1000 + +/** + * Where one hook's claim for one session is recorded. + * + * Path-safe: a session id is a UUID today, and a `/` in one would otherwise + * write the marker somewhere else or fail silently. + */ +function markerPath(hook, sessionId) { + const session = String(sessionId ?? 'unknown').replace(/[^A-Za-z0-9._-]/g, '_') + return join(tmpdir(), `modbux-${hook}-${session}`) +} + +export function firstThisSession(hook, sessionId) { + const marker = markerPath(hook, sessionId) + + // `wx` fails when the file exists, which makes the check and the claim one + // step. Two tool calls arriving together would both pass a separate `exists` + // test and both take the full text. + try { + writeFileSync(marker, '', { flag: 'wx' }) + } catch { + return false + } + + // Only on the first firing — after that the marker is this session's own. + try { + const prefix = `modbux-${hook}-` + for (const name of readdirSync(tmpdir())) { + if (!name.startsWith(prefix)) continue + const stale = join(tmpdir(), name) + if (existsSync(stale) && Date.now() - statSync(stale).mtimeMs > WEEK) { + rmSync(stale, { force: true }) + } + } + } catch { + // Tidying is not worth a failure. + } + + return true +} diff --git a/.claude/hooks/test-trigger.mjs b/.claude/hooks/test-trigger.mjs new file mode 100644 index 0000000..b9b5154 --- /dev/null +++ b/.claude/hooks/test-trigger.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * The trigger for `/test`, at the moment a test is written rather than at the + * commit that carries it. + * + * A test written from the same model as the fix inherits that model's blind + * spot, and `precommit` opens hours later. + * + * The first firing of a session states the rule; every one after it asks the + * questions. It reminds and never blocks. + * + * Reads the hook payload on stdin, writes hook JSON on stdout. + */ + +import { pathsIn, WRITES_A_FILE } from './bash-target.mjs' +import { readPayload } from './payload.mjs' +import { firstThisSession } from './session-marker.mjs' + +/** The three spellings this suite uses. Vitest and Playwright share them. */ +const IS_TEST_CODE = /\b(describe|it|test)\s*\(/ + +/** A path that is a test whatever it holds. */ +const IS_TEST_PATH = /(^|\/)__tests__\/|(^|\/)e2e\/|\.test\.[tj]sx?$|\.spec\.[tj]sx?$/ + +const FULL = + 'This write is a test. Which tests the change needs, and whether each one can fail, is ' + + '`/test`: cover the blast radius rather than the bug, ship the pair (the state that must ' + + 'not recur and the state that must keep working) and prove the first goes red when the fix ' + + 'is reverted. A test you have not seen fail proves nothing.' + +const SHORT = 'test: seen it fail? does the pair cover both directions?' + +const payload = await readPayload() + +const input = payload.tool_input ?? {} +const path = input.file_path ?? '' +const written = input.content ?? input.new_string ?? '' +const command = input.command ?? '' + +// A heredoc writing a test names neither `file_path` nor `new_string`, so the +// command is asked the same two questions: a test path among the paths it +// names, or a test call in the bytes it is about to write. +const bashWritesATest = + WRITES_A_FILE.test(command) && + (pathsIn(command).some((p) => IS_TEST_PATH.test(p)) || IS_TEST_CODE.test(command)) + +// The content rule reads code only. `describe(` inside a markdown table is +// prose about tests, and matching it fires the hook on documentation. +const isSource = /\.[tj]sx?$/.test(path) +const writeIsATest = IS_TEST_PATH.test(path) || (isSource && IS_TEST_CODE.test(written)) +if (!writeIsATest && !bashWritesATest) process.exit(0) + +const first = firstThisSession('test-trigger', payload.session_id) + +console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: first ? FULL : SHORT } + }) +) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..eea37cf --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,41 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/precommit-trigger.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-restore-guard.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/bulk-edit-guard.mjs"] + } + ] + }, + { + "matcher": "Write|Edit|Bash", + "hooks": [ + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/prose-trigger.mjs"] + }, + { + "type": "command", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/test-trigger.mjs"] + } + ] + } + ] + } +} diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 0000000..84c1bcb --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,217 @@ +--- +name: audit +description: Audit one area of Modbux against the eight criteria and write the findings to tmp/AUDIT-.md, changing no code. Use when the user asks to audit or assess an area, or to re-check one after changes. Do NOT use to review a branch or your own diff — that is /code-review; and do NOT use to execute a finished audit, which is a separate session with fresh context. +--- + +# Audit one area + +**Read-only with respect to code.** The only file this writes is +`tmp/AUDIT-.md`, which is gitignored. Every improvement you spot becomes a +finding, including the one-line obvious ones. + +## The areas + +Split by what the code shares, not by directory size. One area per run. + +| area | what it is | +| --- | --- | +| `modbus` | `main/modules/modbusClient.ts`, `modbusServer.ts` and `modbusServer/` | +| `boundary` | `main/ipc.ts`, `preload/`, `shared/types/ipc.ts`, `main/state.ts`, `main/windows.ts` | +| `shared` | `shared/` minus `types/ipc.ts`: schemas, migrations, pure helpers | +| `stores` | `renderer/src/context/` | +| `client-ui` | `renderer/src/components/client/` | +| `server-ui` | `renderer/src/components/server/`, `components/shared/`, `containers/` | + +## Before you start + +Read `CLAUDE.md`, `CONTRIBUTING.md` and `src/__tests__/conformance.test.ts`. + +The suite already asserts nine conventions. **Do not report what it asserts** — +it is green, so those are closed. Audit what a test cannot see. + +## The eight criteria + +Apply all eight. Do not merge them and do not skip the cosmetic ones. + +1. **What the conformance suite cannot see.** The store-versus-component IPC + rule, the folder-per-component judgement, and anything else CONTRIBUTING + states as prose under *The rules no test can see*. +2. **Duplication.** Search for the shape, not the name. Two functions doing one + job often share no word, so a search for what one is called returns neither. + Report identical copies too, because they diverge later. A duplication claim + carries its own burden of proof, under *Verification*. + → [WHY: a meter is a claim too](./references/a-meter-is-a-claim.md) +3. **Dead code.** Unused exports, unreachable branches, config entries pointing + at deleted paths, comments naming files that are gone. +4. **Deferred comments.** Every `TODO`, `FIXME`, `for now`, `later`, `until we`, + with its exact location and text. +5. **Test coverage.** What is *not* covered. Think like someone with a field + device, not like the author: a unit id of 0 and of 248, an address at 65535 + with a data type needing four registers, a serial port that disappears + mid-read, a config file from two versions ago. +6. **File size and module shape.** Four files stand clear of the rest, and the + distribution is the argument rather than any number you pick: + + ```sh + find src -name '*.ts' -o -name '*.tsx' | grep -v __tests__ | xargs wc -l | sort -rn | head + ``` + + Propose a split only along a real responsibility boundary, never on length + alone. Count code lines and test lines separately: a file that looks like the + worst offender is sometimes a thin one with a large test block bolted on, and + the two call for opposite conclusions. +7. **Architecture fit.** Does this block RTU over TCP, a second client, the + gateway idea, or anything `CHANGELOG.md` says is coming? +8. **What modbus-serial actually does.** For `modbus`, `boundary` and `shared`: + every assumption this code makes about the library is checked against + `node_modules/modbus-serial/`, not against its README. Two of this project's + sharper findings came from that gap. + → [WHY: read the library, not its README](./references/read-the-library.md) + +## Reproduce, do not reason + +**Build first.** A reproduction against a stale bundle looks like a measurement +and is not one: + +```sh +npx electron-vite build +``` + +Then drive it. The e2e fixtures already stand up a server, a client and a socat +serial pair: + +```sh +npx playwright test e2e/specs/01-main/-.spec.ts +``` + +For a claim about the wire, a scratch spec beats reading. For a claim about a +schema or a pure helper, `npx vitest run ` in a scratch test is faster than +either. + +**A reproduced defect beats ten lines of reading.** Delete your scratch files +when you are done. + +## Recording a finding + +Every finding is a heading in this exact form, and then the fields under it: + +``` +### · ` ` · criterion <1-8> · · size · reproduced +``` + +The ID is the area's initial and a number: `F1`, `CU-01`, `B-04`. Drop +`reproduced` when you did not run it. + +**The heading is the form, not a suggestion.** Six agents were given the field +list and five wrote the same heading; the sixth used numbered titles with the +fields as bullets, and a grep for severity across the six documents found +nothing in that one. The document was fine and the count was wrong. + +Under the heading: `claim` (one sentence) · `evidence` (the code, or the command +and its output) · `proposal` · `recipe` if reproduced. + +**severity — what the app does to a user decides it, not how much it annoys +you.** + +| | | +| --- | --- | +| **blocking** | A user gets a wrong answer, a crash, or lost configuration. A malformed frame reaching the socket. A register read as the wrong type. Also: dead code that makes something look covered when it is not. | +| **annoying** | Right behaviour, wrong construction. Duplication, a rule enforced in one of two places, untested logic. Nothing a user sees today; the next change here is where it bites. | +| **cosmetic** | Neither. A stale comment, an unused export, a name that misleads. | + +**size — how much work the proposal is, not how large the defect is.** **S** is +one file and no decision. **M** touches several call sites or needs a small +decision you can make from the area alone. **L** needs a decision that is not +yours: a protocol question, a persisted shape, or anything crossing two areas. + +**Give the exact recipe if you reproduced it** — the file contents and the +command, complete enough to paste. A recipe that does not work as written is +worse than none, because the next person dismisses a real defect. + +**Anchor on the symbol, not the line.** `modbusClient.ts readRegisters`, not +`modbusClient.ts:412`. A line number is right for one commit; the symbol keeps +working. The exception is inside a finding's evidence, where the line is what +makes it checkable. + +**Do not record a file's size as a number.** A size is worth writing only as an +argument: past the threshold, and here is why it is still one file. + +## Also record what you checked and found sound + +A document listing only defects leaves the reader unable to tell "examined and +correct" from "not looked at". Say what you read and what held, briefly. + +A claim you could not settle is an **open question**, labelled as one. Not a +finding. + +## Verification + +Every finding goes to a second agent whose job is to **break** it. That is not a +reviewer looking for problems; it is handed a claim and asked to demonstrate it +wrong. + +- **refuted** — you can demonstrate it is wrong. Give the demonstration. +- **unconfirmed** — neither proved nor disproved. **Keep it.** Say what you + checked and what would settle it. +- **confirmed** — independently verified. + +**The bar for rejection is high.** A wrong finding costs one look; a dropped +correct one costs a defect in a released build. When in doubt: unconfirmed. +**No verdict returned counts as kept, never as rejected.** + +Two exceptions, where the burden runs the other way: + +- **A duplication claim must be actively substantiated.** If you cannot show the + two are equivalent in behaviour, refute it and say how they differ. +- **A claim marked reproduced must actually reproduce.** Finding one that does + not is the most valuable single outcome available to you. + +## What a finding is worth once it is written + +| part | trust | +| --- | --- | +| the file, the symbol, the evidence | high, and verifiable | +| the claim, if reproduced | high | +| the claim, if only read | good | +| **the recipe** | **low** — the most common defect in an audit is a recipe describing an input that does not trigger the behaviour | +| **the proposal** | **low** — a guess by someone who did not read the rest of the file | +| any summary or state | **low** — a compression, and compressions interpret | + +Open the file, reproduce, then decide. Being written down is not evidence. + +## When more than one area is done + +**Group the blocking findings by cause before anyone fixes them.** Six areas +audited in parallel produced 28 blocking findings that turned out to be thirteen +causes, and three of those thirteen were invisible from any single document. +→ [WHY: the same bug from three directions](./references/one-bug-three-reports.md) + +Write `tmp/AUDIT-clusters.md`: one section per cause, naming the findings it +holds, what they share, and what a fix has to settle. Keep the per-finding +evidence where it is; the cluster document points, it does not copy. + +Check every blocking finding reaches the document: + +```sh +for a in ; do + grep -E '^#{3,4} .*· blocking' tmp/AUDIT-$a.md | sed 's/^#* *//;s/ ·.*//' | while read id; do + grep -q "$a/$id" tmp/AUDIT-clusters.md || echo "missing: $a/$id" + done +done +``` + +**A wrong grouping is a new way to be wrong.** A cluster that is really two +causes gets fixed as one and half of it survives, so the refutation reviewer is +asked to break the grouping as well as the claims. + +**A cluster's size is not the largest size inside it.** Each finding was sized +inside one area by someone who could not see the others. Four of the thirteen +here needed a decision that fits in no single area, which is size L whatever the +findings said. + +## Finish by reporting + +The document path, then one line per finding: severity, symbol, claim. Then +stop. Proposing is the whole job, and the session that proved a defect is the +worst one to fix it: it is invested in the finding and has read past everything +it already dismissed. diff --git a/.claude/skills/audit/references/a-meter-is-a-claim.md b/.claude/skills/audit/references/a-meter-is-a-claim.md new file mode 100644 index 0000000..7c17cad --- /dev/null +++ b/.claude/skills/audit/references/a-meter-is-a-claim.md @@ -0,0 +1,33 @@ +# A meter is a claim too + +A script that counts is a claim about a population, and it is wrong in the same +ways prose is. Four times in one branch, on 1 and 2 September 2026: + +**It counted a different set than the sentence named.** The conformance run's +audit said 83 components were unwrapped. Re-measuring with the rule the audit +itself stated found 86: `ExpandCell` in `columns/bitmapExpand.tsx` and `Action` +in both `columns/interpolation.tsx` and `columns/write.tsx` are rendered from a +`getActions` array, which is JSX the first pass did not follow. + +**It looked in one place when the thing had two.** A meter for interactive +elements without a `data-testid` reported the `DateTimePicker` as missing one. +It has carried `add-reg-datetime-input` all along, nested inside `slotProps` +where a reader of JSX attributes does not go. + +**It was wrong in both directions on the same input.** Deciding whether a +tsconfig `include` points at anything by reading the directory part off the glob +got `electron.vite.config.*` wrong, then got it wrong the other way after the +fix. Expanding the glob with `globSync` answers the question that was asked; a +rule about the shape of the string answers a different one. + +**It matched the name and not the thing.** Renaming `rootState` to `clientState` +with a regex renamed the `clientState` *field* on the store as well, producing +`clientState.clientState`. The language service knows which binding an +identifier is on; a search and replace knows only the characters. + +## The rule + +State what population the meter counted, then ask whether that is the noun in +your sentence. Run it over a tree where you already know the answer. If a meter +and a reading disagree, the meter is the one to check first, because it is the +one nobody reads. diff --git a/.claude/skills/audit/references/one-bug-three-reports.md b/.claude/skills/audit/references/one-bug-three-reports.md new file mode 100644 index 0000000..ef5d1e8 --- /dev/null +++ b/.claude/skills/audit/references/one-bug-three-reports.md @@ -0,0 +1,37 @@ +# The same bug from three directions + +Six areas were audited in parallel. The agents could not see each other, which is +what makes a refutation reviewer worth having and also what produces this: + +| the area | names it as | claim | +| --- | --- | --- | +| modbus | `modbusServer.ts removeRegister` | clears a fixed 24 registers where `addRegister` wrote `length ?? 10` | +| server-ui | `DeleteButton` and `addRegister.zustand.ts submit` | removing a utf8 register blanks unrelated registers past its end | +| shared | `addressGrouping.ts getRegisterLength` | returns 24 with no `nextAddress`, and takes no `length` parameter at all | + +One defect. Three blocking findings. Three different files named, and **none of +the three is where the fix belongs**: the width is stated in several places that +do not agree, and only the `shared` agent could see that, because only it was +reading the file that holds the table. + +The `boundary` agent found the same asymmetry a level up: the add path and the +remove path validate against different schemas, so a register can go in at an +address it cannot come out of. + +Fixing any one of these leaves the others standing, and fixing all of them +without settling where the width lives leaves the remaining copies disagreeing. + +## What this cost, and what it bought + +The grouping pass is a reading round over every area document after the audits +and before anyone fixes anything. Twenty-eight blocking findings came out of it +as twenty-one causes. The saving is small and it is not the point: three of the +four reports above are one edit, and nothing inside a single area document says +so. + +It also introduces its own failure: a cluster that is really two causes gets +fixed as one, and the half nobody looked at survives with the ticket closed. That +is not hypothetical. On the run this reference is drawn from, five of seven +clusters held more than one cause and the reviewer named each of them. So the +grouping goes to the refutation reviewer along with the claims, and that reviewer +is asked to break the grouping before it looks at a single claim. diff --git a/.claude/skills/audit/references/read-the-library.md b/.claude/skills/audit/references/read-the-library.md new file mode 100644 index 0000000..f639060 --- /dev/null +++ b/.claude/skills/audit/references/read-the-library.md @@ -0,0 +1,54 @@ +# Read the library, not its README + +Two of this project's sharper findings came from opening +`node_modules/modbus-serial/` instead of trusting a name or a doc page. Both +would have been invisible to any amount of reading in `src/`. + +## A method that lies in its name + +`connectTcpRTUBuffered(host, opts)` sends **Modbus TCP with MBAP framing**, not +RTU over TCP. Its port strips the CRC and prepends an MBAP header: + +```sh +grep -n "MBAP\|crc\|write" node_modules/modbus-serial/ports/tcprtubufferedport.js +``` + +Real encapsulated RTU is `connectTelnet`, whose port writes the raw RTU frame +with its CRC unchanged: + +```sh +grep -n "write" node_modules/modbus-serial/ports/telnetport.js +``` + +Modbux uses `connectTelnet` for `ModbusRtuOverTcp` in `modbusClient.ts connect`. + +The consequence reaches the tests. RTU over TCP cannot be validated against +Modbux's own `ServerTCP`, because that server speaks MBAP: a correct +`connectTelnet` client times out against it, and that timeout is the right +answer rather than a defect. A green happy-path e2e for that transport is not +available without a real gateway. + +## An error that does not travel + +`ServerSerial` builds two objects: + +``` +_serverPath = new SerialPort(options) +_server = _serverPath.pipe(ServerSerialPipeHandler) +``` + +`.pipe()` forwards data and not errors, so an open failure on `_serverPath` — +port not found, permission denied — never reaches `_server`. Listening only on +the object the code hands you produces an unhandled rejection instead of a +message the user can read. + +```sh +grep -n "pipe\|on('error'\|emit(" node_modules/modbus-serial/servers/serverserial.js +``` + +## The rule + +For anything in `modbus`, `boundary` or `shared`, an assumption about +modbus-serial is a finding unless you opened the file that implements it. The +name is not evidence. The README is not evidence. The source in `node_modules` +is. diff --git a/.claude/skills/handover/SKILL.md b/.claude/skills/handover/SKILL.md new file mode 100644 index 0000000..83993f3 --- /dev/null +++ b/.claude/skills/handover/SKILL.md @@ -0,0 +1,118 @@ +--- +name: handover +description: Empty a session into files before its context goes — decide with the user what gets written down, write it, and only then say what the next session needs. Use when the user says "handover", "compact", "wrap this up" or "start clean". Do NOT use to record one thing, which decides one destination and is todo. +--- + +# Emptying a session + +**A prompt is not storage.** What this session learned goes in a file that a +future session opens by itself. What a prompt carries is only what is true of +this moment: which branch, what is uncommitted, what is running, what is next. +All of that is worthless in a week. + +**Decide what gets written first.** Draft the prompt from what is left over, +never from what was interesting. + +Two paths. `/compact` writes its own summary of the conversation, so step 4 has +no reader on that path and step 4b replaces it. + +| next | steps | +| --- | --- | +| `/clear`, or a new session | 1, 2, 3, 4, 5 | +| `/compact` | 1, 2, 3, 4b, 5 | + +## 1. Inventory, by destination + +This fires when the context is nearly gone, so the early half of the session is +the half you recall worst. Reconstruct before you list: + +```sh +git status -sb +git log --oneline -15 # what this session did, not main..HEAD +git diff --stat $(git merge-base main HEAD)..HEAD | tail -3 +``` + +Then sort each item by **where it belongs**, not by how interesting it was. +`/todo` owns the destinations: invoke it rather than deciding here. Two things +it does not own: + +| what it is | where it goes | +| --- | --- | +| state: branch, commits, what is green, what is running | the prompt, or the compact paragraph | +| the next instruction | the user writes it, step 5 | + +**One question makes it mechanical.** For each item: *if a future session needed +this and I were gone, where would it look?* A file, then it goes in the file. +Nowhere, because it only matters for the next hour, then the prompt. **"It would +ask me" is the item most likely to be dropped and the one that must be written.** + +## 2. Put it to the user, item by item + +Not a summary. A list of decisions, each with where you propose to put it, as an +`AskUserQuestion`: keep or drop, your answer first and labelled as the +recommendation, and per option the argument for it **and the strongest one +against, including against the one you recommend.** + +**The destination is not part of the question.** Step 1 settled it. + +Say plainly what you would drop. A session produces more observations than are +worth keeping and the author is the worst judge of which. + +## 3. Write the ones that were kept + +Through `precommit`, like anything else. Being at the end of a session does not +make a doc change cheaper to get wrong. + +**Finish this before drafting anything.** A prompt written first absorbs +whatever was inconvenient to file. + +## 4. Draft the prompt — new-session path + +One fenced block, nothing else inside it, so it is copied in one gesture. Use a +four-backtick fence: a three-backtick one closes early on the first fenced block +inside the prompt. + +- **Where the work is.** Branch, what is committed and what is not, which suites + were last green. Paste step 1's output; from memory this is the one part the + next session cannot check. +- **What is in flight.** A running agent, a branch waiting to merge. +- **What the repo already answers**, by pointing: `CLAUDE.md`, `CONTRIBUTING.md`, + `TODO.md`, the memory directory, the tracker artifact. If one of them is stale, + fixing it was step 3. +- **What the next session should know before it judges the work.** A claim of + yours that is unmeasured, a round that is repairing its own repairs, a range + too large to read at once. This is the only part with no file. +- **The next instruction**, from step 5. Not your guess at it. + +**A prompt that explains something has a step-3 failure in it.** "Watch out for +X" means X has a home and you skipped it. + +## 4b. Write the compact paragraph — compact path + +One paragraph, and **only what has no file may go in it.** Nothing prints a +standing list here, so the paragraph carries itself: state has files, the repo +answers its own shape, and a sentence repeating either spends the space that was +actually at risk. + +What is left is a judgement about *this* session. Hand over the line to type and +nothing else: + +```sh +/compact +``` + +**A paragraph that is only state means there is nothing to type.** Say steps 1 +to 3 hold it, and stop. Inventing a judgement to fill this is worse than leaving +it empty. + +## 5. Ask for the next instruction + +Both paths. An `AskUserQuestion` in step 2's form. + +**Where the answer lands differs.** On the new-session path it is the last +bullet of step 4. After a compact it goes in `TODO.md`, because a paragraph +nobody re-reads is not where an instruction belongs. + +**An answer that changes direction sends you back to step 3.** Ending a session +is a natural moment to change direction, and whoever has been inside the work is +least able to see that. diff --git a/.claude/skills/precommit/SKILL.md b/.claude/skills/precommit/SKILL.md new file mode 100644 index 0000000..91cfc21 --- /dev/null +++ b/.claude/skills/precommit/SKILL.md @@ -0,0 +1,141 @@ +--- +name: precommit +description: Run the checklist before committing or merging — read the diff, lint, typecheck, the unit suite, the e2e specs the change touches, then report and commit. Use when the user says "commit", "committen", "precommit", "merge" or "mergen", and when you run yarn lint, yarn typecheck, yarn test or yarn test:e2e to find out whether your work is finished. Do NOT use to decide which tests a change needs — that is test. +--- + +# Precommit + +## While you work + +**`git add` before you mutate anything.** `git checkout` naming a path restores +from the *index*, which on an unstaged file is HEAD — so the command undoing one +mutation deletes everything else you wrote in that file. + +**A scripted edit leaves no name behind.** After any edit you did not type line +by line: + +```sh +git diff --stat -- # empty means nothing happened +git diff | grep '^-' | grep -E 'const |function |export ' # what left, by name +git diff | grep -E '^[-+][[:space:]]*(//|\*)' # comments it ate or spliced +``` + +A name in the second list you did not decide to remove is one you did not decide +to remove. A `+` comment that does not follow its `-` neighbour is a banner that +now names something else. → [WHY: what a script moved](./references/what-a-script-moved.md) + +**Writing or changing a test?** Its own skill: **`test`**. + +--- + +## 1. Read the diff + +```sh +git status --porcelain # untracked files too — they are in neither diff +git diff +git diff --staged +``` + +Read **every** changed file, and untracked files in full: a new file has no diff, +and is where a fresh copy of something the project already owns lands. + +Then, against `CONTRIBUTING.md` *Code style* and `CLAUDE.md`: + +- **A store selector returning an object** is a whole-store subscription wearing + a selector's clothes. One selector per field. +- **`src/shared` importing from `src/main`** — the one layer that may not reach + back. +- **An interactive element with no `data-testid`** — the e2e suite addresses the + UI through them. +- **A new IPC channel** — a name in `IPC_CHANNELS`, a type in `IpcHandlerSpec`, a + one-line handler. A handler carrying logic belongs in the module it calls. +- **Changed a persisted shape?** It needs a version and a migration. `partialize` + in the store says whether the shape is persisted at all. + +## 2. `yarn lint && yarn typecheck && yarn test` + +About 75 seconds together, most of it typecheck. Run all three: `yarn test` does +not typecheck, so a wrong annotation passes it. + +``` +const n: number = 'a string' # vitest: 1 passed + # tsc: error TS2322 +``` + +## 3. The e2e specs this change touches + +```sh +npx electron-vite build && npx playwright test e2e/specs/01-main/-.spec.ts +``` + +Pick them by what the change reaches, not by name. A change to the server grid +touches `03-server-config`, `04-add-register-modal` and `08-polling-generators`; +a change to writing touches `09-write-operations`; a change to config shapes +touches `05-file-io` and `14-client-config-io`. + +## 4. The full suite, once + +`yarn test:e2e` at the end of a branch, not per commit. It builds first and runs +for minutes, and running it per commit is how a branch stops being worked on. + +**A packaging or dependency change is measured on the artefact**, never on +`package.json`: `asar list` says what ships. + +## 5. Report, then decide what needs asking + +**Report what every step above produced.** A waiver covers the permission, never +the checklist. + +| what you are about to do | ask first? | +| --- | --- | +| `git commit` | no | +| `git push`, `gh` | **yes** — the line is whether it leaves the machine | +| merge | **yes**, and show the squash message first | + +## 6. Commit + +Commit the files you changed. **Never `git add -A` without reading +`git status --porcelain` first** — it takes build output, editor droppings and +anything a tool left behind. +→ [WHY: what git add -A took](./references/what-git-add-a-took.md) + +Conventional Commits, lowercase, no full stop. `feat` is new functionality, +`fix` is something that was broken, `refactor` is the same behaviour in +different code, `test` is test-only, `docs` is docs-only, `chore` is tooling. +Mean what you say. + +Explain **why**, and if something was fixed, what the defect was and how it was +verified. **No `Claude-Session:` trailer** — the message ends at the prose. + +`git commit -F -` reads stdin, which is how a multi-paragraph message gets in +without a shell mangling it. + +**Re-run every pasted command after the last edit, immediately before +`git commit`.** A figure is only true of the tree it ran against, and the way to +get this wrong is to measure, keep working, and commit the measurement beside the +change that moved it. + +## Splitting one change into several commits + +**Never split through the working tree.** `git stash` then `git checkout -- .` +restores every unstashed file from HEAD, and that work is gone. `git stash` with +nothing to stash is a no-op that still succeeds, so the `pop` after it applies +whatever was already on the stack. + +Build the commit in the **index**: `git add -p`, or `git apply --cached` for a +hunk. Both write only the index, so a mistake cannot destroy anything. + +To move uncommitted work to another branch you need no stash at all: +`git checkout -b ` carries it. + +## The prose pass + +Its own skill: **`prose`**. + +**Every commit hands over to it, once, after the message is drafted and before +`git commit`.** Unconditionally — no "if it makes a claim", because deciding +whether your own sentence makes a claim is the judgement that fails. + +**What it covers is the whole diff, not the message.** A false sentence in a code +comment and a false sentence in a commit message are the same defect, and the +comment is the one that survives. diff --git a/.claude/skills/precommit/references/what-a-script-moved.md b/.claude/skills/precommit/references/what-a-script-moved.md new file mode 100644 index 0000000..e61e562 --- /dev/null +++ b/.claude/skills/precommit/references/what-a-script-moved.md @@ -0,0 +1,30 @@ +# What a script moved without saying so + +**A split left six section banners naming the wrong thing.** `AddRegister.tsx` +was 900 lines and 27 components; a script cut it into five files on each `const` +line. A banner sits *above* the component it introduces, so every one of them +stayed behind with the component before it. + +| the banner | what ended up under it | +| --- | --- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file | + +Jens found the first by reading the diff. The sweep that found the other five +was a script too: + +```sh +python3 - <<'PY' +import re, pathlib +for f in pathlib.Path('').glob('*.tsx'): + s = f.read_text() + for m in re.finditer(r'^(?://\n)+// (.+)$', s, re.M): + nxt = re.search(r'^(?:export )?(?:const|function) (\w+)', s[m.end():], re.M) + print(f.name, m.group(1), '->', nxt.group(1) if nxt else 'NOTHING') +PY +``` + +**Nothing failed.** Lint passed, typecheck passed, 591 unit tests and 86 e2e +specs passed. A banner is prose, and prose has no suite — which is why the check +has to be a command run against the diff rather than a suite waited on. diff --git a/.claude/skills/precommit/references/what-git-add-a-took.md b/.claude/skills/precommit/references/what-git-add-a-took.md new file mode 100644 index 0000000..5f3bcde --- /dev/null +++ b/.claude/skills/precommit/references/what-git-add-a-took.md @@ -0,0 +1,22 @@ +# What `git add -A` took + +**A build artefact rode into a commit on this branch.** `tsconfig.node.tsbuildinfo` +is written by `tsc --composite` and had never been tracked. It went in because +the commit was staged with `git add -A` and the status was not read first. + +```sh +git ls-tree main --name-only | grep tsbuildinfo # nothing: it was never on main +grep -n tsbuildinfo .gitignore # nothing: it was not ignored either +``` + +Two things had to be false at once for it to land, and both were: it was not in +`.gitignore`, and nobody looked at what was being staged. `.gitignore` now +carries `*.tsbuildinfo`. + +**Nothing failed.** Lint, typecheck and the suite all passed with it in the tree, +because it is not code. It was caught while consolidating branches, one commit +later, by reading a file list for a different reason. + +The rule is not "never use `-A`". It is that `git status --porcelain` is read +first, every time, and that a name in it you did not expect gets answered before +it is staged. diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md new file mode 100644 index 0000000..16fa3a0 --- /dev/null +++ b/.claude/skills/prose/SKILL.md @@ -0,0 +1,151 @@ +--- +name: prose +description: Measure a sentence you just wrote against the thing it describes, then prune the block it lands in. Use after writing or editing a code comment, a commit message, a CHANGELOG entry, an issue reply, or any markdown paragraph, and before adding to one that already exists — reflowing a sentence that states a measurement counts as writing it. Do NOT use as a tone pass — a sentence that passed its check is finished. +--- + +# Prose + +Is it code? Skip. Everything else gets read back sentence by sentence before it +stands, and **a claim you did not measure does not stay.** + +## The trigger + +You just wrote a sentence containing one of these. Run its command now. + +| the sentence contains | the command | +| --- | --- | +| a quoted message — a snackbar, an error, test output | run it and copy the line out of the output | +| a number — including "both", "all three", "each", "the only remaining" | the command that counts it, pasted with its output | +| a reference — a file, a symbol, a commit | `git show :` and `grep -rn '' src/` | +| a cause — "because", "this closes", "it is missing X" | `grep -rn '' src/` over every caller, and show both sides | +| a date or an order — "pre-existing", "added after", "still" | `git log -S '' --format='%h %ad %s' --date=short` | +| a qualifier — "mostly", "except", a parenthesis | read the hedge back; ask whether the claim in front survives | +| the shape of the code — "X now calls Y", "the copy is gone" | `grep` and `git diff`, never a green suite | +| **a command the reader is told to run** | run it, and read its output the way its reader will | +| **you are adding to a comment or a section that already exists** | read the whole block first — see *read the block* below | + +**A fact from outside this repository has no command here.** Cite the source, or +cut the sentence. + +To quote something the app says, find it rather than remember it: + +```sh +grep -rn "message:" src/main src/renderer/src --include=*.ts --include=*.tsx | grep -v __tests__ +``` + +## The command you paste + +Four ways it is still wrong: + +- **It did not run.** Read the exit code. `yarn lint | tail -5` prints nothing + useful when lint failed on a file you did not open. +- **It answered a different question.** Read your sentence's noun, read what came + back, and say whether they are the same set. +- **It could not have contradicted you.** Searching for the fix never returns a + site that needs it. Search the population — every call, every caller. +- **It matched the sentence you were writing.** Run the search before you paste + it into a file and again after, and see whether the number moved. + +## Figures + +→ [WHY: figures](./references/figures.md) + +**No hand-written figure goes into prose.** Not a careful one, not a checked one, +not one you just measured. + +**The default is not to count.** A sentence with no number in it is the one to +write unless counting earns its place. *"The suite is green"*, *"its callers are +`AddButtons`, `DeleteButton` and the edit submit"* — neither can go stale. + +**"Did you measure it" is the wrong gate**, and it passes the failures. A +measured figure fails when **the command counted one set and the sentence names +another**. So the question is not *did I run it* but **which set did the command +count, and is that the noun in the sentence?** + +**A figure about work in progress does not go in at all** — steps done, files +left, lines in your own diff. It is a prediction, and it is wrong before the +commit lands. + +**Editing a sentence that states a measurement is writing it.** Reflowing or +trimming does not make the measurement true again. Run the command again, or cut +the sentence. + +**The reader has the diff.** Files, functions, call sites — `git show` answers all +of it, correctly, forever. + +Naming a mechanism is a count too: *"the linter would catch it"*, *"nothing else +reads this"* — a claim about a set you did not enumerate. + +**Check the last item in any list of three.** The first two get verified and the +third rides along on the pattern they set. + +## Then: read the block your sentence lands in, and cut it + +**The unit is the block, not the sentence you just wrote.** Every sentence in a +long comment was justified on the day it was added, and nobody reads the whole +thing — so a comment grows by accretion and never shrinks. + +Before the sentence stands, read the **whole** comment block, the whole section: + +- **Does your addition make something above it redundant?** A correction + supersedes what it corrects. Delete the superseded half; do not leave both and + let the reader work out which is current. +- **Is any of it now held by something that cannot go stale?** A Zod schema says + what a shape is, a test says what the code does, `CONTRIBUTING.md` says what + the rules are. Prose repeating one of those is a second copy that drifts. +- **What would a reader lose if the block were three sentences?** Write those + three. If nothing is lost, that is the block. + +**A comment that survives a move has not been re-read.** A section banner +introduces the thing under it; after any split or reorder, check that it still +names what follows. + +**What it costs is paid by a reviewer, and it is more than one reading.** Two +comments in one block that disagree cost a *second measurement*, because the only +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.** + +## Then: can this be written with fewer sentences? + +Ask of each sentence: + +- Cover it. Does anything change for the reader? No — cut it. +- Is it narration? `CLAUDE.md` has the test. +- Does a test or a schema already hold this fact? Then it needs no prose. +- Is this the third rewrite of this paragraph? Delete it. + +| what | how long | +| --- | --- | +| a commit message | what changed and why. The evidence is in the diff, not here | +| a CHANGELOG entry | what a user can now do. Grouped by feature, never by code change | +| a code comment | what the reader cannot see from the code. If it argues, cut it | +| an issue reply | casual, brief, first person. No release-notes formatting | + +## References + +**No line number.** It goes wrong on the next edit above it, and the reader has +to search for the symbol anyway. Name the file and the symbol. + +## The form + +**No em dash in anything a person reads** — product copy, README, release notes, +CHANGELOG, issue replies. Never search and replace: each sentence gets its own +fix, a comma, a colon, a full stop, or a rewrite. An em dash usually marks a +sentence that wants restructuring anyway. + +English in the repository, Dutch in the chat. + +## Sentences with nothing to measure + +- **Description, not assertion** — "this maps the register list into rows" + describes code the reader can see. +- **Reasoning about a decision** — "one selector per field is easier to read" + can be disagreed with; it cannot be wrong. + +## Stop + +Two passes, then stop: the commands, and the shortening. Do not read it a third +time to make it sound better. Do not soften a sentence that survived, and do not +add a hedge to one you now feel less sure about — go and measure it instead. diff --git a/.claude/skills/prose/references/figures.md b/.claude/skills/prose/references/figures.md new file mode 100644 index 0000000..c3ce2cb --- /dev/null +++ b/.claude/skills/prose/references/figures.md @@ -0,0 +1,42 @@ +# Figures + +Why the prohibition is flat rather than conditional. History, not state. + +**A measured figure that counted the wrong set.** The component census behind +the `meme` decision was written as *"102 wrapped, 81 not, 183 total"*. The +command had run. It counted declarations matching `^const [A-Z]`, and the +sentence named *components* — two different sets, because a component declared +`export const` is invisible to that pattern. + +```sh +grep -rn "^const [A-Z]" src/renderer/src --include=*.tsx | wc -l # what ran +grep -rn "^\(export \)\?const [A-Z]" src/renderer/src --include=*.tsx | wc -l # what the sentence meant +``` + +The figure survived three retellings, two artifacts and a decision, because +every retelling was a reflow rather than a re-run. The repair was not a better +count: it was running the same meter over `main` and over the branch, so the +difference could be attributed to the meter rather than to the work. + +**A cause asserted from a neighbouring fact.** *"The schemas already exist; this +is wiring, not authoring"* was written about the IPC validation step. Thirty-seven +Zod schemas did exist, and that was the neighbouring fact. None of them described +an IPC argument: twelve of the thirteen argument types were hand-written +interfaces. + +```sh +grep -rn "export const .*Schema" src/shared --include=*.ts | wc -l # the fact that was true +grep -rn "export interface WriteParameters\|export type WriteParameters" src/shared # the one that mattered +``` + +The sentence sized a step. It was wrong by the whole authoring half. + +**A grep that matched names instead of concerns.** *"Four channels are called +from both a store and a component"* came from intersecting two lists of channel +names. Two of the four were not duplicates at all: in the store, `read` follows +an endianness flip and `stopScanningUnitIds` is a reload cleanup; in the +components both are a button. Same channel, different reason, both correct. + +The command answered *"which names appear on both sides"*. The sentence claimed +*"which concerns are duplicated"*. Nothing about running it again would have +caught that. diff --git a/.claude/skills/prose/references/the-block-not-the-sentence.md b/.claude/skills/prose/references/the-block-not-the-sentence.md new file mode 100644 index 0000000..32b60e0 --- /dev/null +++ b/.claude/skills/prose/references/the-block-not-the-sentence.md @@ -0,0 +1,33 @@ +# What a block nobody re-read cost + +Why `/prose` prunes the block and not the sentence. + +**A comment that survived a move stops being true without being edited.** +`AddRegister.tsx` was split into five files by a script that cut on each `const` +line. A section banner sits *above* the component it introduces, so every banner +stayed behind with the component before it. + +Jens found the first one by reading the diff: a `// MAIN` at the end of +`addRegisterActions.tsx`, introducing nothing. A sweep found five more of the +same shape, one of them mislabelling a live component: + +| banner | what was under it | +| --- | --- | +| `// Min Max components` | `IntervalInputForward` | +| `// Fixed Or Generator` | `CommentField` | +| `// MAIN`, `// Comment`, `// Shared submit logic` | end of file | +| `// Toggle endianness button removed` | end of file, and already dead before the split | + +Nothing failed. Lint passed, typecheck passed, 591 tests passed, and 86 E2E +specs passed. A banner is prose, and prose has no suite. + +**The same day, an edit left a keyword behind.** Reading the app version off the +store instead of over IPC removed the only `await` from two handlers, and left +`async` on both. Jens found those by reading too. + +```sh +yarn lint # passes: require-await is not enabled +``` + +Both are the same failure: the sentence that was edited was checked, and the +block it lived in was not re-read. diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 0000000..004f3d0 --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,111 @@ +--- +name: test +description: Decide which tests a change needs and prove each one can fail. Use before changing code, while writing or editing a test, and after a review or a user hands you a defect. Do NOT use to run the suites before a commit — that is precommit. +--- + +# Test + +## Before you change anything + +Test the **blast radius**, not the bug. The bug's own test goes green and the +regression lands in a neighbouring path of the same function. + +Two steps, no transitive closure: + +1. **The callers** of what you are changing. `grep -rn '' src/` — then ask + per caller whether the rule is true of *it*. +2. **The input forms** that reach it. A register type, a data type, an endianness, + a unit id, an address at the top of its range. Enumerate the axes; do not + assume the value you have in mind is the shape. + +Then: what of that radius is covered — not whether tests exist, whether they +touch *this* — what the behaviour should be across all of it, write those, run +them. Anything already failing that your change does not turn green goes to the +user with the output. Never absorbed, never left because it was there first. + +## Which suite can see it + +| | sees | cannot see | +| --- | --- | --- | +| **vitest** | pure functions, schemas, migrations, stores, a component in isolation | Electron, IPC, the real DataGrid, anything across two windows | +| **Playwright** | the app as a user drives it, both windows, a real Modbus socket | anything without a `data-testid` to address it | + +`yarn test` strips types rather than checking them, so a wrong annotation passes +it and only `yarn typecheck` says so. + +**A behaviour that only the e2e suite can see is a behaviour with one test.** +Say so when you write it, because the fast loop will not protect it. + +## Every fix ships a pair + +| the test | what it guards | when it is red | +| --- | --- | --- | +| the **state that must not recur** | the exact input the finding named | before the fix | +| the **state that must keep working** | the behaviour beside it, which the fix could break | never | + +Apply the fix and run both; revert it and run both again. The first must go red +and the second must stay green. If both stay green, the pair does not test the +fix. + +**A repair changes behaviour in two directions and you will test one.** The +finding names what was wrong; nothing in it names what was right. Name the input +the old behaviour handled correctly, and put it in the list. + +## Then prove it can fail + +A test written after the fix proves nothing until you have watched it go red. +→ [WHY: five rules, five mutations](./references/five-rules-five-mutations.md) + +**Mutate the rule, not the function.** One rule per run, restored between: + +```sh +cp /tmp/orig # then edit one rule +npx vitest run # want: exactly the test for that rule, red +cp /tmp/orig # and green again +git status --porcelain # empty, or the restore did not take +``` + +- **Break the rule you are claiming, not something it shares.** A change to a + helper two rules call goes red for whichever is load-bearing, which reads + exactly like proof for the other. +- **A condition is as many mutations as it has clauses.** Per clause: delete it, + which asks whether it is load-bearing, and put the neighbouring rule in its + place, which asks whether it is the *right* one. The second finds the + survivors — a rival that refuses the same input you happened to write is + invisible to a deletion. +- **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. +- **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 + and a thing correctly produced empty read identically, and most mutations stop + something happening. +- **When the output is a message, assert which one, never how many.** A count + passes with the guard removed, because the input still earns exactly one + message and it is the wrong one. +- **Name the wrong behaviour it rules out.** If you cannot name an input that + answers differently under the rival rule, the test does not discriminate. + +## Write it as the difficult user + +Not the well-behaved one. An address at 65535 with a data type that needs four +registers. A unit id of 0, and of 248. An empty comment, and one with a newline +in it. A config file from two versions ago. A serial port that disappears +mid-read. Everything that "nobody would do" — someone with a field device does. + +## The e2e suite is one app, in order + +`playwright.config.ts` sets `workers: 1` and `retries: 0`, and every spec is a +`test.describe.serial`. The specs share one running app and one server, so: + +- **A spec cleans up before it starts**, not after it ends. `cleanServerState` + is the first test in the ones that configure a server, because the spec before + it may have failed halfway. +- **The number in the filename is the order.** A spec that needs what an earlier + one built is a spec that breaks when run alone. +- **The DataGrid virtualises both axes**, so a column far enough right or a row + far enough down is not in the DOM. `MODBUX_E2E=1` is set by the fixture for + exactly that, and it is never set in a shipped build. +- **A failure that does not reproduce is reported, not re-run into silence.** diff --git a/.claude/skills/test/references/five-rules-five-mutations.md b/.claude/skills/test/references/five-rules-five-mutations.md new file mode 100644 index 0000000..24f993c --- /dev/null +++ b/.claude/skills/test/references/five-rules-five-mutations.md @@ -0,0 +1,35 @@ +# Five rules, five mutations + +**Eight tests were written and none of them had been seen to fail.** +`toRegisterParams` was extracted out of a 89-line function, and its tests were +written against the extracted code and passed on the first run. That is the +shape the rule exists for: a test written after the thing it tests, green from +the start, proves only that it agrees with the code it was copied from. + +Run afterwards, one rule at a time: + +``` + baseline: 27 passed +interval: drop the seconds-to-ms conversion 1 failed | 26 passed +unix: stop converting to seconds 1 failed | 26 passed +utf8: fall back to 1 instead of 10 1 failed | 26 passed +generated timestamp: honour min and max 1 failed | 26 passed +drop the comment 1 failed | 26 passed + restored: 27 passed +``` + +**Exactly one test red per mutation** is what makes the set discriminating +rather than overlapping. A mutation that reddens four tests has found a shared +helper, not a covered rule. + +**A sixth run reported `Tests no tests`**, which looks like a mutation nothing +covers and was a quoting error in the script doing the mutating: the file no +longer parsed, so vitest collected nothing. A mutation run that reports *no +tests* rather than a failure has broken the file, and says nothing about +coverage. + +The restore is checked rather than assumed: + +```sh +git status --porcelain # empty, or the file is still mutated +``` diff --git a/.claude/skills/todo/SKILL.md b/.claude/skills/todo/SKILL.md new file mode 100644 index 0000000..ff5f406 --- /dev/null +++ b/.claude/skills/todo/SKILL.md @@ -0,0 +1,66 @@ +--- +name: todo +description: Route something you want to record to the place that holds it — TODO.md, a GitHub issue, the memory directory, or the plan in flight. Use before writing down a task, a reason or a defect, and whenever the user says "note that", "write that down" or "add a TODO". Do NOT use to decide whether a change needs a test — that is test. +--- + +# Todo + +## First: is it yours to fix instead? + +Writing a defect down converts it into a backlog item, and backlog items feel +handled. Before choosing a place, choose whether there is one: + +- **the fix is small** — do it in this commit, and record nothing +- **larger** — tell the user, and let them decide +- **the user deferred it** — now it goes somewhere, and the rest of this decides where + +**The tell is the sentence you are about to write.** *"Pre-existing"*, *"older +than this branch"*, *"not mine"*, *"pulled in by proximity"*. Each of those can +be true, and none of them answers whether the fix is small. + +## Then: state, or history? + +| what you are writing | where it goes | +| --- | --- | +| something to **do**, on this branch or the next | `TODO.md`, one line | +| something a **user** would recognise as a bug or a request | a GitHub issue | +| **why** — a decision, a measurement, an approach that failed | the memory directory | +| a **preference** the user stated, or how they want to work | the memory directory, never the repository | +| something already in the **plan being executed** | there, and not twice | +| what a user can now **do** | `CHANGELOG.md`, at the release | + +**The tell is the tense.** *"Enable `require-await`"* is a task. *"Two handlers +stayed async because reading the version off the store took their only await, +and lint has the rule off"* is a record. If your sentence explains, it is not a +task, however true it is. + +**The other tell is length.** A task is one line, maybe three. The moment you +reach for a table, a code block, or a paragraph beginning "the cause is", you +are writing a record, and the memory directory is where records go. + +## `TODO.md` is not a record and not shared + +It is untracked. It does not survive a clone, it does not reach anyone else, and +it does not move with a branch. So: + +- **Anything another person needs** goes to a GitHub issue instead. A note to + yourself is the only thing this file holds. +- **A symbol name is authoritative, a line number is a hint.** Name the symbol; + a line number goes stale on the next edit above it. +- **A finished item is deleted, never ticked.** A ticked box is history, and + history in a task list is what makes a task list stop being read. + +## Before adding a paragraph to something already there + +The destination looks settled, so the test above gets skipped — and that is how +a task list turns into a history one paragraph at a time. + +Read the whole entry first. If your addition explains rather than instructs, +the entry stays one line and the explanation goes to memory. +→ [WHY: the explanation that ate the task](./references/the-explanation-that-ate-the-task.md) + +## What the user says goes where they say + +"Note that" and "write that down" name the act, not the destination. Route it by +the table, then say in one line where it landed and why, so a wrong call is +cheap to correct. diff --git a/.claude/skills/todo/references/the-explanation-that-ate-the-task.md b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md new file mode 100644 index 0000000..25a46ff --- /dev/null +++ b/.claude/skills/todo/references/the-explanation-that-ate-the-task.md @@ -0,0 +1,25 @@ +# The explanation that ate the task + +Why the routing test runs again when you are only adding a paragraph. + +**Ploxc's `TODO.md` went back to being a history one appended paragraph at a +time, and nobody noticed until it was unusable.** The gauge that caught it is +lines per open item. Its own `todo` skill records **10.6** on the morning of the +split and **4.7** after it; both figures are quoted from there, not measured +here. + +```sh +awk 'END{printf "%.1f lines per open item\n", NR/o} /^[[:space:]]*- \[ \]/{o++}' TODO.md +``` + +**Read a rise, not a level.** Anything shorter than the current mean lowers it, +so six one-line tasks move the number as far as a large cut does. What it +measures is *prose per task*, and the only thing that raises it is prose. + +The shape is the same at document scale. Ploxc's `precommit` skill records +going 5398, 5523, 5699 and 5799 words over four review rounds, one correct step +added per round, until the round was stopped rather than the steps refused. + +Modbux's `TODO.md` is untracked and small enough that the gauge is not worth +running. The rule it produced is the part that transfers: **an addition that +explains belongs where records go, not on the task.** diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3c3cb84..9205149 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,6 +9,7 @@ module.exports = { plugins: ['react-hooks'], rules: { 'react-hooks/rules-of-hooks': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', 'react-hooks/exhaustive-deps': 'warn', 'prettier/prettier': [ 'error', diff --git a/.gitignore b/.gitignore index 7f0c115..e78a782 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ out .pnp.* tmp +TODO.md temp # Test coverage @@ -27,4 +28,6 @@ test-results e2e/presentation-output # Claude Code -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +# TypeScript incremental build info +*.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index c1244c2..b2fab0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,107 @@ All notable changes to Modbux will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **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 + theirs did. It now says nothing at all for an address it does not host, which + is the only answer that leaves the line alone. Over TCP, where saying nothing + is just a timeout, it replies that the unit is not there. A unit is one you + gave registers to, and the ID picker still lists all 256. +- **A write to a unit you never configured no longer creates one.** Any client + 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 + 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 + dialog started every coil at off, and a write of multiple coils sends every + coil from the one you opened to the end of the range, so everything you had + not touched went out as off. The dialog now opens showing what the last read + returned, which is what goes back to the device. +- **An empty value field no longer writes a zero.** The box turned red and the + write went out anyway, as a 0, because an empty field is what JavaScript reads + as zero. The write buttons are now off until the field holds a number, and the + field is no longer left marked as wrong after you close the dialog. +- **The write dialog no longer keeps the data type of the address you opened + before.** A register your configuration gives no type kept whatever the last + one used, so a value could go out encoded as something the address is not. + Such an address now opens as INT16. +- **Resetting server registers while a client writes to them no longer breaks + the view.** Values arrive in batches, so one could land just after you deleted + a register or reset the type, and that threw where nothing could catch it. + Such a value is now dropped, and the address you deleted stays deleted. +- **A write no longer times out a read that was already on its way.** Logging a + transaction threw away the bookkeeping for every request still waiting for an + answer, so a read overlapping a write reported a timeout that never happened + and the grid kept its old values. +- **A disconnect that hangs no longer costs you auto-reconnect.** When closing + the connection took too long, the client was replaced by a fresh one that + nobody was listening to, so for the rest of the session a dropped connection + went unreported and was never reconnected. +- **The transaction log no longer marks a good read as failed.** Reading a + configuration reads one group of addresses at a time, and once one group + failed, every group after it was logged carrying that group's error. +- **A server that fails to start says so.** The port was reported back to the + view before the server had actually taken it, so a port claimed in the + meantime left you with a server that looked up and answered nothing. Modbux + now waits for the answer, moves to the next free port, and keeps a server on + its old port when a port change cannot be completed. +- **On Linux the offer to unblock port 502 now reaches the split window too.** + It was asked only by the main window, and splitting puts the server in a + window of its own, so anyone who worked that way was left on port 1024 with no + explanation. +- **Unplugging the serial adapter now stops the RTU server in the view.** The + server stayed marked as running on a port that was gone, so the only sign was + that nothing answered it any more. +- **Remove in the edit dialog no longer answers for an address you only typed.** + Changing the address and then pressing Remove left the register you opened + where it was, deleted whatever sat at the address you had typed, and closed + the dialog as though it had worked. The two buttons now follow what the + dialog holds: Remove until you change something, Submit Change once you have. +- **Switching a register between Fixed and Generator no longer leaves the other + side blank.** A fixed register carries no range and a generator carries no + value, so the fields you switched to came up empty and marked wrong, and + Submit Change stayed off until you typed both of them. They now start where a + new register starts. Opening the dialog also no longer flashes the labels red + for a moment. +- **A register at an address outside the map no longer loads.** A configuration + file edited by hand could put one past 65535, where no Modbus request can + reach it and Remove could not clear it, because the add path was the one that + left the range unchecked. Such a file is now refused and names the register, + and a saved setup that already carried one comes back without it, with + everything else kept. +- **Loading a configuration no longer rewrites your own words.** Four register + type names were renamed everywhere they appeared in the file, so a + configuration called "Coils bank A" came back as "coils bank A" and a comment + reading "read InputRegisters here" came back as "read input_registers here". + Saving after that made it permanent. The rename now applies to the keys it was + written for, and only to a configuration old enough to need it. +- **On macOS, opening Modbux again while it sits in the Dock with no window now + brings the window back.** It used to answer with a red "A JavaScript error + occurred in the main process" dialog and no window at all. Your client stays + connected and polling the whole time, and the server keeps the connections it + has, which was already true and is now covered by a test. + +### Changed + +- **Unit 0 is the broadcast address on RTU.** A request to 0 goes to every + device on the bus at once and none of them replies, so registers you put on + unit 0 cannot be read back over serial. A write to 0 still lands, on every + unit the server hosts, and nothing goes back on the line. Modbux says so once + while the RTU server is running and unit 0 holds registers. Over TCP there is + no broadcast and unit 0 stays an ordinary address. +- **The parity list offers none, even and odd.** Mark and space were in it, and + on macOS and Linux picking either one failed the connection outright, because + the serial layer Modbux uses has no setting for them on those platforms. A + saved configuration that carries one now comes back on none, with the com port + and baud rate beside it kept. + ## [2.3.0] - 2026-08-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be8ef33 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# How to write here + +Every sentence is a **claim**, an **order**, or a **measurement**. Anything else +is narration — cut it before writing it. This holds in chat, in code comments, in +commit messages, in the changelog, and in every markdown file here. + +- **claim** — can be contested, and the reader acts differently if it does not + hold. +- **order** — something to run or to do. +- **measurement** — a command and what it printed. + +Before you write anything that is not code, run `/prose`. + +# What this is + +An Electron app: Modbus TCP and RTU, client and server, in one window. + +``` +src/main/ the Electron main process — Modbus client, servers, device state +src/preload/ the bridge; window.api is generated from IPC_CHANNELS +src/shared/ types, Zod schemas, pure helpers, migrations +src/renderer/ the React UI +``` + +`@renderer/*` and `@shared` are the import aliases. There are no others. + +# What breaks if you do not know it + +- **Nothing in `src/shared` may import from `src/main`.** All three processes + import shared; it is the one layer that may not reach back. +- **One store selector per field.** `useClientZustand((z) => z.a)` and then + `((z) => z.b)`, never one selector returning an object. The renderer has zero + whole-store subscriptions and zero `useShallow`, and that is why it renders a + two-thousand-row grid without either. +- **`window.api` is generated.** A channel is a name in `IPC_CHANNELS`, a type in + `IpcHandlerSpec`, and a one-line handler in `main/ipc.ts`. The camelCase method + appears by itself. +- **Every interactive element carries a `data-testid`.** The e2e suite addresses + the UI through them. + +`src/__tests__/conformance.test.ts` asserts the conventions this codebase has +settled, so breaking one fails `yarn test`. What each means, and the ones no test +can see, is in CONTRIBUTING.md under *Code style*. + +# The rules + +@CONTRIBUTING.md + +# What is slow, and what that means + +```sh +yarn lint && yarn typecheck && yarn test # about 75 seconds, most of it typecheck +yarn test:e2e # builds first, then minutes +``` + +Run the e2e specs a change touches while you work, and the suite once, at the +end. `e2e/specs/01-main/` is numbered in the order it runs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f09c6be..2565e45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,14 @@ # Contributing to Modbux -Thanks for your interest in contributing. Modbux was born from real-world frustration with Modbus tooling, and your help makes it better for the entire industry. - -Before you start, please read this document carefully. These guidelines exist to keep the codebase consistent and the review process smooth. They are not suggestions. +Everything below is a rule rather than a suggestion. The ones under *The +conventions this codebase has already settled* are asserted by +`src/__tests__/conformance.test.ts`, so breaking one fails `yarn test` instead of +waiting for a reviewer. ## Ground rules -1. **Open an issue first.** Before writing code, open an issue describing the bug or feature. This avoids wasted effort if the change doesn't align with the project direction. -2. **One PR, one concern.** Don't mix a bug fix with a refactor. Don't sneak in "while I was here" changes. Keep your diff focused. +1. **Open an issue first.** Describe the bug or the feature before writing code, so a change that does not fit the project's direction is found before you build it. +2. **One PR, one concern.** Don't mix a bug fix with a refactor, and don't sneak in "while I was here" changes. 3. **Don't break the build.** Run `yarn verify` before pushing. If it doesn't pass, your PR won't be reviewed. 4. **Match the existing style.** Don't introduce new patterns, conventions, or abstractions without discussing them first. @@ -38,7 +39,7 @@ e2e/ fixtures/ Test data and helpers ``` -**Path aliases:** `@main`, `@renderer/*`, `@preload`, `@shared`, `@backend`. Use them instead of deep relative imports. +**Path aliases:** `@renderer/*` and `@shared`. There are no others. Use them instead of deep relative imports. ## Code style @@ -47,13 +48,117 @@ Formatting and linting are enforced by ESLint, Prettier, and TypeScript strict m Beyond what the linter catches: - **No `any`, no `@ts-ignore`.** If the types are fighting you, your approach is wrong. +- **No `!` either, and no guard a test cannot reach.** `noUncheckedIndexedAccess` is on, so `record[key]` and `array[i]` are `T | undefined` and every index asks a question. `@typescript-eslint/no-non-null-assertion` is an error, because an assertion answers that question without leaving the reasoning behind. The other wrong answer passes lint: `if (!x) break` on an index that is provably in range is a branch no input reaches, no test covers and no mutation can turn red, and it tells the reader the case is possible. Take the index away instead: `readUInt16BE`, `for..of`, `.entries()`, `slice`. Where something really can be missing, handle it and write the test that reaches it. In a test, `data[0]?.id` lets a missing element fail the assertion, but `handler?.()` makes it pass quietly, so that one gets a helper that throws by name. - **Zod for validation.** External data (configs, IPC payloads) is validated with Zod schemas. Don't trust unvalidated input. - **Zustand + Mutative for state.** Follow the existing store patterns. Don't introduce new state management approaches. - **MUI only.** Don't add other UI libraries. -- **Every interactive element needs a `data-testid`** for e2e tests. - **Spell out variable names.** `resetButton`, not `rstBtn`. `registerAddress`, not `regAddr`. Abbreviations make code harder to read. The only exceptions are well-known conventions like `i` in loops, `el` in DOM callbacks, `z` for Zod schemas and Zustand state accessors, and established project abbreviations like `e2e`. - **Match existing patterns.** Look at how the codebase does it, do it the same way. +### The conventions this codebase has already settled + +`src/__tests__/conformance.test.ts` asserts every rule below, so a PR that breaks +one fails `yarn test` rather than waiting for a reviewer to notice. Every +rule asserts that the population it reads is not empty before it asserts the +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. + +**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 +dependency list naming something the component does not own is a list no reader +can check. What the component holds goes in the list; what the store holds is +read through `getState()`. That also covers a *value* the component wants at a +moment rather than on every change: read that way, it causes no render. + +Two shapes, and which one you write depends on whether the component adds +anything. A handler that does its own work is a `useCallback` whose first line +reads the store, `const clientZustand = useClientZustand.getState()`. A prop +that only forwards takes the action itself, +`const setHost = useClientZustand.getState().setHost`, because wrapping it in a +`useCallback` that calls it with the same arguments only gives it a second name. + +Either way the thing has a name and the prop takes the name, so a `getState()` +written into a JSX attribute breaks the rule from the other side: the call sits +where the reader is looking at layout, and a handler with no name is a handler +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. + +**A local store is named after its component.** `.zustand.ts`, matching +the global stores in `context/`, and named after the component rather than the +folder it sits in. + +**MUI is imported deep.** `@mui/material/Button`, not `@mui/material`. The same +for `@mui/icons-material`, `@mui/x-data-grid` and `@mui/x-date-pickers`, because +the rule is about barrels and those are barrels. Two exceptions are the package's +doing rather than a choice: `useGridApiContext` and `useGridApiRef` are exported +by none of the subpaths `@mui/x-data-grid` declares, so they come from +the root. + +**Nothing in `src/shared` imports from `src/main`.** All three processes import +shared; it is the one layer that may not reach back. + +**Every interactive element carries a `data-testid`.** Buttons, fields, sliders, +selects and grid action cells. Containers do not, because the e2e suite reaches +what is inside them instead: a `ToggleButtonGroup` through its `ToggleButton`s, +and a `Select`'s options through `getByRole('option')`. The `Select` itself +carries one. A picker takes the attribute through `slotProps`, which still +counts as carrying it. + +**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 +take an object or a union, and that is where a hand-edited config file arrives. The +schema goes beside the handler in `main/ipc.ts`, and it is only accepted where +`undefined` is an honest answer: a rejected payload has nothing else to give +back, so a channel returning a value has to say so in its type. + +**Every channel has a caller in the renderer.** `window.api` is generated from +`IPC_CHANNELS`, so a channel nobody calls still gets a method, a handler and a +spec entry, and nothing says so. Two sat that way with the app's only config +repair branch inside one of them, which is worse than no repair at all: it reads +like a guard. The caller has to be in `src/renderer`, because a channel only the +e2e suite drives is one the app does not use, and that is a decision to take +rather than to let happen. + +**Every configured path alias is imported through.** `@renderer/*` and `@shared` +are the two, in the tsconfigs and in `electron.vite.config.ts` alike. An alias +nobody imports through resolves whatever it points at, including a directory +that is gone, so the last import leaving is what retires it. + +**Every configured include points at something.** A glob that matches nothing +costs nothing to keep and says nothing when it stops being true, so the test +expands it rather than reading its shape. + +### The rules no test can see + +**The store owns IPC that changes state; a component owns IPC the user asked +for.** Writing through another store is a mutation, and the store owns those. A +button press is the component's. + +The same channel can be called from both and be right both times, which is why +this is a reviewer's judgement and not an assertion: `read` is a consequence of +flipping endianness in the store, and a button in the toolbar. Same channel, two +concerns. + +**A component that owns something gets a folder.** Its store, its helpers, its +subcomponents and their tests go in with it, and the folder takes its name. A +component that owns nothing stays a file: `SliderComponent.tsx` and +`HomeButton.tsx` are leaves, `columns/` and `shared/inputs/` are collections of +them, and neither wants a folder each. Where the line falls is a judgement, so +no test draws it. + ## Commits Follow [Conventional Commits](https://www.conventionalcommits.org/). Lowercase, no period at the end. @@ -100,15 +205,15 @@ Don't use `feat` for a bug fix. Don't use `fix` for a refactor. Mean what you sa are invoked on purpose: - `99-hardware` needs an Arduino on a serial port. It finds the board by USB - vendor ID and skips the suite when none is attached, so it runs unattended -- - but CI has no board, which is why it stays out of `test:e2e`. + vendor ID and skips the suite when none is attached, so it runs unattended. + CI has no board, which is why it stays out of `test:e2e`. - `03-presentation` is a documentation utility, not a check. It clicks through the app and captures what it sees without asserting much, so it costs two minutes to tell you little that `01-main` does not already cover. Run it when the UI changed and the manual needs new screenshots. -`verify` deliberately leaves out `test:e2e:packaged` — it adds a full packaging -step and runs far longer, which is too much for every push. The `test:all:*` +`verify` deliberately leaves out `test:e2e:packaged`, which adds a full packaging +step and runs far longer than is worth doing on every push. The `test:all:*` rounds do include it, and those are for cutting a release rather than for a PR. It is the only check that exercises what actually ships: `electron-vite` externalizes whatever sits in `dependencies` and `electron-builder` packs only @@ -120,10 +225,9 @@ installed Modbux's config. `playwright.config.ts` ignores `99-hardware`, so neither `test:e2e` nor `test:e2e:packaged` picks those specs up. They need an Arduino running `tools/arduino/iem3000.ino` on a serial port. The board is found by USB vendor -ID — `manufacturer` is useless for this, it reads "Microsoft" on Windows where -the generic driver claims the device — and the suite skips itself when no board -is attached. So the round is unattended, and every `test:all:*` ends with it. -Use `yarn test:e2e:hardware` to run it alone. +ID rather than by `manufacturer`, which reads "Microsoft" on Windows where the +generic driver claims the device. Every `test:all:*` ends with this round, and +`yarn test:e2e:hardware` runs it alone. ### Test expectations diff --git a/e2e/fixtures/config-files/server-basic-unit1.json b/e2e/fixtures/config-files/server-basic-unit1.json new file mode 100644 index 0000000..bb1dbd3 --- /dev/null +++ b/e2e/fixtures/config-files/server-basic-unit1.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "modbuxVersion": "2.0.0", + "name": "Basic Server (unit 1)", + "littleEndian": false, + "serverRegistersPerUnit": { + "1": { + "coils": {}, + "discrete_inputs": {}, + "input_registers": { + "0": { + "value": 200, + "params": { + "address": 0, + "registerType": "input_registers", + "dataType": "int16", + "comment": "temperature", + "value": 200 + } + } + }, + "holding_registers": { + "0": { + "value": 100, + "params": { + "address": 0, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "setpoint", + "value": 100 + } + }, + "1": { + "value": 500, + "params": { + "address": 1, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "counter", + "value": 500 + } + } + } + } + } +} diff --git a/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json b/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json new file mode 100644 index 0000000..4d7269b --- /dev/null +++ b/e2e/fixtures/config-files/server-huawei-smartlogger-unit1.json @@ -0,0 +1,936 @@ +{ + "version": 2, + "modbuxVersion": "2.0.0", + "name": "Huawei Smart Logger (unit 1)", + "littleEndian": false, + "serverRegistersPerUnit": { + "1": { + "coils": {}, + "discrete_inputs": {}, + "holding_registers": { + "40000": { + "value": 0, + "params": { + "address": 40000, + "registerType": "holding_registers", + "dataType": "unix", + "comment": "Date & Time (UTC)", + "min": 0, + "max": 0, + "interval": 1000 + } + }, + "40002": { + "value": 12345, + "params": { + "address": 40002, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "City", + "value": 12345 + } + }, + "40004": { + "value": 1, + "params": { + "address": 40004, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Daylight Saving Time Enabled", + "value": 1 + } + }, + "40005": { + "value": 3600, + "params": { + "address": 40005, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Time zone offset (s)", + "value": 3600 + } + }, + "40007": { + "value": 1, + "params": { + "address": 40007, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "DST state (0: not entered, 1: entered)", + "value": 1 + } + }, + "40008": { + "value": 60, + "params": { + "address": 40008, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "DST offset (min)", + "value": 60 + } + }, + "40009": { + "value": 1748961632, + "params": { + "address": 40009, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Local Time", + "value": 1748961632 + } + }, + "40011": { + "value": 2025, + "params": { + "address": 40011, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Year", + "value": 2025 + } + }, + "40012": { + "value": 6, + "params": { + "address": 40012, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Month", + "value": 6 + } + }, + "40013": { + "value": 3, + "params": { + "address": 40013, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Day", + "value": 3 + } + }, + "40014": { + "value": 16, + "params": { + "address": 40014, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Hour", + "value": 16 + } + }, + "40015": { + "value": 46, + "params": { + "address": 40015, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Minute", + "value": 46 + } + }, + "40016": { + "value": 12, + "params": { + "address": 40016, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Second", + "value": 12 + } + }, + "40204": { + "value": 0, + "params": { + "address": 40204, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Transfer trip (0: Run, 1: Fault outage)", + "value": 0 + } + }, + "40420": { + "value": 4294967295, + "params": { + "address": 40420, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Adjustment of all inverters (kW)", + "value": 4294967295 + } + }, + "40422": { + "value": 2147483647, + "params": { + "address": 40422, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Adjustment of all inverters (kVar)", + "value": 2147483647 + } + }, + "40424": { + "value": 4294967295, + "params": { + "address": 40424, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Adjustment of all inverters (kW) VOLATILE", + "value": 4294967295 + } + }, + "40426": { + "value": 0, + "params": { + "address": 40426, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Adjustment of all inverters (kVar) VOLATILE", + "value": 0 + } + }, + "40428": { + "value": 990, + "params": { + "address": 40428, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Active adjustment % (all inverters)", + "value": 990 + } + }, + "40429": { + "value": 30000, + "params": { + "address": 40429, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Power Factor (-1,-0.8]U[0.8,1]", + "value": 30000 + } + }, + "40500": { + "value": 0, + "params": { + "address": 40500, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "DC Current (A)", + "min": 500, + "max": 520, + "interval": 1000 + } + }, + "40521": { + "value": 0, + "params": { + "address": 40521, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Input Power (kW)", + "min": 2000000, + "max": 2004452, + "interval": 1000 + } + }, + "40523": { + "value": 123456789, + "params": { + "address": 40523, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "CO2 Reduction (kg)", + "value": 123456789 + } + }, + "40525": { + "value": 0, + "params": { + "address": 40525, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Active Power (kW)", + "min": 1800000, + "max": 1802356, + "interval": 1000 + } + }, + "40532": { + "value": 0, + "params": { + "address": 40532, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Power Factor", + "min": 999, + "max": 1000, + "interval": 1000 + } + }, + "40543": { + "value": 1, + "params": { + "address": 40543, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Qinghai (1: Unlimited, 2: Limited, 3: Idle, 4: Outage, 5: Comm interrupt)", + "value": 1 + } + }, + "40544": { + "value": 0, + "params": { + "address": 40544, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive Power (kVar)", + "min": 230, + "max": 245, + "interval": 1000 + } + }, + "40550": { + "value": 12345678900, + "params": { + "address": 40550, + "registerType": "holding_registers", + "dataType": "uint64", + "comment": "CO2 Reduction (kg) - larger value range", + "value": 12345678900 + } + }, + "40554": { + "value": 0, + "params": { + "address": 40554, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "DC current 2 (A) - larger value range", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40560": { + "value": 65432485, + "params": { + "address": 40560, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total energy generated by all inverters (kWh)", + "value": 65432485 + } + }, + "40562": { + "value": 35445, + "params": { + "address": 40562, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total energy daily (kWh)", + "value": 35445 + } + }, + "40564": { + "value": 98, + "params": { + "address": 40564, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Duration of daily power generation (h)", + "value": 98 + } + }, + "40566": { + "value": 1, + "params": { + "address": 40566, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Xinjiang", + "value": 1 + } + }, + "40567": { + "value": 1, + "params": { + "address": 40567, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Plant status Ningxia", + "value": 1 + } + }, + "40568": { + "value": 0, + "params": { + "address": 40568, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active alarm sequence number", + "value": 0 + } + }, + "40570": { + "value": 0, + "params": { + "address": 40570, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Historical alarm sequence number", + "value": 0 + } + }, + "40572": { + "value": 0, + "params": { + "address": 40572, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase A current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40573": { + "value": 0, + "params": { + "address": 40573, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase B current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40574": { + "value": 0, + "params": { + "address": 40574, + "registerType": "holding_registers", + "dataType": "int16", + "comment": "Phase C current (A)", + "min": 5020, + "max": 5045, + "interval": 1000 + } + }, + "40575": { + "value": 0, + "params": { + "address": 40575, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage AB (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40576": { + "value": 0, + "params": { + "address": 40576, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage BC (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40577": { + "value": 0, + "params": { + "address": 40577, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Voltage CA (V)", + "min": 4000, + "max": 4023, + "interval": 1000 + } + }, + "40685": { + "value": 0, + "params": { + "address": 40685, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Inverter Efficiency (%)", + "min": 9845, + "max": 9854, + "interval": 1000 + } + }, + "40693": { + "value": 12554, + "params": { + "address": 40693, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Max reactive adjustment (kVar)", + "value": 12554 + } + }, + "40695": { + "value": -12554, + "params": { + "address": 40695, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Min reactive adjustment (kVar)", + "value": -12554 + } + }, + "40697": { + "value": 12345, + "params": { + "address": 40697, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Max active adjustment (kW)", + "value": 12345 + } + }, + "40699": { + "value": 1, + "params": { + "address": 40699, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "0: Locked - 1: Unlocked", + "value": 1 + } + }, + "40700": { + "value": 37, + "params": { + "address": 40700, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "DI status", + "value": 37, + "bitMap": { + "0": { + "comment": "DI1" + }, + "1": { + "comment": "DI2" + }, + "2": { + "comment": "DI3" + }, + "3": { + "comment": "DI4" + }, + "4": { + "comment": "DI5" + }, + "5": { + "comment": "DI6" + }, + "6": { + "comment": "DI7" + }, + "7": { + "comment": "DI8" + } + } + } + }, + "40713": { + "value": 0, + "params": { + "address": 40713, + "registerType": "holding_registers", + "dataType": "utf8", + "comment": "ESN", + "stringValue": "HW-SL3000A", + "length": 10, + "value": 0 + } + }, + "40736": { + "value": 0, + "params": { + "address": 40736, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device access status (0: done, 1: in progress, 2: failed)", + "value": 0 + } + }, + "40737": { + "value": 4, + "params": { + "address": 40737, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Active Mode (0: No limit, 4: Remote scheduling)", + "value": 4 + } + }, + "40738": { + "value": 20000, + "params": { + "address": 40738, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Target total active power (kW)", + "value": 20000 + } + }, + "40740": { + "value": 2, + "params": { + "address": 40740, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Reactive Mode (2: Reactive power fix control)", + "value": 2 + } + }, + "40741": { + "value": 1, + "params": { + "address": 40741, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Reactive power curve mode (0: PF, 1: reactive fixed)", + "value": 1 + } + }, + "40742": { + "value": 123, + "params": { + "address": 40742, + "registerType": "holding_registers", + "dataType": "int32", + "comment": "Reactive power scheduling target value", + "value": 123 + } + }, + "40802": { + "value": 100, + "params": { + "address": 40802, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Active Scheduling percentage (%)", + "value": 100 + } + }, + "41124": { + "value": 1234, + "params": { + "address": 41124, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "CO2 emission reduction coefficient (kg/kWh)", + "value": 1234 + } + }, + "41934": { + "value": 1800000, + "params": { + "address": 41934, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "PV Module capacity (kW)", + "value": 1800000 + } + }, + "41936": { + "value": 2000000, + "params": { + "address": 41936, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Rated plant capacity (kW)", + "value": 2000000 + } + }, + "41938": { + "value": 60000, + "params": { + "address": 41938, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Total rated capacity of grid-connected inverters (kW)", + "value": 60000 + } + }, + "41940": { + "value": 750, + "params": { + "address": 41940, + "registerType": "holding_registers", + "dataType": "uint32", + "comment": "Conversion coefficient", + "value": 750 + } + }, + "41942": { + "value": 0, + "params": { + "address": 41942, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Status of communication", + "value": 0 + } + }, + "41947": { + "value": 0, + "params": { + "address": 41947, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Communication abnormal shutdown (0: Disable, 1: Enable)", + "value": 0 + } + }, + "41948": { + "value": 300, + "params": { + "address": 41948, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Communication abnormal detection time (s) [60-1800]", + "value": 300 + } + }, + "41949": { + "value": 1, + "params": { + "address": 41949, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Auto start upon communication recovery (0: Disable, 1: Enable)", + "value": 1 + } + }, + "42017": { + "value": 2025, + "params": { + "address": 42017, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Year", + "value": 2025 + } + }, + "42018": { + "value": 6, + "params": { + "address": 42018, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Month", + "value": 6 + } + }, + "42019": { + "value": 3, + "params": { + "address": 42019, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Day", + "value": 3 + } + }, + "42020": { + "value": 16, + "params": { + "address": 42020, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Hour", + "value": 16 + } + }, + "42021": { + "value": 46, + "params": { + "address": 42021, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Minute", + "value": 46 + } + }, + "42022": { + "value": 12, + "params": { + "address": 42022, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "SystemTime: Second", + "value": 12 + } + }, + "42150": { + "value": 0, + "params": { + "address": 42150, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Current error during scanning", + "value": 0 + } + }, + "50000": { + "value": 2048, + "params": { + "address": 50000, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 1", + "value": 2048, + "bitMap": { + "3": { + "comment": "Active Schedule" + }, + "11": { + "comment": "Reactive Schedule" + } + } + } + }, + "50001": { + "value": 8, + "params": { + "address": 50001, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 2", + "value": 8, + "bitMap": { + "1": { + "comment": "MCB" + }, + "2": { + "comment": "Cubicle" + }, + "3": { + "comment": "Addr Conflict" + }, + "4": { + "comment": "SPD" + }, + "5": { + "comment": "DI1" + }, + "6": { + "comment": "DI2" + }, + "7": { + "comment": "DI3" + }, + "8": { + "comment": "DI4" + }, + "9": { + "comment": "DI5" + }, + "10": { + "comment": "DI6" + }, + "11": { + "comment": "DI7" + }, + "12": { + "comment": "DI8" + }, + "13": { + "comment": "24V" + }, + "14": { + "comment": "License" + } + } + } + }, + "50002": { + "value": 0, + "params": { + "address": 50002, + "registerType": "holding_registers", + "dataType": "bitmap", + "comment": "Alarm Info 3", + "value": 0, + "bitMap": { + "0": { + "comment": "Cert alarm 1" + }, + "1": { + "comment": "Cert alarm 2" + }, + "2": { + "comment": "Cert alarm 3" + } + } + } + }, + "65521": { + "value": 5, + "params": { + "address": 65521, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device list change number", + "value": 5 + } + }, + "65522": { + "value": 1, + "params": { + "address": 65522, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Port number", + "value": 1 + } + }, + "65523": { + "value": 0, + "params": { + "address": 65523, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device Address", + "value": 0 + } + }, + "65524": { + "value": 0, + "params": { + "address": 65524, + "registerType": "holding_registers", + "dataType": "utf8", + "comment": "Device name", + "stringValue": "SmartLogger", + "length": 10, + "value": 0 + } + }, + "65534": { + "value": 45057, + "params": { + "address": 65534, + "registerType": "holding_registers", + "dataType": "uint16", + "comment": "Device connection status (0xB000=Disconnected, 0xB001=Online)", + "value": 45057 + } + } + }, + "input_registers": {} + } + } +} diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts index d8b0e6d..557c6c1 100644 --- a/e2e/fixtures/helpers.ts +++ b/e2e/fixtures/helpers.ts @@ -1,4 +1,4 @@ -import { test, expect, type Locator, type Page } from '@playwright/test' +import { test, expect, type ElectronApplication, type Locator, type Page } from '@playwright/test' import type { RegisterDef, ServerConfig } from './types' /** Scale a timeout for fast mode: 300→75ms, 200→50ms, ≤100→0ms, 500→100ms */ @@ -52,6 +52,19 @@ export async function cell(p: Page, rowId: number, field: string): Promise { + const text = await p.getByTestId(`section-${registerType}`).textContent() + const count = text?.match(/\((\d+)\)/)?.[1] + if (count === undefined) throw new Error(`no count in the ${registerType} title: ${text}`) + return Number(count) +} + /** Assert a cell's text, retrying until it matches or the timeout expires. */ export async function expectCell( p: Page, @@ -225,14 +238,15 @@ export async function setupServerConfig( // This lets us open the modal once per type and chain with Add & Next. const byType = new Map() for (const reg of config.registers) { - if (!byType.has(reg.registerType)) byType.set(reg.registerType, []) - byType.get(reg.registerType)!.push(reg) + const forType = byType.get(reg.registerType) ?? [] + forType.push(reg) + byType.set(reg.registerType, forType) } for (const regs of byType.values()) { - for (let i = 0; i < regs.length; i++) { + for (const [i, reg] of regs.entries()) { const isLast = i === regs.length - 1 - await addRegister(p, { ...regs[i], next: !isLast }, i > 0, fast) + await addRegister(p, { ...reg, next: !isLast }, i > 0, fast) } } } else { @@ -260,7 +274,9 @@ export async function setupServerConfig( } }) - const boolsWithComment = config.bools.filter((b) => b.comment) + const boolsWithComment = config.bools.filter( + (b): b is (typeof config.bools)[number] & { comment: string } => Boolean(b.comment) + ) if (boolsWithComment.length > 0) { await test.step(`set ${boolsWithComment.length} bool comments`, async () => { for (const bool of boolsWithComment) { @@ -269,7 +285,7 @@ export async function setupServerConfig( await row.locator('p').click() const input = row.locator('input') await expect(input).toBeVisible() - await input.fill(bool.comment!) + await input.fill(bool.comment) await input.press('Enter') await p.waitForTimeout(t(100, fast)) } @@ -575,9 +591,7 @@ export async function writeCoil(p: Page, address: number, state: boolean): Promi await p.getByTestId(`write-action-${address}`).click() await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() - if (state) { - await p.getByTestId(`write-coil-${address}-select-btn`).click() - } + await setCoilButton(p, address, state) await p.getByTestId('write-fc5-btn').click() await p.getByTestId('write-submit-btn').click() @@ -587,6 +601,41 @@ export async function writeCoil(p: Page, address: number, state: boolean): Promi await expect(p.getByTestId(`write-coil-${address}-select-btn`)).not.toBeVisible() } +/** + * Click a coil button only when it is not already showing the state you want. + * + * The dialog opens with what the grid holds, so a coil the device already + * answered TRUE for opens pressed, and clicking it would send the opposite of + * what the caller asked for. + */ +async function setCoilButton(p: Page, address: number, state: boolean): Promise { + const coil = p.getByTestId(`write-coil-${address}-select-btn`) + if ((await coil.getAttribute('aria-pressed')) !== String(state)) await coil.click() +} + +/** Open the write dialog on `address`, set the given coils, and send FC15 */ +export async function writeCoilsFc15( + p: Page, + address: number, + states: Record +): Promise { + await p.getByTestId(`write-action-${address}`).click() + await expect(p.getByTestId(`write-coil-${address}-select-btn`)).toBeVisible() + + await p.getByTestId('write-fc15-btn').click() + + for (const [coilAddress, state] of Object.entries(states)) { + await expect(p.getByTestId(`write-coil-${coilAddress}-select-btn`)).toBeVisible() + await setCoilButton(p, Number(coilAddress), state) + } + + await p.getByTestId('write-submit-btn').click() + + // Close the dialog + await p.keyboard.press('Escape') + await expect(p.getByTestId(`write-coil-${address}-select-btn`)).not.toBeVisible() +} + /** Ensure a server panel is in the desired collapse state */ export async function setServerPanelCollapsed( p: Page, @@ -679,3 +728,21 @@ export async function openColumnMenu(p: Page, field: string): Promise { await expect(menu).toBeVisible() }) } + +/** + * Split the server out of Home and hand back the window that opens. + * + * `waitForEvent` hangs its listener at the moment it is called, so awaiting the + * click first leaves a gap the window can open in, and by then the event is + * gone. Measured on Linux with the listener armed after the click: 2 timeouts + * in 3 runs, with the Server window open in all three. Arming it beside the + * click is what Playwright documents for this. + */ +export async function splitOutServerWindow(app: ElectronApplication, p: Page): Promise { + const [serverPage] = await Promise.all([ + app.waitForEvent('window', { timeout: 10_000 }), + p.getByTestId('home-split-btn').click() + ]) + await serverPage.waitForLoadState('domcontentloaded') + return serverPage +} diff --git a/e2e/fixtures/launch.ts b/e2e/fixtures/launch.ts index 1d9c907..d9d0c51 100644 --- a/e2e/fixtures/launch.ts +++ b/e2e/fixtures/launch.ts @@ -91,17 +91,31 @@ function isolatedUserDataDir(): string { return userDataDir } +/** + * A profile no other launch shares, for a spec that runs two apps against each + * other. The single instance lock is taken per profile, so two launches sharing + * one is what makes the second lose it, and a spec that wants that has to keep + * the worker's app out of the race. + */ +export function ownProfileDir(): string { + return mkdtempSync(join(tmpdir(), 'modbux-e2e-own-')) +} + /** * Launch options for every `electron.launch()` call in the suite, so dev and * packaged runs stay in sync. + * + * `userDataDir` names the profile explicitly, for a spec that launches more + * than one app and decides itself which of them share state and a lock. */ -export function launchOptions(): LaunchOptions { +export function launchOptions(userDataDir?: string): LaunchOptions { // Packaged runs always get their own profile. Dev runs use the default one // (matching how the suite has always run) unless asked to isolate — set // E2E_ISOLATED_PROFILE=1 to check whether a spec depends on state left on // this machine by earlier runs rather than on state it sets up itself. const isolate = isPackaged || process.env.E2E_ISOLATED_PROFILE === '1' - const args = isolate ? [`--user-data-dir=${isolatedUserDataDir()}`] : [] + const profile = userDataDir ?? (isolate ? isolatedUserDataDir() : undefined) + const args = profile ? [`--user-data-dir=${profile}`] : [] // MODBUX_E2E turns off DataGrid virtualisation, so a locator finds the column // or row it names instead of only the ones the current window happens to 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 685fd6a..6602ec9 100644 --- a/e2e/specs/01-main/04-add-register-modal.spec.ts +++ b/e2e/specs/01-main/04-add-register-modal.spec.ts @@ -184,6 +184,33 @@ test.describe.serial('AddRegister modal — state management and validation', () await mainPage.waitForTimeout(300) }) + test('edit mode: typing swaps which of the two buttons is offered', async ({ mainPage }) => { + await mainPage.getByTestId('server-edit-reg-holding_registers-100').click() + await mainPage.waitForTimeout(300) + + // Nothing typed, so there is nothing to submit and Remove means the + // register the dialog was opened on. + await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeDisabled() + await expect(mainPage.getByTestId('add-reg-remove-btn')).toBeEnabled() + + const addressInput = mainPage.getByTestId('add-reg-address-input').locator('input') + await addressInput.fill('300') + await mainPage.waitForTimeout(300) + + // A typed address means the register is being moved. Remove there answered + // for 300, so the register at 100 stayed and the dialog closed anyway. + await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeEnabled() + await expect(mainPage.getByTestId('add-reg-remove-btn')).toBeDisabled() + + await addressInput.fill('100') + await mainPage.waitForTimeout(300) + await expect(mainPage.getByTestId('add-reg-submit-btn')).toBeDisabled() + await expect(mainPage.getByTestId('add-reg-remove-btn')).toBeEnabled() + + await mainPage.keyboard.press('Escape') + await mainPage.waitForTimeout(300) + }) + test('remove test registers via edit modal', async ({ mainPage }) => { // Remove register at 100 await mainPage.getByTestId('server-edit-reg-holding_registers-100').click() diff --git a/e2e/specs/01-main/09-write-operations.spec.ts b/e2e/specs/01-main/09-write-operations.spec.ts index 46d2568..3f1a7e8 100644 --- a/e2e/specs/01-main/09-write-operations.spec.ts +++ b/e2e/specs/01-main/09-write-operations.spec.ts @@ -12,6 +12,7 @@ import { cleanServerState, writeRegister, writeCoil, + writeCoilsFc15, expectCell, expectCellContains } from '../../fixtures/helpers' @@ -185,6 +186,38 @@ test.describe.serial('Write Operations', () => { await expectCell(mainPage, 0, 'bit', 'FALSE') await clearData(mainPage) }) + + test('set a coil above the one FC15 will be opened on', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await writeCoil(mainPage, 6, true) + }) + + test('verify the neighbour is TRUE before the FC15 write', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 6, 'bit', 'TRUE') + }) + + /** + * FC15 sends every coil from the opened address to the end of the range, so + * coil 6 is in the frame a write opened on coil 5 puts on the wire. + */ + test('write coil 5 via FC15', async ({ mainPage }) => { + await writeCoilsFc15(mainPage, 5, { 5: true }) + }) + + test('verify FC15 wrote coil 5 and left coil 6 alone', async ({ mainPage }) => { + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 5, 'bit', 'TRUE') + await expectCell(mainPage, 6, 'bit', 'TRUE') + }) + + test('put both coils back to FALSE', async ({ mainPage }) => { + await writeCoilsFc15(mainPage, 5, { 5: false, 6: false }) + await readRegisters(mainPage, '0', '8') + await expectCell(mainPage, 5, 'bit', 'FALSE') + await expectCell(mainPage, 6, 'bit', 'FALSE') + await clearData(mainPage) + }) }) // ─── Cleanup ─────────────────────────────────────────────────────── diff --git a/e2e/specs/01-main/10-split-view.spec.ts b/e2e/specs/01-main/10-split-view.spec.ts index ce12b80..baca82d 100644 --- a/e2e/specs/01-main/10-split-view.spec.ts +++ b/e2e/specs/01-main/10-split-view.spec.ts @@ -1,11 +1,16 @@ import { test, expect } from '../../fixtures/electron-app' -import { navigateToHome } from '../../fixtures/helpers' +import { navigateToHome, navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { type Page } from '@playwright/test' +import net from 'net' let serverPage: Page +let serverPort: number +let master: net.Socket +const masterEvents: string[] = [] test.describe.serial('Split View — Server in separate window', () => { test.afterAll(async ({ electronApp }) => { + master?.destroy() await electronApp.evaluate(({ BrowserWindow }) => { BrowserWindow.getAllWindows() .filter((w) => w.getTitle() === 'Server') @@ -13,14 +18,26 @@ test.describe.serial('Split View — Server in separate window', () => { }) }) - test('navigate to home', async ({ mainPage }) => { + test('read the server port, then navigate to home', async ({ mainPage }) => { + await navigateToServer(mainPage) + serverPort = Number( + await mainPage.getByTestId('server-port-input').locator('input').inputValue() + ) + expect(serverPort).toBeGreaterThan(0) await navigateToHome(mainPage) }) + test('a master connects to the server before the window opens', async () => { + master = net.connect(serverPort, '127.0.0.1') + await new Promise((resolve, reject) => { + master.once('connect', resolve) + master.once('error', reject) + }) + master.on('close', () => masterEvents.push('closed')) + }) + test('open split view from Home', async ({ electronApp, mainPage }) => { - await mainPage.getByTestId('home-split-btn').click() - serverPage = await electronApp.waitForEvent('window', { timeout: 10000 }) - await serverPage.waitForLoadState('domcontentloaded') + serverPage = await splitOutServerWindow(electronApp, mainPage) await serverPage.waitForTimeout(500) const title = await electronApp.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows() @@ -40,6 +57,17 @@ test.describe.serial('Split View — Server in separate window', () => { await expect(serverPage.getByTestId('section-holding_registers')).toBeVisible() }) + /** + * The second window runs the store module again, so `init` calls + * `createServer` for every uuid. When that rebound the listener, + * `ServerTCP.close` destroyed every open socket and a master outside Modbux + * got a FIN. Only the e2e suite can see this: it takes two windows. + */ + test('the master keeps its connection through the window opening', async () => { + expect(masterEvents).toEqual([]) + expect(master.readyState).toBe('open') + }) + test('close server window and verify main returns to normal', async ({ electronApp, mainPage diff --git a/e2e/specs/01-main/16-client-rtu.spec.ts b/e2e/specs/01-main/16-client-rtu.spec.ts index a0e67d8..82124cc 100644 --- a/e2e/specs/01-main/16-client-rtu.spec.ts +++ b/e2e/specs/01-main/16-client-rtu.spec.ts @@ -63,13 +63,16 @@ test.describe.serial('Client RTU — serial protocol configuration', () => { await expect(mainPage.getByTestId('rtu-baudrate-select')).toContainText('115200') }) - test('parity select has expected options', async ({ mainPage }) => { + // The count is the assertion. A list this select reads from somewhere else + // passes every visibility check while offering a fourth the binding refuses. + test('parity select offers the three the serial binding accepts', async ({ mainPage }) => { await mainPage.getByTestId('rtu-parity-select').click() - const expectedOptions = ['none', 'even', 'odd', 'mark', 'space'] + const expectedOptions = ['none', 'even', 'odd'] for (const option of expectedOptions) { await expect(mainPage.getByRole('option', { name: option })).toBeVisible() } + await expect(mainPage.getByRole('option')).toHaveCount(expectedOptions.length) await mainPage.keyboard.press('Escape') }) diff --git a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts index 4b9fd5c..b0cd959 100644 --- a/e2e/specs/01-main/17-huawei-smartlogger.spec.ts +++ b/e2e/specs/01-main/17-huawei-smartlogger.spec.ts @@ -18,7 +18,8 @@ import { expandAllServerPanels, navigateToServer, expectCellContains, - expectCell + expectCell, + sectionCount } from '../../fixtures/helpers' import { HUAWEI_UNIT_0 } from '../../fixtures/test-data' import { resolve } from 'path' @@ -55,13 +56,8 @@ test.describe.serial('Huawei Smart Logger — comprehensive integration test', ( }) test('verify server has all holding registers', async ({ mainPage }) => { - const section = mainPage.getByTestId('section-holding_registers') // The config has 75 holding registers - const text = await section.textContent() - const match = text?.match(/\((\d+)\)/) - expect(match).toBeTruthy() - const count = Number(match![1]) - expect(count).toBeGreaterThanOrEqual(70) + expect(await sectionCount(mainPage, 'holding_registers')).toBeGreaterThanOrEqual(70) }) // ─── Client setup ───────────────────────────────────────────────── 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 6b7ad7d..3290b52 100644 --- a/e2e/specs/01-main/18-huawei-manual-client.spec.ts +++ b/e2e/specs/01-main/18-huawei-manual-client.spec.ts @@ -74,39 +74,42 @@ function buildGroups(regs: Record, maxLength: number = 3 .map(([addr, reg]) => ({ address: Number(addr), reg })) .sort((a, b) => a.address - b.address) + // A register's width needs the address after it, so the widths are taken + // first and the packing below is one pass. + const withEnd = sorted.map((entry, index) => ({ + ...entry, + end: entry.address + getWidth(entry.reg.dataType, entry.address, sorted[index + 1]?.address) - 1 + })) + const groups: AddressGroup[] = [] - let i = 0 - - while (i < sorted.length) { - const startAddr = sorted[i].address - const groupRegs = [sorted[i]] - let endAddr = - startAddr + getWidth(sorted[i].reg.dataType, startAddr, sorted[i + 1]?.address) - 1 - let j = i - - while (j + 1 < sorted.length) { - const next = sorted[j + 1] - const nextWidth = getWidth(next.reg.dataType, next.address, sorted[j + 2]?.address) - const nextEnd = next.address + nextWidth - 1 - const span = Math.max(endAddr, nextEnd) - startAddr + 1 - - if (span <= maxLength) { - endAddr = Math.max(endAddr, nextEnd) - groupRegs.push(next) - j++ - } else { - break + let open: { start: number; end: number; registers: typeof withEnd } | undefined + + const close = (): void => { + if (open) { + groups.push({ + start: open.start, + length: open.end - open.start + 1, + registers: open.registers + }) + } + } + + for (const entry of withEnd) { + if (open) { + const candidateEnd = Math.max(open.end, entry.end) + if (candidateEnd - open.start + 1 <= maxLength) { + open.end = candidateEnd + open.registers.push(entry) + continue } } - groups.push({ - start: startAddr, - length: endAddr - startAddr + 1, - registers: groupRegs - }) - i = j + 1 + close() + open = { start: entry.address, end: entry.end, registers: [entry] } } + close() + return groups } diff --git a/e2e/specs/01-main/21-bitmap-settings.spec.ts b/e2e/specs/01-main/21-bitmap-settings.spec.ts index 136637d..05016ac 100644 --- a/e2e/specs/01-main/21-bitmap-settings.spec.ts +++ b/e2e/specs/01-main/21-bitmap-settings.spec.ts @@ -178,6 +178,8 @@ test.describe.serial('Bitmap settings — color, invert & config persistence', ( // Server-side bitmap tests // ───────────────────────────────────────────────────────────────────────────── +const BIT_INDICES = Array.from({ length: 16 }, (_, i) => i) + test.describe.serial('Server bitmap — expand, bit toggle & comment', () => { test('navigate to server and clear state', async ({ mainPage }) => { await cleanServerState(mainPage) @@ -211,43 +213,35 @@ test.describe.serial('Server bitmap — expand, bit toggle & comment', () => { await expect(mainPage.getByTestId('server-bit-15')).toBeVisible() }) + // A circle is visible whatever its bit is. `data-active` carries the state + // its colour comes from. test('verify initial bit states (value=5 → bits 0,2 on)', async ({ mainPage }) => { - // Bit 0 circle should be "on" (success color) - const bit0Circle = mainPage.getByTestId('server-bit-circle-0') - await expect(bit0Circle).toBeVisible() - - // Bit 1 circle should be "off" - const bit1Circle = mainPage.getByTestId('server-bit-circle-1') - await expect(bit1Circle).toBeVisible() - - // Bit 2 circle should be "on" - const bit2Circle = mainPage.getByTestId('server-bit-circle-2') - await expect(bit2Circle).toBeVisible() + for (const bitIndex of BIT_INDICES) { + const expected = (5 >> bitIndex) & 1 ? 'true' : 'false' + await expect(mainPage.getByTestId(`server-bit-circle-${bitIndex}`)).toHaveAttribute( + 'data-active', + expected + ) + } }) test('toggle bit 1 on (value changes 5 → 7)', async ({ mainPage }) => { await mainPage.getByTestId('server-bit-circle-1').click() - await mainPage.waitForTimeout(500) - // Value in the register row should now show 7 - const row = mainPage.getByTestId('server-edit-reg-holding_registers-100') - await expect(row).toBeVisible() + await expect(mainPage.getByTestId('server-bit-circle-1')).toHaveAttribute('data-active', 'true') + await expect(mainPage.getByTestId('server-reg-value-holding_registers-100')).toHaveText('7') }) test('add comment to bit 0', async ({ mainPage }) => { - // Click the comment area of bit 0 to start editing - const bit0 = mainPage.getByTestId('server-bit-0') - await bit0.locator('span.MuiTypography-root').last().click() + // The comment carries one testid in both of its states, editing and not. + await mainPage.getByTestId('server-bit-comment-0').click() await mainPage.waitForTimeout(200) - // Type comment and press Enter - const input = bit0.locator('input') + const input = mainPage.getByTestId('server-bit-0').locator('input') await input.fill('motor running') await input.press('Enter') - await mainPage.waitForTimeout(300) - // Verify comment is displayed - await expect(bit0).toContainText('motor running') + await expect(mainPage.getByTestId('server-bit-comment-0')).toHaveText('motor running') }) test('collapse bitmap row', async ({ mainPage }) => { diff --git a/e2e/specs/01-main/23-server-rtu.spec.ts b/e2e/specs/01-main/23-server-rtu.spec.ts index d030c1f..dfaa21f 100644 --- a/e2e/specs/01-main/23-server-rtu.spec.ts +++ b/e2e/specs/01-main/23-server-rtu.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { test, expect } from '../../fixtures/electron-app' +import type { Page } from '@playwright/test' import { navigateToServer, navigateToClient, @@ -15,7 +16,9 @@ import { cell, expandAllServerPanels, selectRegisterType, - expectCell + selectUnitId, + expectCell, + sectionCount } from '../../fixtures/helpers' import { resolve } from 'path' import { spawn, type ChildProcess } from 'child_process' @@ -23,7 +26,11 @@ import { existsSync, unlinkSync } from 'fs' import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') -const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-huawei-smartlogger.json') +// Unit 0 is the broadcast address on RTU, so an RTU server hosts nothing there. +// This is the same SmartLogger config with its registers on unit 1. +const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-huawei-smartlogger-unit1.json') +const SERVER_CONFIG_UNIT_0 = resolve(CONFIG_DIR, 'server-huawei-smartlogger.json') +const SNACKBARS = '.notistack-SnackbarContainer' const CLIENT_CONFIG = resolve(CONFIG_DIR, 'client-huawei-smartlogger.json') const PTY_0 = '/tmp/ttyV0' @@ -165,13 +172,10 @@ test.describe.serial('Server RTU — UI elements', () => { // First switch to TCP and load config await mainPage.getByTestId('server-mode-tcp-btn').click() await loadServerConfig(mainPage, SERVER_CONFIG) + await selectUnitId(mainPage, '1') // Check register count - const section = mainPage.getByTestId('section-holding_registers') - const textBefore = await section.textContent() - const matchBefore = textBefore?.match(/\((\d+)\)/) - expect(matchBefore).toBeTruthy() - const countBefore = Number(matchBefore![1]) + const countBefore = await sectionCount(mainPage, 'holding_registers') expect(countBefore).toBeGreaterThanOrEqual(70) // Switch to RTU and back @@ -180,10 +184,7 @@ test.describe.serial('Server RTU — UI elements', () => { await mainPage.getByTestId('server-mode-tcp-btn').click() // Register count unchanged - const textAfter = await section.textContent() - const matchAfter = textAfter?.match(/\((\d+)\)/) - expect(matchAfter).toBeTruthy() - expect(Number(matchAfter![1])).toBe(countBefore) + expect(await sectionCount(mainPage, 'holding_registers')).toBe(countBefore) }) // ─── Cleanup ─────────────────────────────────────────────────────── @@ -241,17 +242,14 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await cleanServerState(mainPage) }) - test('load Huawei server config (TCP mode)', async ({ mainPage }) => { + test('load Huawei server config on unit 1 (TCP mode)', async ({ mainPage }) => { test.setTimeout(15_000) await loadServerConfig(mainPage, SERVER_CONFIG) await mainPage.waitForTimeout(500) + await selectUnitId(mainPage, '1') // Verify registers loaded - const section = mainPage.getByTestId('section-holding_registers') - const text = await section.textContent() - const match = text?.match(/\((\d+)\)/) - expect(match).toBeTruthy() - expect(Number(match![1])).toBeGreaterThanOrEqual(70) + expect(await sectionCount(mainPage, 'holding_registers')).toBeGreaterThanOrEqual(70) }) test('switch server to RTU mode', async ({ mainPage }) => { @@ -280,8 +278,8 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await navigateToClient(mainPage) }) - test('configure client RTU', async ({ mainPage }) => { - await connectClientRTU(mainPage, '0', '9600', 'none', '8', '1') + test('configure client RTU on unit 1', async ({ mainPage }) => { + await connectClientRTU(mainPage, '1', '9600', 'none', '8', '1') }) test('enter client COM /tmp/ttyV1 + connect', async ({ mainPage }) => { @@ -372,6 +370,67 @@ test.describe.serial('Server RTU — round-trip via socat', () => { await expectCell(mainPage, 65534, 'word_uint16', '45057') }) + // ─── Which unit ids answer on the bus ────────────────────────────── + + /** + * Reads 40011 on one unit id. The failure message names the id, which is what + * separates this read's silence from the one before it. + */ + async function readYearOn(mainPage: Page, unitId: string): Promise { + await mainPage.getByTestId('reg-address-input').locator('input').fill('40011') + await mainPage.getByTestId('reg-length-input').locator('input').fill('1') + await mainPage.getByTestId('client-unitid-input').locator('input').fill(unitId) + await mainPage.getByTestId('read-btn').click() + } + + test('a unit the server does not host says nothing', async ({ mainPage }) => { + test.setTimeout(20_000) + + // Silence is the only safe answer on RS-485: an exception frame would + // collide with whatever real device carries that address. + await readYearOn(mainPage, '7') + + await expect(mainPage.locator(SNACKBARS)).toContainText('Timed out [addr:40011, len:1, id:7]', { + timeout: 10_000 + }) + }) + + test('unit 0 says nothing, because it is the broadcast address', async ({ mainPage }) => { + test.setTimeout(20_000) + + await readYearOn(mainPage, '0') + + await expect(mainPage.locator(SNACKBARS)).toContainText('Timed out [addr:40011, len:1, id:0]', { + timeout: 10_000 + }) + }) + + test('back to unit 1, which still answers', async ({ mainPage }) => { + test.setTimeout(20_000) + + await readYearOn(mainPage, '1') + await expectCell(mainPage, 40011, 'word_uint16', '2025') + }) + + test('a config on unit 0 is called out as unreadable over RTU', async ({ mainPage }) => { + test.setTimeout(30_000) + + await navigateToServer(mainPage) + await loadServerConfig(mainPage, SERVER_CONFIG_UNIT_0) + + await expect(mainPage.locator(SNACKBARS)).toContainText( + 'Unit 0 is the broadcast address on RTU', + { timeout: 10_000 } + ) + }) + + test('restore the unit 1 config', async ({ mainPage }) => { + test.setTimeout(15_000) + await loadServerConfig(mainPage, SERVER_CONFIG) + await selectUnitId(mainPage, '1') + await navigateToClient(mainPage) + }) + // ─── ReadConfiguration via client config ─────────────────────────── test('load client config + readConfiguration', async ({ 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 73dbea2..4203b21 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 @@ -8,6 +8,7 @@ import { disconnectClient, readRegisters, selectRegisterType, + selectUnitId, expectCell } from '../../fixtures/helpers' import { resolve } from 'path' @@ -16,7 +17,9 @@ import { existsSync, unlinkSync } from 'fs' import { SOCAT_PATH, hasSocat } from '../../fixtures/socat' const CONFIG_DIR = resolve(__dirname, '../../fixtures/config-files') -const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-basic.json') +// The gateway carries real RTU frames, so unit 0 is the broadcast address here +// and the server hosts nothing on it. Same config, moved to unit 1. +const SERVER_CONFIG = resolve(CONFIG_DIR, 'server-basic-unit1.json') // A serial-to-Ethernet gateway in transparent mode passes raw RTU frames (with // CRC) between a TCP socket and a serial line. A single socat instance emulates @@ -71,9 +74,10 @@ test.describe.serial('Client RTU over TCP — round-trip via socat gateway', () await cleanServerState(mainPage) }) - test('load basic server config', async ({ mainPage }) => { + test('load basic server config on unit 1', async ({ mainPage }) => { await loadServerConfig(mainPage, SERVER_CONFIG) await mainPage.waitForTimeout(500) + await selectUnitId(mainPage, '1') await expect(mainPage.getByTestId('section-holding_registers')).toContainText('(2)') await expect(mainPage.getByTestId('section-input_registers')).toContainText('(1)') @@ -118,7 +122,7 @@ test.describe.serial('Client RTU over TCP — round-trip via socat gateway', () test('connect to the gateway over TCP', async ({ mainPage }) => { await mainPage.getByTestId('tcp-host-input').locator('input').fill('127.0.0.1') await mainPage.getByTestId('tcp-port-input').locator('input').fill(TCP_PORT) - await mainPage.getByTestId('client-unitid-input').locator('input').fill('0') + await mainPage.getByTestId('client-unitid-input').locator('input').fill('1') await mainPage.getByTestId('connect-btn').click() await expect(mainPage.getByTestId('connect-btn')).toContainText('Disconnect', { diff --git a/e2e/specs/01-main/25-disconnect-messages.spec.ts b/e2e/specs/01-main/25-disconnect-messages.spec.ts index 38f5167..5cbe354 100644 --- a/e2e/specs/01-main/25-disconnect-messages.spec.ts +++ b/e2e/specs/01-main/25-disconnect-messages.spec.ts @@ -24,7 +24,9 @@ import { spawn, type ChildProcess } from 'child_process' import { existsSync, unlinkSync } from 'fs' const SOCAT_PATHS = ['/usr/local/bin/socat', '/usr/bin/socat'] -const SOCAT_PATH = SOCAT_PATHS.find((p) => existsSync(p)) ?? SOCAT_PATHS[0] +// The bare name when neither path is there, so `hasSocat` is what decides +// whether the suite runs rather than a path that happens to exist. +const SOCAT_PATH = SOCAT_PATHS.find((p) => existsSync(p)) ?? 'socat' const hasSocat = existsSync(SOCAT_PATH) const PTY_0 = '/tmp/ttyVDISC0' const PTY_1 = '/tmp/ttyVDISC1' diff --git a/e2e/specs/01-main/26-server-layout.spec.ts b/e2e/specs/01-main/26-server-layout.spec.ts index 2db45e0..8ca5c2f 100644 --- a/e2e/specs/01-main/26-server-layout.spec.ts +++ b/e2e/specs/01-main/26-server-layout.spec.ts @@ -31,9 +31,12 @@ test.describe.serial('Server layout — panels stay inside the view', () => { test(`no overflow at ${width}x${height}`, async ({ mainPage, electronApp }) => { await electronApp.evaluate( ({ BrowserWindow }, size) => { - BrowserWindow.getAllWindows()[0].setSize(size[0], size[1]) + const [window] = BrowserWindow.getAllWindows() + if (!window) throw new Error('no window to resize') + const [w, h] = size + window.setSize(w, h) }, - [width, height] + [width, height] as [number, number] ) await mainPage.waitForTimeout(500) @@ -47,7 +50,10 @@ test.describe.serial('Server layout — panels stay inside the view', () => { test('restore the window', async ({ electronApp }) => { await electronApp.evaluate(({ BrowserWindow }, size) => { - BrowserWindow.getAllWindows()[0].setSize(size[0], size[1]) + 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/02-standalone/01-persistence.spec.ts b/e2e/specs/02-standalone/01-persistence.spec.ts index 50e7302..e8200a4 100644 --- a/e2e/specs/02-standalone/01-persistence.spec.ts +++ b/e2e/specs/02-standalone/01-persistence.spec.ts @@ -35,8 +35,9 @@ async function launchApp(clearStorage = true): Promise { const found = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') ) - if (found && app.windows().length === 1) { - page = app.windows()[0] + const [firstWindow] = app.windows() + if (found && firstWindow && app.windows().length === 1) { + page = firstWindow break } await new Promise((r) => setTimeout(r, 1000)) @@ -125,3 +126,58 @@ test.describe.serial('Persistence — State survives app restart', () => { await expect(portInput).toHaveValue('502') }) }) + +// +// The client store was called root.zustand until it was named after what it +// holds. persist reads one key and builds an empty store when it finds nothing, +// so an upgrade would have come up with no connection config at all. +// +// Only this suite can see it. The unit test covers carryFormerStorageKey, but +// whether it runs before persist reads is a question about module load order, +// and that is only true in a running app. +test.describe.serial('Persistence — a config saved under the former key', () => { + test.afterAll(async () => { + if (app) await app.close() + }) + + test('write a config, then put it back under the old key', async () => { + await launchApp(true) + await page.getByTestId('home-client-btn').click() + await expect(page.getByTestId('protocol-tcp-btn')).toBeVisible({ timeout: 5000 }) + + await page.getByTestId('tcp-host-input').locator('input').fill('10.9.8.7') + await page.getByTestId('client-unitid-input').locator('input').fill('9') + await page.waitForTimeout(500) // let zustand persist + + // Moving what the app itself wrote keeps the payload valid, which a + // hand-built one would not be: the store validates on load and clears + // anything it cannot parse. + const moved = await page.evaluate(() => { + const saved = localStorage.getItem('client.zustand') + if (saved === null) return false + localStorage.setItem('root.zustand', saved) + localStorage.removeItem('client.zustand') + return true + }) + expect(moved).toBe(true) + }) + + test('close app', async () => { + await app.close() + await new Promise((r) => setTimeout(r, 1000)) + }) + + test('reopen and find the config carried over', async () => { + await launchApp(false) + await page.getByTestId('home-client-btn').click() + await expect(page.getByTestId('protocol-tcp-btn')).toBeVisible({ timeout: 5000 }) + + await expect(page.getByTestId('tcp-host-input').locator('input')).toHaveValue('10.9.8.7') + await expect(page.getByTestId('client-unitid-input').locator('input')).toHaveValue('9') + }) + + test('the old key is still there for a build that goes back', async () => { + const former = await page.evaluate(() => localStorage.getItem('root.zustand')) + expect(former).not.toBeNull() + }) +}) diff --git a/e2e/specs/02-standalone/02-macos-dock.spec.ts b/e2e/specs/02-standalone/02-macos-dock.spec.ts new file mode 100644 index 0000000..dff32cd --- /dev/null +++ b/e2e/specs/02-standalone/02-macos-dock.spec.ts @@ -0,0 +1,187 @@ +import { + test, + expect, + _electron as electron, + type ElectronApplication, + type Page +} from '@playwright/test' +import net from 'net' +import { keepOutput } from '../../fixtures/electron-app' +import { launchOptions, ownProfileDir } from '../../fixtures/launch' +import { connectClient, navigateToClient, navigateToServer } from '../../fixtures/helpers' + +/** + * What the app does on macos once its last window is gone. + * + * `window-all-closed` quits on every other platform, so only here does the app + * outlive its windows, and only here can a handle to a destroyed one still be + * read. Nothing else in the suite reaches that state: `10-split-view` closes + * the server window and leaves the main one standing. + */ +test.skip(process.platform !== 'darwin', 'the app quits with its last window everywhere else') + +let app: ElectronApplication +let page: Page +let profile: string + +let device: net.Server +let devicePort: number +let requestsSeen = 0 + +let master: net.Socket +const masterEvents: string[] = [] +let modbuxServerPort: number + +/** + * A Modbus TCP device that answers a register read and counts what it was + * asked. + * + * The count is the only witness that the client kept polling with no window + * open: the client's state lives in main, and `electronApp.evaluate` reaches + * electron rather than the app's own modules. An answer to a read is one MBAP + * header, the function code back, a byte count and the data, and echoing the + * function code answers a holding and an input register read alike. + */ +function startDevice(): Promise { + device = net.createServer((socket) => { + socket.on('data', (request) => { + requestsSeen++ + const transactionId = request.readUInt16BE(0) + const unitId = request.readUInt8(6) + const functionCode = request.readUInt8(7) + const registerCount = request.readUInt16BE(10) + const byteCount = registerCount * 2 + + const response = Buffer.alloc(9 + byteCount) + response.writeUInt16BE(transactionId, 0) + response.writeUInt16BE(0, 2) + response.writeUInt16BE(3 + byteCount, 4) + response.writeUInt8(unitId, 6) + response.writeUInt8(functionCode, 7) + response.writeUInt8(byteCount, 8) + socket.write(response) + }) + }) + + return new Promise((resolve) => { + device.listen(0, '127.0.0.1', () => { + const address = device.address() + if (address === null || typeof address === 'string') throw new Error('device has no port') + devicePort = address.port + resolve() + }) + }) +} + +async function windowCount(): Promise { + return app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length) +} + +/** + * Playwright learns about a window after the app has built it, and it keeps the + * closed ones in `windows()`, so both counts have to arrive before the page is + * the one to drive. + */ +async function waitForWindow(): Promise { + const openPages = (): Page[] => app.windows().filter((w) => !w.isClosed()) + + await expect.poll(windowCount, { timeout: 15000 }).toBe(1) + await expect.poll(() => openPages().length, { timeout: 15000 }).toBe(1) + + const [firstWindow] = openPages() + if (!firstWindow) throw new Error('the app reports a window and playwright has none') + await firstWindow.waitForLoadState('domcontentloaded') + return firstWindow +} + +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) + page = await waitForWindow() + await page.waitForTimeout(500) + }) + + test.afterAll(async () => { + master?.destroy() + await app?.close().catch(() => {}) + await new Promise((resolve) => device.close(() => resolve())) + }) + + test('a master outside modbux connects to the server', async () => { + await navigateToServer(page) + modbuxServerPort = Number( + await page.getByTestId('server-port-input').locator('input').inputValue() + ) + expect(modbuxServerPort).toBeGreaterThan(0) + + master = net.connect(modbuxServerPort, '127.0.0.1') + await new Promise((resolve, reject) => { + master.once('connect', resolve) + master.once('error', reject) + }) + master.on('close', () => masterEvents.push('closed')) + }) + + test('the client polls the device', async () => { + await navigateToClient(page) + await connectClient(page, '127.0.0.1', String(devicePort), '1') + await page.getByTestId('poll-btn').click() + await expect.poll(() => requestsSeen, { timeout: 10000 }).toBeGreaterThan(1) + }) + + test('closing every window leaves the app running', async () => { + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().forEach((w) => w.close()) + ) + await expect.poll(windowCount, { timeout: 10000 }).toBe(0) + }) + + test('the client keeps polling with no window open', async () => { + const before = requestsSeen + await expect.poll(() => requestsSeen, { timeout: 10000 }).toBeGreaterThan(before) + }) + + test('the server keeps the master it had', async () => { + expect(masterEvents).toEqual([]) + expect(master.readyState).toBe('open') + }) + + /** + * Launching Modbux a second time is what reaches `second-instance`, and a + * dock click does not: that fires `activate`, which builds a window of its + * own. The second app loses the single instance lock and quits, which is why + * playwright never gets to attach to it. + */ + test('launching modbux again brings the window back', async () => { + await electron + .launch({ ...launchOptions(profile), timeout: 15000 }) + .then((second) => second.close()) + .catch(() => undefined) + + page = await waitForWindow() + }) + + test('the client is still polling once the window is back', async () => { + const before = requestsSeen + await expect.poll(() => requestsSeen, { timeout: 10000 }).toBeGreaterThan(before) + }) + + /** + * The window that came back has to be told, because main pushes `client_state` + * on a change and the last change was before this window existed. Without the + * question in `init` the button reads Connect while the polling above is + * running, and pressing it answers `Already connected`. + */ + test('the window that came back knows the client is connected', async () => { + await navigateToClient(page) + await expect(page.getByTestId('connect-btn')).toContainText('Disconnect', { timeout: 5000 }) + }) + + test('the master survived it too', async () => { + expect(masterEvents).toEqual([]) + expect(master.readyState).toBe('open') + }) +}) diff --git a/e2e/specs/03-presentation/01-feature-tour.spec.ts b/e2e/specs/03-presentation/01-feature-tour.spec.ts index 28b1c7b..39a72c9 100644 --- a/e2e/specs/03-presentation/01-feature-tour.spec.ts +++ b/e2e/specs/03-presentation/01-feature-tour.spec.ts @@ -34,7 +34,8 @@ import { cell, disableClientRawMode, expectCell, - expectCellContains + expectCellContains, + splitOutServerWindow } from '../../fixtures/helpers' import { resolve } from 'path' import { readFileSync, writeFileSync } from 'fs' @@ -66,7 +67,9 @@ const snap = async (page: Page, name: string): Promise => { /** Stitch PNG frame buffers into an animated GIF */ const framesToGif = (frames: Buffer[], delay: number, outPath: string): void => { - const first = PNG.sync.read(frames[0]) + const [firstFrame] = frames + if (!firstFrame) throw new Error('no frames to stitch into a gif') + const first = PNG.sync.read(firstFrame) const encoder = new GIFEncoder(first.width, first.height) encoder.setDelay(delay) encoder.setRepeat(0) @@ -925,10 +928,7 @@ test.describe.serial('Act V — Side by Side', () => { await navigateToHome(mainPage) await beat(mainPage, 3500) - // Open split view - await mainPage.getByTestId('home-split-btn').click() - serverPage = await electronApp.waitForEvent('window', { timeout: 10000 }) - await serverPage.waitForLoadState('domcontentloaded') + serverPage = await splitOutServerWindow(electronApp, mainPage) await beat(serverPage, 1500) await snap(mainPage, 'split-view-client') 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 194f277..b797742 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 @@ -21,7 +21,7 @@ import { type Page } from '@playwright/test' import { launchOptions } from '../../fixtures/launch' -import { navigateToServer } from '../../fixtures/helpers' +import { navigateToServer, splitOutServerWindow } from '../../fixtures/helpers' import { readFileSync } from 'fs' const PROC_PATH = '/proc/sys/net/ipv4/ip_unprivileged_port_start' @@ -168,6 +168,24 @@ test.describe.serial('Privileged port modal (manual, Linux only)', () => { await expect(page.getByTestId(MODAL)).toBeHidden() }) + /** + * 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. + */ + test('the split-out server window asks', async () => { + await closeApp() + await launchApp() + + const serverPage = await splitOutServerWindow(app, page) + + await waitForModal(serverPage) + await expect(page.getByTestId(MODAL)).toBeHidden() + + await closeApp() + }) + // ─── Fix it through the modal itself ─────────────────────────────── test('Allow runs pkexec and unblocks the port', async () => { diff --git a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts index 52d4c08..f6ad123 100644 --- a/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts +++ b/e2e/specs/99-hardware/02-iem3000-reconnect.spec.ts @@ -49,8 +49,9 @@ async function launchApp(clearStorage = true): Promise { const found = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux') ) - if (found && app.windows().length === 1) { - page = app.windows()[0] + const [firstWindow] = app.windows() + if (found && firstWindow && app.windows().length === 1) { + page = firstWindow break } await new Promise((r) => setTimeout(r, 1000)) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ae3fb9e..e12a636 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,9 +7,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], resolve: { alias: { - '@main': resolve('src/main'), - '@shared': resolve('src/shared'), - '@backend': resolve('src/backend') + '@shared': resolve('src/shared') } } }, @@ -17,9 +15,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], resolve: { alias: { - '@preload': resolve('src/preload'), - '@shared': resolve('src/shared'), - '@backend': resolve('src/backend') + '@shared': resolve('src/shared') } } }, diff --git a/src/__tests__/conformance.test.ts b/src/__tests__/conformance.test.ts new file mode 100644 index 0000000..97e06b5 --- /dev/null +++ b/src/__tests__/conformance.test.ts @@ -0,0 +1,730 @@ +/** + * The conventions, as assertions. + * + * CONTRIBUTING.md says what the codebase agrees on. This says it again in a form + * that fails, because a rule that lives only in prose is the rule that produced + * a 104-of-190 memo split while the prose sat there being correct. + * + * Every rule asserts twice: that its population is not empty, and that the + * population holds no violation. Without the first, a meter that reads no files + * passes every rule it has. + * + * A violation prints the file and the symbol, never a line number, because the + * line moves on the next edit above it and the symbol does not. + */ +import { describe, expect, it } from 'vitest' +import { globSync, readdirSync, readFileSync } from 'fs' +import { join, relative } from 'path' +import ts from 'typescript' + +const repoRoot = join(__dirname, '..', '..') +const rendererRoot = join(repoRoot, 'src/renderer/src') + +const sourceFiles = (root: string, extensions = /\.tsx?$/): string[] => { + const found: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') walk(full) + } else if (extensions.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + found.push(full) + } + } + } + walk(root) + return found +} + +const parse = (file: string): ts.SourceFile => + ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + +const at = (file: string): string => relative(repoRoot, file) + +const eachNode = (source: ts.SourceFile, visit: (node: ts.Node) => void): void => { + const walk = (node: ts.Node): void => { + visit(node) + ts.forEachChild(node, walk) + } + walk(source) +} + +/** 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) + ? node.moduleSpecifier.text + : 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. + +describe('every component is wrapped in meme', () => { + const files = sourceFiles(rendererRoot) + const parsed = files.map((file) => ({ file, source: parse(file) })) + + const renderedAsJsx = new Set() + const defaultExported = new Set() + + for (const { source } of parsed) { + eachNode(source, (node) => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + let name: ts.Node = node.tagName + while (ts.isPropertyAccessExpression(name)) name = name.expression + if (ts.isIdentifier(name)) renderedAsJsx.add(name.text) + } + if (ts.isExportAssignment(node) && !node.isExportEquals && ts.isIdentifier(node.expression)) { + defaultExported.add(node.expression.text) + } + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) { + defaultExported.add(node.name.text) + } + }) + } + + /** Peel the wrappers a component declaration can sit under. */ + const classify = (expression: ts.Expression): { isComponent: boolean; wrapped: boolean } => { + let node: ts.Node = expression + let wrapped = false + for (;;) { + if (ts.isCallExpression(node)) { + const callee = node.expression + const calleeName = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) + ? callee.text + : null + // Only meme, not React's memo. meme is memo with deepEqual, and a bare + // memo gets the shallow comparator that a mutated row defeats. + if (calleeName === 'meme') { + wrapped = true + if (!node.arguments[0]) return { isComponent: true, wrapped } + node = node.arguments[0] + continue + } + if (calleeName === 'memo') { + if (!node.arguments[0]) return { isComponent: true, wrapped: false } + node = node.arguments[0] + continue + } + if (calleeName === 'forwardRef') { + if (!node.arguments[0]) return { isComponent: true, wrapped } + node = node.arguments[0] + continue + } + // styled('svg')({}) is a component, but not one memo has anything to do + // with: it renders exactly its props and holds no state. + if ( + calleeName === 'styled' || + (ts.isCallExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'styled') + ) { + return { isComponent: false, wrapped } + } + return { isComponent: wrapped, wrapped } + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return { isComponent: true, wrapped } + } + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) { + node = node.expression + continue + } + return { isComponent: wrapped, wrapped } + } + } + + const components: { name: string; file: string; wrapped: boolean }[] = [] + for (const { file, source } of parsed) { + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + 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 + const { isComponent, wrapped } = classify(declaration.initializer) + if (isComponent) components.push({ name, file: at(file), wrapped }) + } + } + } + + it('finds components to check', () => { + expect(components.length).toBeGreaterThan(150) + }) + + it('leaves none of them bare', () => { + const bare = components.filter((c) => !c.wrapped).map((c) => `${c.file}\t${c.name}`) + expect(bare).toEqual([]) + }) +}) + +// +// ─── shared may not reach back into main ───────────────────────────────────── +// +// All three processes import shared. It is the one layer that may not reach +// back, and an import of main from shared pulls Electron into the renderer. + +describe('shared does not import from main', () => { + const files = sourceFiles(join(repoRoot, 'src/shared')) + + it('finds shared files to check', () => { + expect(files.length).toBeGreaterThan(5) + }) + + it('has none of them importing main', () => { + const reaching: string[] = [] + for (const file of files) { + eachNode(parse(file), (node) => { + const specifier = importedFrom(node) + if (specifier === null) return + if (specifier.startsWith('@main') || /(^|\/)\.\.\/main\//.test(specifier)) { + reaching.push(`${at(file)}\t${specifier}`) + } + }) + } + expect(reaching).toEqual([]) + }) +}) + +// +// ─── 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. + +describe('one store selector per field', () => { + const files = sourceFiles(rendererRoot) + const selectorCalls: { file: string; text: string }[] = [] + const objectSelectors: string[] = [] + const shallowUses: string[] = [] + const wholeStore: string[] = [] + + for (const file of files) { + const source = parse(file) + eachNode(source, (node) => { + if (ts.isIdentifier(node) && node.text === 'useShallow') shallowUses.push(at(file)) + if (!ts.isCallExpression(node)) return + const callee = node.expression + if (!ts.isIdentifier(callee) || !/^use[A-Z].*Zustand$/.test(callee.text)) return + const argument = node.arguments[0] + // No selector at all subscribes to the whole store, and so does one that + // hands the state straight back. Neither is an object literal, so the + // check below would let both through. + if (!argument) { + if (!/\.(getState|setState|persist|subscribe)\b/.test(node.parent?.getText(source) ?? '')) { + wholeStore.push(`${at(file)}\t${callee.text}()`) + } + return + } + if (!ts.isArrowFunction(argument)) return + if (ts.isIdentifier(argument.body) && argument.parameters.length === 1) { + const parameter = argument.parameters[0].name + if (ts.isIdentifier(parameter) && parameter.text === argument.body.text) { + wholeStore.push(`${at(file)}\t${callee.text}((z) => z)`) + } + } + selectorCalls.push({ file: at(file), text: callee.text }) + const body = argument.body + // ({ a, b }) is a parenthesized object literal; { return { a, b } } is a + // block that ends in one. Both hand back a new reference every render. + const returnsObject = + (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) || + ts.isObjectLiteralExpression(body) || + (ts.isBlock(body) && + body.statements.some( + (statement) => + ts.isReturnStatement(statement) && + statement.expression !== undefined && + ts.isObjectLiteralExpression(statement.expression) + )) + if (returnsObject) objectSelectors.push(`${at(file)}\t${callee.text}`) + }) + } + + it('finds selectors to check', () => { + expect(selectorCalls.length).toBeGreaterThan(100) + }) + + it('has none of them returning an object', () => { + expect(objectSelectors).toEqual([]) + }) + + it('has no useShallow anywhere', () => { + expect(shallowUses).toEqual([]) + }) + + it('has nothing subscribing to a whole store', () => { + expect(wholeStore).toEqual([]) + }) +}) + +// +// ─── An action is fetched where it runs ────────────────────────────────────── +// +// In a named `useCallback` whose dependency list holds only what the component +// itself owns: `const clientZustand = useClientZustand.getState()`, then +// `clientZustand.setAddress(value)`. Two ways to break it, and the rule catches +// both. A selector puts the action in the dependency list, and a list naming +// something the component does not own is a list that cannot be read. A +// `getState()` written into the JSX puts the call where the reader is looking +// at layout, and a handler with no name is a handler with nothing to read. + +describe('an action is fetched where it runs', () => { + const files = sourceFiles(rendererRoot) + const parsed = files.map((file) => ({ file, source: parse(file) })) + + // A member is a function when it says so, or when its type alias does: + // `setAddress: MaskSetFn` is as much an action as `clear: () => void`. + const functionAliases = new Set() + for (const { source } of parsed) { + eachNode(source, (node) => { + if (ts.isTypeAliasDeclaration(node) && ts.isFunctionTypeNode(node.type)) { + functionAliases.add(node.name.text) + } + }) + } + const isFunctionType = (type: ts.TypeNode | undefined): boolean => { + if (!type) return false + if (ts.isFunctionTypeNode(type)) return true + return ( + ts.isTypeReferenceNode(type) && + ts.isIdentifier(type.typeName) && + functionAliases.has(type.typeName.text) + ) + } + + // Zustand, so useClientZustand is matched against ClientZustand and not + // against every store's members at once. + const storeFunctions = new Map>() + for (const { source } of parsed) { + eachNode(source, (node) => { + if (!ts.isTypeAliasDeclaration(node) && !ts.isInterfaceDeclaration(node)) return + if (!/Zustand$/.test(node.name.text)) return + const members: ts.TypeElement[] = [] + const collect = (type: ts.TypeNode | undefined): void => { + if (!type) return + if (ts.isTypeLiteralNode(type)) members.push(...type.members) + else if (ts.isIntersectionTypeNode(type)) type.types.forEach(collect) + } + if (ts.isTypeAliasDeclaration(node)) collect(node.type) + else members.push(...node.members) + const found = storeFunctions.get(node.name.text) ?? new Set() + for (const member of members) { + if (!member.name || !ts.isIdentifier(member.name)) continue + if (ts.isMethodSignature(member)) found.add(member.name.text) + if (ts.isPropertySignature(member) && isFunctionType(member.type)) { + found.add(member.name.text) + } + } + storeFunctions.set(node.name.text, found) + }) + } + + const functionSelectors: string[] = [] + for (const { file, source } of parsed) { + eachNode(source, (node) => { + if (!ts.isCallExpression(node)) return + const callee = node.expression + if (!ts.isIdentifier(callee)) return + const store = /^use([A-Z].*Zustand)$/.exec(callee.text)?.[1] + if (!store) return + const argument = node.arguments[0] + if (!argument || !ts.isArrowFunction(argument) || argument.parameters.length !== 1) return + if (!ts.isPropertyAccessExpression(argument.body)) return + const field = argument.body.name.text + if (!storeFunctions.get(store)?.has(field)) return + functionSelectors.push(`${at(file)}\t${callee.text}((z) => z.${field})`) + }) + } + + it('finds stores with functions in them', () => { + const withFunctions = [...storeFunctions.values()].filter((found) => found.size > 0) + expect(withFunctions.length).toBeGreaterThan(5) + }) + + it('has no selector returning one of them', () => { + expect(functionSelectors).toEqual([]) + }) + + // The other half. A prop takes the handler's name, never the call. + const storeReads: string[] = [] + const readsInJsx: string[] = [] + for (const { file, source } of parsed) { + eachNode(source, (node) => { + if (!ts.isCallExpression(node)) return + const callee = node.expression + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'getState') return + if (!ts.isIdentifier(callee.expression)) return + if (!/^use[A-Z].*Zustand$/.test(callee.expression.text)) return + storeReads.push(at(file)) + // The attribute is what names the offence, so walk out to it and stop at + // the element: a getState inside a child element is that child's. + let ancestor: ts.Node | undefined = node.parent + while (ancestor) { + if (ts.isJsxAttribute(ancestor)) { + const attribute = ts.isIdentifier(ancestor.name) + ? ancestor.name.text + : ancestor.name.getText(source) + readsInJsx.push(`${at(file)}\t${attribute}={${callee.expression.text}.getState()...}`) + return + } + if ( + ts.isJsxElement(ancestor) || + ts.isJsxSelfClosingElement(ancestor) || + ts.isJsxFragment(ancestor) + ) { + return + } + ancestor = ancestor.parent + } + }) + } + + it('finds stores read through getState', () => { + expect(storeReads.length).toBeGreaterThan(20) + }) + + it('has none of those reads inside a JSX attribute', () => { + expect(readsInJsx).toEqual([]) + }) +}) + +// +// ─── Stores are named after their component ────────────────────────────────── + +describe('every store file is named .zustand.ts', () => { + const files = sourceFiles(join(repoRoot, 'src')) + const storeFiles: string[] = [] + + for (const file of files) { + const source = parse(file) + let createsStore = false + let importsZustand = false + eachNode(source, (node) => { + if (importedFrom(node) === 'zustand') importsZustand = true + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'create' + ) { + createsStore = true + } + }) + if (createsStore && importsZustand) storeFiles.push(at(file)) + } + + it('finds stores to check', () => { + expect(storeFiles.length).toBeGreaterThan(5) + }) + + it('has none of them off the name', () => { + const misnamed = storeFiles.filter((file) => !file.endsWith('.zustand.ts')) + expect(misnamed).toEqual([]) + }) +}) + +// +// ─── MUI comes in deep ─────────────────────────────────────────────────────── +// +// A barrel import pulls the package's whole index through the dev server on +// every cold start. Two hooks have no deep home: the exports map in +// @mui/x-data-grid/package.json declares thirteen subpaths besides the root and +// neither hook is exported by any of them, so the root is the only way to write +// them. + +const rootOnlyGridHooks = ['useGridApiContext', 'useGridApiRef'] + +describe('MUI is imported deep', () => { + const files = sourceFiles(join(repoRoot, 'src')) + const muiImports: string[] = [] + const barrelImports: string[] = [] + + for (const file of files) { + const source = parse(file) + for (const statement of source.statements) { + const specifier = importedFrom(statement) + if (specifier === null || !specifier.startsWith('@mui/')) continue + muiImports.push(specifier) + if (specifier.split('/').length !== 2) continue + + const bindings = statement.importClause?.namedBindings + const names = + bindings && ts.isNamedImports(bindings) + ? bindings.elements.map((element) => element.name.text) + : [] + const allowed = names.length > 0 && names.every((name) => rootOnlyGridHooks.includes(name)) + if (!allowed) barrelImports.push(`${at(file)}\t${specifier}\t${names.join(', ')}`) + } + } + + it('finds MUI imports to check', () => { + expect(muiImports.length).toBeGreaterThan(100) + }) + + it('has no barrel import that could have been deep', () => { + expect(barrelImports).toEqual([]) + }) +}) + +// +// ─── Every interactive element carries a data-testid ───────────────────────── +// +// The e2e suite addresses the UI through them. Containers are excluded on +// purpose: ToggleButtonGroup and ButtonGroup are addressed through the buttons +// inside them, and a Select's options through getByRole('option'). +// +// A picker hands attributes to its input through slotProps, so the attribute can +// sit nested rather than on the element. Reading only JSX attributes misses +// those, which is how the DateTimePicker showed up as missing one it had. + +const interactiveLeaves = new Set([ + 'Button', + 'IconButton', + 'TextField', + 'Slider', + 'Switch', + 'Checkbox', + 'Autocomplete', + 'Link', + 'DateTimePicker', + 'GridActionsCellItem', + 'ToggleButton', + 'Select' +]) + +describe('every interactive element carries a data-testid', () => { + const files = sourceFiles(rendererRoot, /\.tsx$/) + const elements: string[] = [] + const bare: string[] = [] + + for (const file of files) { + const source = parse(file) + eachNode(source, (node) => { + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return + const tag = node.tagName.getText(source) + if (!interactiveLeaves.has(tag)) return + elements.push(`${at(file)}\t${tag}`) + + const attributes = node.attributes.properties + // A spread can carry anything, including the attribute, so it counts. + if (attributes.some((attribute) => ts.isJsxSpreadAttribute(attribute))) return + // slotProps nests the attribute one or more levels down, so the whole + // attribute list is searched rather than only its top level. + const carries = attributes.some((attribute) => + attribute.getText(source).includes('data-testid') + ) + if (!carries) bare.push(`${at(file)}\t${tag}`) + }) + } + + it('finds interactive elements to check', () => { + expect(elements.length).toBeGreaterThan(50) + }) + + it('leaves none of them without one', () => { + expect(bare).toEqual([]) + }) +}) + +// +// ─── Every alias resolves ──────────────────────────────────────────────────── +// +// @main, @preload and @backend outlived their use, and @backend outlived its +// directory. An alias nobody imports through is a name a contributor will reach +// for and a reviewer will have to rule on. + +describe('every configured path alias is used', () => { + const configs = ['electron.vite.config.ts', 'vitest.config.mts'] + const declared = new Map() + + for (const config of configs) { + const source = parse(join(repoRoot, config)) + const names: string[] = [] + eachNode(source, (node) => { + if (!ts.isPropertyAssignment(node)) return + const key = ts.isStringLiteral(node.name) + ? node.name.text + : ts.isIdentifier(node.name) + ? node.name.text + : null + if (key !== null && key.startsWith('@')) names.push(key) + }) + declared.set(config, [...new Set(names)]) + } + + const imported = new Set() + for (const file of [ + ...sourceFiles(join(repoRoot, 'src')), + ...sourceFiles(join(repoRoot, 'e2e')) + ]) { + eachNode(parse(file), (node) => { + const specifier = importedFrom(node) + if (specifier?.startsWith('@') === true) imported.add(specifier.split('/')[0]) + }) + } + + it('finds aliases to check', () => { + expect([...declared.values()].flat().length).toBeGreaterThan(2) + }) + + it('has none that nothing imports through', () => { + const unused: string[] = [] + for (const [config, names] of declared) { + for (const name of names) if (!imported.has(name)) unused.push(`${config}\t${name}`) + } + expect(unused).toEqual([]) + }) +}) + +// +// ─── Every channel carrying an object declares a schema ────────────────────── +// +// TypeScript covers the shape of a bare primitive, and sixteen channels take no +// argument at all. What is left is an object or a union, and that is where a +// hand-edited config file or anything reaching the boundary from outside the UI +// arrives. A channel added without a schema is the one that gets missed. + +describe('every channel carrying an object declares a schema', () => { + const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) + + /** Channel to the argument it takes, for the ones taking more than a primitive. */ + const carriers = new Map() + eachNode(spec, (node) => { + if (!ts.isInterfaceDeclaration(node) || node.name.text !== 'IpcHandlerSpec') return + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type || !ts.isTypeLiteralNode(member.type)) + continue + const args = member.type.members.find((m) => m.name?.getText(spec) === 'args') + const argument = (args?.type?.getText(spec) ?? '[]').slice(1, -1).trim() + if (argument === '' || ['string', 'number', 'boolean'].includes(argument)) continue + carriers.set(member.name.getText(spec).replace(/[[\]']/g, ''), argument) + } + }) + + /** Channel to whether its ipcHandle call was given a third argument. */ + const guarded = new Set() + eachNode(parse(join(repoRoot, 'src/main/ipc.ts')), (node) => { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return + if (node.expression.text !== 'ipcHandle') return + const channel = node.arguments[0] + if (channel && ts.isStringLiteral(channel) && node.arguments.length >= 3) + guarded.add(channel.text) + }) + + it('finds channels to check', () => { + expect(carriers.size).toBeGreaterThan(10) + }) + + it('leaves none of them unguarded', () => { + const unguarded = [...carriers] + .filter(([channel]) => !guarded.has(channel)) + .map(([channel, argument]) => `${channel}\t${argument}`) + expect(unguarded).toEqual([]) + }) +}) + +// +// ─── Every channel has a caller ────────────────────────────────────────────── +// +// `window.api` is generated from IPC_CHANNELS, so a channel nobody calls still +// gets a method, a handler and a spec entry, and nothing says so. +// `get_connection_config` and `get_client_state` sat that way, with the app's +// only config repair branch inside one of them. A handler that reads as a guard +// and never runs is worse than no handler. +// +// The caller has to be in the renderer. A channel only the e2e suite drives is +// a channel the app itself does not use, and that is a decision to take rather +// than to let happen. + +describe('every channel has a caller', () => { + const spec = parse(join(repoRoot, 'src/shared/types/ipc.ts')) + + /** Each channel, and the camelCase method it becomes on `window.api`. */ + const methods = new Map() + eachNode(spec, (node) => { + if (!ts.isVariableDeclaration(node)) return + 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()) + ) + }) + }) + + /** Every name called as `.(` in the renderer. */ + const called = new Set() + for (const file of sourceFiles(rendererRoot)) { + eachNode(parse(file), (node) => { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return + called.add(node.expression.name.text) + }) + } + + it('finds channels to check', () => { + expect(methods.size).toBeGreaterThan(30) + }) + + it('finds calls to check', () => { + expect(called.size).toBeGreaterThan(30) + }) + + it('leaves none of them uncalled', () => { + const dead = [...methods] + .filter(([, method]) => !called.has(method)) + .map(([channel, method]) => `${channel}\t${method}`) + expect(dead).toEqual([]) + }) +}) + +// +// ─── Every configured path is somewhere ────────────────────────────────────── +// +// @backend pointed at a directory that had been deleted, and the alias outlived +// it in three configs. A tsconfig include does the same thing more quietly: it +// names a glob, finds nothing, and says nothing. + +describe('every configured include points at something', () => { + const configs = ['tsconfig.node.json', 'tsconfig.web.json', 'tsconfig.e2e.json'] + const globs: { config: string; glob: string }[] = [] + + for (const config of configs) { + // A tsconfig is jsonc: comments and trailing commas, which JSON.parse + // refuses and the compiler's own reader does not. + const { config: parsed } = ts.parseConfigFileTextToJson( + config, + readFileSync(join(repoRoot, config), 'utf8') + ) + for (const glob of (parsed as { include?: string[] })?.include ?? []) + globs.push({ config, glob }) + } + + it('finds includes to check', () => { + expect(globs.length).toBeGreaterThan(5) + }) + + it('has none of them pointing at nothing', () => { + // Expanded rather than approximated. Reading the directory part off the + // glob was tried first and got electron.vite.config.* wrong twice, once in + // each direction. + const empty = globs.filter(({ glob }) => globSync(glob, { cwd: repoRoot }).length === 0) + expect(empty.map(({ config, glob }) => `${config}\t${glob}`)).toEqual([]) + }) +}) diff --git a/src/main/__tests__/ipc.test.ts b/src/main/__tests__/ipc.test.ts new file mode 100644 index 0000000..1fdf143 --- /dev/null +++ b/src/main/__tests__/ipc.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const handle = vi.fn() +vi.mock('electron', () => ({ + ipcMain: { + handle: (...args: unknown[]): unknown => handle(...args), + on: vi.fn(), + removeAllListeners: vi.fn() + } +})) + +import { + AddRegisterParamsSchema, + ConnectionConfigSchema, + CreateServerParamsSchema, + PortSchema, + PrivilegedPortFixModeSchema, + RemoveRegisterParamsSchema, + ResetBoolsParamsSchema, + ResetRegistersParamsSchema, + ScanRegistersParametersSchema, + ScanUnitIDParametersSchema, + SetBooleanParametersSchema, + StartRtuServerParamsSchema, + SyncBoolsParametersSchema, + SyncRegisterValueParamsSchema, + WriteParametersSchema, + type BackendMessage, + type Windows +} from '@shared' +import { createIpcHandle, initIpc } from '../ipc' + +const createWindows = (): { windows: Windows; sent: BackendMessage[] } => { + const sent: BackendMessage[] = [] + const windows = { + send: (_event: string, payload: BackendMessage) => sent.push(payload) + } as unknown as Windows + return { windows, sent } +} + +/** Invokes the listener that was registered for `channel`. */ +const invoke = async (channel: string, payload?: unknown): Promise => { + const call = handle.mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`nothing registered for ${channel}`) + return (call[1] as (e: unknown, p?: unknown) => unknown)({}, payload) +} + +beforeEach(() => handle.mockClear()) + +describe('createIpcHandle', () => { + it('registers an unguarded channel and passes the payload straight through', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('update_connection_config', listener) + await invoke('update_connection_config', { unitId: 3 }) + + expect(listener).toHaveBeenCalledWith({}, { unitId: 3 }) + expect(sent).toEqual([]) + }) + + it('calls the listener when a guarded payload parses', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + await invoke('set_bool', { + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true + }) + + expect(listener).toHaveBeenCalledOnce() + expect(sent).toEqual([]) + }) + + it('never calls the listener when the payload is rejected', async () => { + const { windows, sent } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + // unitId 300 is not a Modbus unit id + const returned = await invoke('set_bool', { + uuid: 'server-1', + unitId: '300', + registerType: 'coils', + address: 12, + state: true + }) + + expect(listener).not.toHaveBeenCalled() + expect(returned).toBeUndefined() + expect(sent).toHaveLength(1) + expect(sent[0]?.variant).toBe('error') + expect(String(sent[0]?.error)).toContain('set_bool') + expect(String(sent[0]?.error)).toContain('unitId') + }) + + it('reports rather than throws, so the renderer never sees a rejected invoke', async () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + ipcHandle('set_bool', vi.fn(), SetBooleanParametersSchema) + await expect(invoke('set_bool', undefined)).resolves.toBeUndefined() + }) + + it('hands the listener the parsed payload, so unknown keys never reach the socket', async () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + const listener = vi.fn() + + ipcHandle('set_bool', listener, SetBooleanParametersSchema) + await invoke('set_bool', { + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true, + __proto__polluted: 'nope', + extra: 'stripped' + }) + + expect(listener.mock.calls[0]?.[1]).toEqual({ + uuid: 'server-1', + unitId: '1', + registerType: 'coils', + address: 12, + state: true + }) + }) + + it('refuses a schema on a channel with no room for undefined in its answer', () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + // get_privileged_port_status answers with a status object and says nothing + // about undefined, so there is nothing to hand back for a rejected payload. + // @ts-expect-error a schema needs undefined to be an honest answer + ipcHandle('get_privileged_port_status', vi.fn(), PortSchema) + }) + + it('accepts one where the answer admits undefined', () => { + const { windows } = createWindows() + const ipcHandle = createIpcHandle(windows) + + // create_server answers Promise for exactly this, so + // the guard is allowed and a refused payload does not invent a port. + ipcHandle('create_server', vi.fn(), CreateServerParamsSchema) + expect(handle).toHaveBeenCalledWith('create_server', expect.any(Function)) + }) +}) + +describe('write-path schemas', () => { + it('accepts a coil write and a register write', () => { + expect( + WriteParametersSchema.safeParse({ + address: 4, + single: true, + type: 'coils', + value: [true, false] + }).success + ).toBe(true) + + expect( + WriteParametersSchema.safeParse({ + address: 4, + single: false, + type: 'holding_registers', + value: 1234, + dataType: 'uint16' + }).success + ).toBe(true) + }) + + it('rejects an address outside the Modbus range', () => { + const result = WriteParametersSchema.safeParse({ + address: 70000, + single: true, + type: 'coils', + value: [true] + }) + expect(result.success).toBe(false) + }) + + it('rejects a register write with no data type', () => { + const result = WriteParametersSchema.safeParse({ + address: 4, + single: true, + type: 'holding_registers', + value: 1234 + }) + expect(result.success).toBe(false) + }) + + it('rejects an add-register payload whose params are incomplete', () => { + const result = AddRegisterParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + littleEndian: false, + params: { address: 0, registerType: 'holding_registers' } + }) + expect(result.success).toBe(false) + }) +}) + +// +// The channels that carry a loaded config outward. A saved config file can be +// hand-edited, so what comes back through these is the least trustworthy input +// the app takes. + +describe('scan schemas', () => { + it('accepts a scan over the whole unit id byte', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [0, 255], + address: 65535, + length: 1, + registerTypes: ['coils'], + timeout: 1 + }) + expect(result.success).toBe(true) + }) + + it('rejects a unit id scan with no register type, which scans nothing', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [1, 10], + address: 0, + length: 1, + registerTypes: [], + timeout: 500 + }) + expect(result.success).toBe(false) + }) + + it('rejects a unit id above the byte a unit id is', () => { + const result = ScanUnitIDParametersSchema.safeParse({ + range: [1, 256], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 500 + }) + expect(result.success).toBe(false) + }) + + it('rejects a register scan with a timeout of zero, which never waits', () => { + const result = ScanRegistersParametersSchema.safeParse({ + addressRange: [0, 100], + length: 10, + timeout: 0 + }) + expect(result.success).toBe(false) + }) +}) + +describe('server register schemas', () => { + it('rejects a remove with an empty uuid, which names no server', () => { + const result = RemoveRegisterParamsSchema.safeParse({ + uuid: '', + unitId: '1', + registerType: 'holding_registers', + address: 0, + dataType: 'uint16' + }) + expect(result.success).toBe(false) + }) + + it('accepts a sync that clears every register, which is a list of none', () => { + const result = SyncRegisterValueParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + registerValues: [], + littleEndian: false + }) + expect(result.success).toBe(true) + }) + + it('rejects a sync whose register carries no address', () => { + const result = SyncRegisterValueParamsSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + registerValues: [{ registerType: 'holding_registers', dataType: 'uint16', value: 1 }], + littleEndian: false + }) + expect(result.success).toBe(false) + }) + + // The two reset channels take the same three fields and differ only in which + // register types they accept. Swapping their schemas would pass a test that + // only checked the happy path of each. + it('resets registers on a number type and refuses a boolean one', () => { + const params = { uuid: 'server-1', unitId: '1' } + expect( + ResetRegistersParamsSchema.safeParse({ ...params, registerType: 'holding_registers' }).success + ).toBe(true) + expect(ResetRegistersParamsSchema.safeParse({ ...params, registerType: 'coils' }).success).toBe( + false + ) + }) + + it('resets bools on a boolean type and refuses a number one', () => { + const params = { uuid: 'server-1', unitId: '1' } + expect(ResetBoolsParamsSchema.safeParse({ ...params, registerType: 'coils' }).success).toBe( + true + ) + expect( + ResetBoolsParamsSchema.safeParse({ ...params, registerType: 'holding_registers' }).success + ).toBe(false) + }) + + it('rejects a bool sync whose coils are not booleans', () => { + const result = SyncBoolsParametersSchema.safeParse({ + uuid: 'server-1', + unitId: '1', + coils: [1, 0], + discrete_inputs: [] + }) + expect(result.success).toBe(false) + }) +}) + +describe('server lifecycle schemas', () => { + it('accepts the Modbus port and rejects one past 16 bits', () => { + expect(CreateServerParamsSchema.safeParse({ uuid: 'server-1', port: 502 }).success).toBe(true) + expect(CreateServerParamsSchema.safeParse({ uuid: 'server-1', port: 70000 }).success).toBe( + false + ) + }) + + it('rejects an RTU start with no serial config', () => { + const result = StartRtuServerParamsSchema.safeParse({ uuid: 'server-1' }) + expect(result.success).toBe(false) + }) + + it('accepts both privileged port fix modes and refuses a third', () => { + expect(PrivilegedPortFixModeSchema.safeParse('session').success).toBe(true) + expect(PrivilegedPortFixModeSchema.safeParse('persist').success).toBe(true) + expect(PrivilegedPortFixModeSchema.safeParse('reboot').success).toBe(false) + }) +}) + +describe('the config updates, which arrive one field at a time', () => { + it('accepts a nested field on its own', () => { + const result = ConnectionConfigSchema.deepPartial().safeParse({ tcp: { host: '10.0.0.4' } }) + expect(result.success).toBe(true) + }) + + it('rejects a unit id that is not a number, even nested in a partial', () => { + const result = ConnectionConfigSchema.deepPartial().safeParse({ unitId: 'one' }) + expect(result.success).toBe(false) + }) +}) + +// +// Which schema a channel got. +// +// The schema tests above check a schema, and the createIpcHandle tests check the +// guard. Neither says that `sync_bools` got SyncBoolsParametersSchema rather +// than the one beside it, and the reset and sync channels take payloads similar +// enough that a swap parses. +// +// So each channel is driven twice through initIpc. The valid payload must reach +// the listener, which a swapped schema breaks. The invalid one must come back as +// a message naming the channel, which a missing schema breaks: a channel with no +// guard accepts everything, and passing the valid payload proves nothing about +// it. + +describe('each guarded channel got its own schema', () => { + /** Enough of a collaborator to record the call and nothing more. */ + const stub = (): Record> => + new Proxy({} as Record>, { + get: (target, key: string) => (target[key] ??= vi.fn()) + }) + + const validPayloads: Record = { + update_connection_config: { unitId: 3 }, + update_register_config: { address: 40, length: 10 }, + set_register_mapping: { + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: {} + }, + write: { address: 4, single: true, type: 'coils', value: [true] }, + scan_registers: { addressRange: [0, 100], length: 10, timeout: 500 }, + scan_unit_ids: { + range: [1, 10], + address: 0, + length: 1, + registerTypes: ['holding_registers'], + timeout: 500 + }, + add_replace_server_register: { + uuid: 'server-1', + unitId: '1', + littleEndian: false, + params: { + address: 0, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value: 1 + } + }, + remove_server_register: { + uuid: 'server-1', + unitId: '1', + registerType: 'holding_registers', + address: 0, + dataType: 'uint16' + }, + sync_server_register: { + uuid: 'server-1', + unitId: '1', + registerValues: [], + littleEndian: false + }, + reset_registers: { uuid: 'server-1', unitId: '1', registerType: 'holding_registers' }, + set_bool: { uuid: 'server-1', unitId: '1', registerType: 'coils', address: 0, state: true }, + reset_bools: { uuid: 'server-1', unitId: '1', registerType: 'coils' }, + sync_bools: { uuid: 'server-1', unitId: '1', coils: [], discrete_inputs: [] }, + start_rtu_server: { + uuid: 'server-1', + serialConfig: { + com: '/dev/ttyUSB0', + options: { baudRate: '9600', dataBits: 8, stopBits: 1, parity: 'none' } + } + } + } + + const start = (): { sent: BackendMessage[] } => { + handle.mockClear() + const { windows, sent } = createWindows() + initIpc( + stub() as unknown as Electron.App, + stub() as never, + stub() as never, + stub() as never, + windows + ) + return { sent } + } + + 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([]) + }) + + // A string reaches every one of these as an object was expected, so it is the + // one payload that is wrong for all of them and right for none. + it.each(Object.keys(validPayloads))( + 'guards %s against a payload that is not one', + async (channel) => { + const { sent } = start() + await invoke(channel, 'not a payload') + 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 9865831..edb4651 100644 --- a/src/main/__tests__/state.test.ts +++ b/src/main/__tests__/state.test.ts @@ -1,6 +1,27 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { AppState } from '../state' -import { defaultConnectionConfig, defaultRegisterConfig } from '@shared' +import { AppState, withoutUndefined } from '../state' +import { ConnectionConfigSchema, defaultConnectionConfig, defaultRegisterConfig } from '@shared' + +describe('withoutUndefined', () => { + it('drops a key set to undefined and keeps the rest', () => { + expect(withoutUndefined({ a: 1, b: undefined })).toEqual({ a: 1 }) + }) + + it('drops one nested two deep', () => { + expect(withoutUndefined({ a: { b: { c: undefined, d: 2 } } })).toEqual({ a: { b: { d: 2 } } }) + }) + + it('hands an array back as an array', () => { + // Recursing would return the indices as an object, and deepmerge would + // never see an array again. + expect(withoutUndefined({ a: [1, 2] }).a).toEqual([1, 2]) + expect(Array.isArray(withoutUndefined({ a: [1, 2] }).a)).toBe(true) + }) + + it('leaves null alone', () => { + expect(withoutUndefined({ a: null })).toEqual({ a: null }) + }) +}) describe('AppState', () => { let state: AppState @@ -57,6 +78,40 @@ 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. + describe('updateConnectionConfig with an undefined field', () => { + it('keeps the stored host', () => { + state.updateConnectionConfig({ tcp: { host: '10.0.0.1' } }) + state.updateConnectionConfig({ tcp: { host: undefined } }) + + expect(state.connectionConfig.tcp.host).toBe('10.0.0.1') + }) + + it('leaves a config the schema still accepts', () => { + state.updateConnectionConfig({ tcp: { host: undefined } }) + + expect(ConnectionConfigSchema.safeParse(state.connectionConfig).success).toBe(true) + }) + + it('keeps a nested option the payload leaves undefined', () => { + state.updateConnectionConfig({ rtu: { options: { parity: undefined } } }) + + expect(state.connectionConfig.rtu.options.parity).toBe( + defaultConnectionConfig.rtu.options.parity + ) + }) + + it('still writes the fields that carry a value', () => { + state.updateConnectionConfig({ unitId: undefined, tcp: { host: '10.0.0.2' } }) + + expect(state.connectionConfig.tcp.host).toBe('10.0.0.2') + expect(state.connectionConfig.unitId).toBe(defaultConnectionConfig.unitId) + }) + }) + describe('updateRegisterConfig', () => { it('deep merges partial register config', () => { state.updateRegisterConfig({ address: 100 }) diff --git a/src/main/index.ts b/src/main/index.ts index b5ed96a..2f940f8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ import { initIpc, onIpcEvent } from './ipc' import { AppState } from './state' import { ModbusClient } from './modules/modbusClient' import os from 'os' -import { ModbusServer } from './modules/mobusServer' +import { ModbusServer } from './modules/modbusServer' import { Windows } from '@shared' if (is.dev && os.platform() === 'darwin') { @@ -26,7 +26,7 @@ const client = new ModbusClient({ appState, windows }) const server = new ModbusServer({ windows }) // IPC -initIpc(app, appState, client, server) +initIpc(app, appState, client, server, windows) /** * Say which path took the app down. @@ -45,18 +45,26 @@ if (!gotTheLock) { lifecycle('another instance holds the single instance lock, quitting') app.quit() } else { + /** + * Someone launched Modbux again. Show them the app they already have. + * + * On macos `window-all-closed` does not quit, so the app can be sitting in + * the dock with no window at all, and then there is nothing to focus and one + * to create. Everywhere else the app is gone before a second launch can + * happen, so only macos reaches the second branch. + */ app.on('second-instance', () => { - // Someone tried to run a second instance, we should focus our window. - if (windows.main) { - if (windows.main.isMinimized()) windows.main.restore() - windows.main.focus() + if (windows.main === null) { + createWindow() + return } + if (windows.main.isMinimized()) windows.main.restore() + windows.main.focus() }) } function createWindow(): BrowserWindow { - // Create the browser window. - windows.main = new BrowserWindow({ + const mainWindow = new BrowserWindow({ width: 1480, height: 1000, minWidth: 820, @@ -75,29 +83,42 @@ function createWindow(): BrowserWindow { backgroundColor: '#181818' }) - windows.main.on('ready-to-show', () => { - if (windows.main === null) return - windows.main.show() + windows.main = mainWindow + + mainWindow.on('ready-to-show', () => { + mainWindow.show() }) - windows.main.webContents.setWindowOpenHandler((details) => { + mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) - windows.main.on('close', () => { + mainWindow.on('close', () => { windows.server?.close() }) + /** + * Let go of the handle the moment the window is destroyed, the way the server + * window already does. + * + * `close` is too early: the window is still alive there and a listener may + * still cancel it. Every method on a destroyed `BrowserWindow` throws, so a + * handle kept past this point is one that costs whoever reads it next. + */ + mainWindow.on('closed', () => { + windows.main = null + }) + // 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']) { - windows.main.loadURL(process.env['ELECTRON_RENDERER_URL']) + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - windows.main.loadFile(join(__dirname, '../renderer/index.html')) + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } - return windows.main + return mainWindow } // diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9491f45..f409bd2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,58 +2,127 @@ import { AppState } from './state' import { ScanRegistersParameters, ConnectionConfigSchema, - defaultConnectionConfig, - ClientStateSchema, - defaultClientState, IpcHandlerMap, IpcEvent, - IpcEventPayloadMap + IpcEventPayloadMap, + Windows, + formatZodError, + WriteParametersSchema, + AddRegisterParamsSchema, + SetBooleanParametersSchema, + CreateServerParamsSchema, + PrivilegedPortFixModeSchema, + RegisterConfigSchema, + RegisterMappingSchema, + RemoveRegisterParamsSchema, + ResetBoolsParamsSchema, + ResetRegistersParamsSchema, + ScanRegistersParametersSchema, + ScanUnitIDParametersSchema, + StartRtuServerParamsSchema, + SyncBoolsParametersSchema, + SyncRegisterValueParamsSchema } from '@shared' import { ModbusClient } from './modules/modbusClient' -import { ModbusServer } from './modules/mobusServer' +import { ModbusServer } from './modules/modbusServer' import { applyPrivilegedPortFix, getPrivilegedPortStatus } from './modules/privilegedPort' import { applySerialGroupFix, getSerialGroupStatus, requestLogout } from './modules/serialGroup' import { IpcMainEvent, IpcMainInvokeEvent, ipcMain } from 'electron' +import type { ZodType } from 'zod' -export const ipcHandle = ( - channel: C, - listener: ( - event: IpcMainInvokeEvent, - ...args: IpcHandlerMap[C]['args'] - ) => Promise | IpcHandlerMap[C]['return'] -): void => { - ipcMain.handle(channel, listener) -} +type IpcListener = ( + event: IpcMainInvokeEvent, + ...args: IpcHandlerMap[C]['args'] +) => Promise | IpcHandlerMap[C]['return'] + +/** + * A schema may only guard a channel where `undefined` is an honest answer. + * + * A rejected payload leaves nothing to return. A channel answering `void` has + * nothing to return anyway; a channel answering a value has to say so in its + * type, because `create_server` hands back the port it actually bound and the + * renderer writes that straight into the port field. A stand-in number would + * show up there as a real one, and so would `String(undefined)`. + */ +type PayloadSchema = + undefined extends Awaited + ? ZodType + : never + +/** + * Builds the `ipcHandle` used below, bound to the windows it reports through. + * + * A guarded channel hands the handler the *parsed* payload, not the one that + * arrived, so anything the schema does not describe is stripped before it can + * reach a Modbus socket. + * + * A rejected payload comes back as a `backend_message`, never as a throw. An + * error crossing the IPC boundary surfaces in the renderer as an unhandled + * rejection carrying the channel name and nothing else, which is exactly the + * failure the Linux helpers avoid by returning results instead of throwing. + */ +export const createIpcHandle = + (windows: Windows) => + ( + channel: C, + listener: IpcListener, + schema?: PayloadSchema + ): void => { + if (!schema) { + ipcMain.handle(channel, listener) + return + } + + ipcMain.handle(channel, (event: IpcMainInvokeEvent, ...args: unknown[]) => { + const result = schema.safeParse(args[0]) + + if (!result.success) { + windows.send('backend_message', { + message: 'Invalid request, nothing was changed', + variant: 'error', + error: `${channel}: ${formatZodError(result.error)}` + }) + return undefined + } + + return listener(event, ...([result.data] as IpcHandlerMap[C]['args'])) + }) + } type InitIpcFn = ( app: Electron.App, state: AppState, client: ModbusClient, - server: ModbusServer + server: ModbusServer, + windows: Windows ) => void -export const initIpc: InitIpcFn = (app, state, client, server) => { - // Connnection config - ipcHandle('get_connection_config', () => { - // Validate and return the current connection config, or default if invalid - const result = ConnectionConfigSchema.safeParse(state.connectionConfig) - if (result.success) return result.data - state.updateConnectionConfig(defaultConnectionConfig) - return defaultConnectionConfig - }) - ipcHandle('update_connection_config', (_, config) => state.updateConnectionConfig(config)) +export const initIpc: InitIpcFn = (app, state, client, server, windows) => { + const ipcHandle = createIpcHandle(windows) + + // Connection config + ipcHandle( + 'update_connection_config', + (_, config) => state.updateConnectionConfig(config), + ConnectionConfigSchema.deepPartial() + ) // Register config - ipcHandle('update_register_config', (_, config) => state.updateRegisterConfig(config)) + ipcHandle( + 'update_register_config', + (_, config) => state.updateRegisterConfig(config), + RegisterConfigSchema.deepPartial() + ) // Client state - ipcHandle('get_client_state', () => { - // Validate and return the current client state, or default if invalid - const result = ClientStateSchema.safeParse(client.state) - if (result.success) return result.data - return defaultClientState - }) - ipcHandle('set_register_mapping', (_, mapping) => state.setRegisterMapping(mapping)) + ipcHandle('get_client_state', () => client.state) + + // Register mapping + ipcHandle( + 'set_register_mapping', + (_, mapping) => state.setRegisterMapping(mapping), + RegisterMappingSchema + ) // Connection Actions ipcHandle('connect', () => client.connect()) @@ -65,33 +134,60 @@ export const initIpc: InitIpcFn = (app, state, client, server) => { ipcHandle('stop_polling', () => client.stopPolling()) // Write Actions - ipcHandle('write', (_, writeParameters) => client.write(writeParameters)) + ipcHandle('write', (_, writeParameters) => client.write(writeParameters), WriteParametersSchema) // Scan Unit ID Actions - ipcHandle('scan_unit_ids', (_, scanUnitIdParameters) => client.scanUnitIds(scanUnitIdParameters)) + ipcHandle( + 'scan_unit_ids', + (_, scanUnitIdParameters) => client.scanUnitIds(scanUnitIdParameters), + ScanUnitIDParametersSchema + ) ipcHandle('stop_scanning_unit_ids', () => client.stopScanningUnitIds()) // Scan Registers Actions - ipcHandle('scan_registers', (_, scanRegistersParameters: ScanRegistersParameters) => - client.scanRegisters(scanRegistersParameters) + ipcHandle( + 'scan_registers', + (_, scanRegistersParameters: ScanRegistersParameters) => + client.scanRegisters(scanRegistersParameters), + ScanRegistersParametersSchema ) ipcHandle('stop_scanning_registers', () => client.stopScanningRegisters()) // Server - ipcHandle('add_replace_server_register', (_, params) => server.addRegister(params)) - ipcHandle('remove_server_register', (_, params) => server.removeRegister(params)) - ipcHandle('sync_server_register', (_, params) => server.syncServerRegisters(params)) - ipcHandle('reset_registers', (_, params) => server.resetRegisters(params)) - ipcHandle('set_bool', (_, params) => server.setBool(params)) - ipcHandle('reset_bools', (_, params) => server.resetBools(params)) - ipcHandle('sync_bools', (_, params) => server.syncBools(params)) + ipcHandle( + 'add_replace_server_register', + (_, params) => server.addRegister(params), + AddRegisterParamsSchema + ) + ipcHandle( + 'remove_server_register', + (_, params) => server.removeRegister(params), + RemoveRegisterParamsSchema + ) + ipcHandle( + 'sync_server_register', + (_, params) => server.syncServerRegisters(params), + SyncRegisterValueParamsSchema + ) + ipcHandle( + 'reset_registers', + (_, params) => server.resetRegisters(params), + ResetRegistersParamsSchema + ) + ipcHandle('set_bool', (_, params) => server.setBool(params), SetBooleanParametersSchema) + ipcHandle('reset_bools', (_, params) => server.resetBools(params), ResetBoolsParamsSchema) + ipcHandle('sync_bools', (_, params) => server.syncBools(params), SyncBoolsParametersSchema) ipcHandle('reset_server', (_, uuid) => server.resetServer(uuid)) - ipcHandle('set_server_port', (_, params) => server.setPort(params)) - ipcHandle('create_server', (_, params) => server.createServer(params)) + ipcHandle('set_server_port', (_, params) => server.setPort(params), CreateServerParamsSchema) + ipcHandle('create_server', (_, params) => server.createServer(params), CreateServerParamsSchema) ipcHandle('delete_server', (_, uuid) => server.deleteServer(uuid)) // RTU Server - ipcHandle('start_rtu_server', (_, params) => server.startRtuServer(params)) + ipcHandle( + 'start_rtu_server', + (_, params) => server.startRtuServer(params), + StartRtuServerParamsSchema + ) ipcHandle('stop_rtu_server', () => server.stopRtuServer()) ipcHandle('stop_all_tcp_servers', () => server.stopAllTcpServers()) @@ -103,7 +199,11 @@ export const initIpc: InitIpcFn = (app, state, client, server) => { // Linux privileged ports (port 502 needs the unprivileged-port floor lowered) ipcHandle('get_privileged_port_status', (_, port) => getPrivilegedPortStatus(port)) - ipcHandle('apply_privileged_port_fix', (_, mode) => applyPrivilegedPortFix(mode)) + ipcHandle( + 'apply_privileged_port_fix', + (_, mode) => applyPrivilegedPortFix(mode), + PrivilegedPortFixModeSchema + ) ipcHandle('get_serial_group_status', () => getSerialGroupStatus()) ipcHandle('apply_serial_group_fix', () => applySerialGroupFix()) ipcHandle('request_logout', () => requestLogout()) diff --git a/src/main/modules/__tests__/modbusClient.test.ts b/src/main/modules/__tests__/modbusClient.test.ts index 490dbe6..b0b706d 100644 --- a/src/main/modules/__tests__/modbusClient.test.ts +++ b/src/main/modules/__tests__/modbusClient.test.ts @@ -7,6 +7,18 @@ import { AppState } from '../../state' // Track event handlers registered on the mock ModbusRTU client let clientEventHandlers: Record void> = {} +/** + * Fire what the client registered, or fail naming the handler that is missing. + * + * Reaching through the record with `?.()` would turn a client that registered + * nothing into a test that quietly does nothing and still passes. + */ +const fireClientEvent = (event: 'close' | 'error', ...args: unknown[]): void => { + const handler = clientEventHandlers[event] + if (!handler) throw new Error(`no '${event}' handler registered`) + handler(...args) +} + const createMockModbusRTU = () => ({ isOpen: false, on: vi.fn(function (this: unknown, event: string, handler: (...args: unknown[]) => void) { @@ -47,6 +59,16 @@ vi.mock('modbus-serial', () => { import { ModbusClient } from '../modbusClient' import ModbusRTU from 'modbus-serial' +/** When a mock was first called, or a failure naming the one that never ran. */ +const firstCallOrder = ( + mock: { mock: { invocationCallOrder: number[] } }, + name: string +): number => { + const [order] = mock.mock.invocationCallOrder + if (order === undefined) throw new Error(`${name} was never called`) + return order +} + const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows describe('ModbusClient', () => { @@ -114,7 +136,7 @@ describe('ModbusClient', () => { it('starts in disconnected state', () => { expect(client.state.connectState).toBe('disconnected') expect(client.state.polling).toBe(false) - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) expect(client.state.scanningRegisters).toBe(false) }) }) @@ -204,7 +226,7 @@ describe('ModbusClient', () => { await connectClient() // Simulate connection loss — isOpen goes false mockModbusRTU.isOpen = false - clientEventHandlers['close']?.() + fireClientEvent('close') // The reconnect will call connect() which calls connectTCP // connectTCP mock still resolves and sets isOpen = true @@ -233,7 +255,7 @@ describe('ModbusClient', () => { await vi.advanceTimersByTimeAsync(11000) // Trigger close events — counter was reset so it starts fresh - clientEventHandlers['close']?.() + fireClientEvent('close') expect(getLastClientState().connectState).toBe('connecting') }) }) @@ -282,6 +304,40 @@ describe('ModbusClient', () => { expect(messages.some((m) => m[1].message.includes('Disconnect timeout'))).toBe(true) }) + // The mock constructor hands out one shared object, so the replacement is + // the same instance and its handlers are still attached. Emptying the + // record first is what makes the two below see the registration itself. + it('re-registers its handlers on the client the timeout replaces', async () => { + await connectClient() + mockModbusRTU.close.mockImplementation(() => {}) + + clientEventHandlers = {} + const disconnectPromise = client.disconnect() + await vi.advanceTimersByTimeAsync(5500) + await disconnectPromise + + expect(Object.keys(clientEventHandlers).sort()).toEqual(['close', 'error']) + + // And they still do the work: a close on the replacement reconnects. + mockModbusRTU.isOpen = false + await connectClient() + fireClientEvent('close') + + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message.includes('Connection lost, reconnecting'))).toBe( + true + ) + }) + + it('leaves the handlers alone when close answers in time', async () => { + await connectClient() + + clientEventHandlers = {} + await client.disconnect() + + expect(Object.keys(clientEventHandlers)).toEqual([]) + }) + it('handles disconnect error', async () => { await connectClient() mockModbusRTU.close.mockImplementation(() => { @@ -299,7 +355,7 @@ describe('ModbusClient', () => { await connectClient() // Simulate connection loss — triggers reconnect, state becomes 'connecting' mockModbusRTU.isOpen = false - clientEventHandlers['close']?.() + fireClientEvent('close') expect(getLastClientState().connectState).toBe('connecting') // Disconnect while in 'connecting' state @@ -316,7 +372,7 @@ describe('ModbusClient', () => { it('schedules reconnect on close event', async () => { await connectClient() - clientEventHandlers['close']?.() + fireClientEvent('close') expect(getLastClientState().connectState).toBe('connecting') @@ -332,7 +388,7 @@ describe('ModbusClient', () => { // Simulate 5 consecutive close events (max) for (let i = 0; i < 5; i++) { - clientEventHandlers['close']?.() + fireClientEvent('close') await vi.advanceTimersByTimeAsync(3500) } @@ -350,7 +406,7 @@ describe('ModbusClient', () => { // Clear call history to only track events after this point ;(windows.send as ReturnType).mockClear() - clientEventHandlers['close']?.() + fireClientEvent('close') // Should stay disconnected expect(getLastClientState().connectState).toBe('disconnected') @@ -366,8 +422,8 @@ describe('ModbusClient', () => { await vi.advanceTimersByTimeAsync(11000) // Trigger two close events quickly - clientEventHandlers['close']?.() - clientEventHandlers['close']?.() + fireClientEvent('close') + fireClientEvent('close') const messages = getWindowCalls('backend_message') const reconnectMessages = messages.filter((m) => m[1].message.includes('reconnecting')) @@ -383,13 +439,13 @@ describe('ModbusClient', () => { // Disable auto-reconnect by exhausting reconnects await vi.advanceTimersByTimeAsync(11000) for (let i = 0; i < 5; i++) { - clientEventHandlers['close']?.() + fireClientEvent('close') await vi.advanceTimersByTimeAsync(3500) } // Now auto-reconnect is disabled. Trigger another close. ;(windows.send as ReturnType).mockClear() - clientEventHandlers['close']?.() + fireClientEvent('close') const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Connection closed unexpectedly')).toBe(true) @@ -403,7 +459,7 @@ describe('ModbusClient', () => { // test does, misses the race entirely. mockModbusRTU.close.mockImplementation((cb: () => void) => { mockModbusRTU.isOpen = false - clientEventHandlers['close']?.() + fireClientEvent('close') cb() }) ;(windows.send as ReturnType).mockClear() @@ -423,12 +479,12 @@ describe('ModbusClient', () => { await connectClient() await vi.advanceTimersByTimeAsync(11000) for (let i = 0; i < 5; i++) { - clientEventHandlers['close']?.() + fireClientEvent('close') await vi.advanceTimersByTimeAsync(3500) } ;(windows.send as ReturnType).mockClear() - clientEventHandlers['close']?.() + fireClientEvent('close') const messages = getWindowCalls('backend_message') expect(messages.some((m) => m[1].message === 'Connection closed unexpectedly')).toBe(true) @@ -521,7 +577,7 @@ describe('ModbusClient', () => { describe('scanning', () => { it('stopScanningUnitIds sets flag to false', () => { client.stopScanningUnitIds() - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) }) it('stopScanningRegisters sets flag to false', () => { @@ -577,7 +633,7 @@ describe('ModbusClient', () => { const dataCalls = getWindowCalls('register_data') expect(dataCalls.length).toBe(1) - expect(dataCalls[0][1].length).toBe(2) + expect(dataCalls[0]?.[1].length).toBe(2) }) it('sends address groups alongside data', async () => { @@ -780,7 +836,7 @@ describe('ModbusClient', () => { const txCalls = getWindowCalls('transaction') expect(txCalls.length).toBe(1) - const tx = txCalls[0][1] + const tx = txCalls[0]?.[1] expect(tx.id).toContain('42__') expect(tx.unitId).toBe(1) expect(tx.address).toBe(0) @@ -793,14 +849,50 @@ describe('ModbusClient', () => { expect(tx.errorMessage).toBeUndefined() }) - it('clears transactions after processing', async () => { + it('removes the transaction it logged', async () => { await connectClient() setupHoldingRegisterReadMock([0]) await client.read() - // After _logTransaction, _transactions should be empty - expect(Object.keys(mockModbusRTU._transactions).length).toBe(0) + expect(Object.keys(mockModbusRTU._transactions)).toEqual([]) + }) + + it('logs a transaction once', async () => { + await connectClient() + let callCount = 0 + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + callCount++ + // Only the first read leaves a transaction behind, so a second call + // that logged the same one again would show up as two. + if (callCount === 1) mockModbusRTU._transactions = { '1': createMockTransaction() } + return { data: [0], buffer: Buffer.alloc(2) } + }) + + await client.read() + await client.read() + + expect(getWindowCalls('transaction')).toHaveLength(1) + }) + + it('leaves a transaction it did not log alone', async () => { + await connectClient() + mockModbusRTU.readHoldingRegisters.mockImplementation(async () => { + // Key 1 is a request still in flight, key 2 the one this read + // finished. modbus-serial drops a response whose entry is gone, so + // taking 1 out with 2 times that request out instead of answering it. + mockModbusRTU._transactions = { + '1': createMockTransaction(50), + '2': createMockTransaction(0) + } + return { data: [0], buffer: Buffer.alloc(2) } + }) + + await client.read() + + expect(Object.keys(mockModbusRTU._transactions)).toEqual(['1']) + expect(getWindowCalls('transaction')).toHaveLength(1) + expect(getWindowCalls('transaction')[0]?.[1].id).toContain('2__') }) it('skips when no transactions exist', async () => { @@ -827,7 +919,7 @@ describe('ModbusClient', () => { const txCalls = getWindowCalls('transaction') expect(txCalls.length).toBe(1) - expect(txCalls[0][1].errorMessage).toBe('Timed out') + expect(txCalls[0]?.[1].errorMessage).toBe('Timed out') }) it('logs a transaction that carries no request or responses', async () => { @@ -851,8 +943,8 @@ describe('ModbusClient', () => { const txCalls = getWindowCalls('transaction') expect(txCalls.length).toBe(1) - expect(txCalls[0][1].request).toBe('') - expect(txCalls[0][1].responses).toEqual([]) + expect(txCalls[0]?.[1].request).toBe('') + expect(txCalls[0]?.[1].responses).toEqual([]) }) it('keeps the serial transaction key, which is not a number', async () => { @@ -867,8 +959,52 @@ describe('ModbusClient', () => { await client.read() const txCalls = getWindowCalls('transaction') - expect(txCalls[0][1].id).toContain('undefined__') - expect(txCalls[0][1].id).not.toContain('NaN') + expect(txCalls[0]?.[1].id).toContain('undefined__') + expect(txCalls[0]?.[1].id).not.toContain('NaN') + }) + + // The two below discriminate a per-group error message from one that + // outlives its group: the second group answers, and the question is which + // error the transaction it produced is logged with. + const readTwoGroups = async (failFirst: boolean) => { + await connectClient() + appState.setReadConfiguration(true) + appState.setRegisterMapping({ + coils: {}, + discrete_inputs: {}, + input_registers: {}, + holding_registers: { + 0: { dataType: 'uint16' }, + 100: { dataType: 'uint16' } + } + }) + + let callCount = 0 + mockModbusRTU.readHoldingRegisters.mockImplementation(async (address: number) => { + callCount++ + mockModbusRTU._transactions = { [String(callCount)]: createMockTransaction(address) } + if (failFirst && callCount === 1) throw new Error('read timeout') + return { data: [100], buffer: Buffer.from([0x00, 0x64]) } + }) + + await client.read() + return getWindowCalls('transaction').map((c) => c[1]) + } + + it('logs the group that succeeds after a failed one without an error', async () => { + const transactions = await readTwoGroups(true) + + expect(transactions).toHaveLength(2) + expect(transactions[0].errorMessage).toBe('read timeout') + expect(transactions[1].errorMessage).toBeUndefined() + }) + + it('logs both groups without an error when both succeed', async () => { + const transactions = await readTwoGroups(false) + + expect(transactions).toHaveLength(2) + expect(transactions[0].errorMessage).toBeUndefined() + expect(transactions[1].errorMessage).toBeUndefined() }) it('records timeout flag from transaction', async () => { @@ -883,7 +1019,7 @@ describe('ModbusClient', () => { await client.read() const txCalls = getWindowCalls('transaction') - expect(txCalls[0][1].timeout).toBe(true) + expect(txCalls[0]?.[1].timeout).toBe(true) }) }) @@ -900,6 +1036,17 @@ describe('ModbusClient', () => { expect(mockModbusRTU.writeFC5).toHaveBeenCalledWith(1, 5, true, expect.any(Function)) }) + it('sends nothing for FC5 when the coil list is empty', async () => { + await connectClient() + + // The schema accepts an empty list, and FC5 takes the first coil of it. + await client.write({ address: 5, type: 'coils', value: [], single: true }) + + expect(mockModbusRTU.writeFC5).not.toHaveBeenCalled() + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'No coil value to write')).toBe(true) + }) + it('writes multiple coils via FC15', async () => { await connectClient() mockModbusRTU.writeFC15.mockImplementation( @@ -1133,10 +1280,10 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(3) - expect(results[0][1].id).toBe(1) - expect(results[1][1].id).toBe(2) - expect(results[2][1].id).toBe(3) - expect(results[0][1].registerTypes).toContain('holding_registers') + expect(results[0]?.[1].id).toBe(1) + expect(results[1]?.[1].id).toBe(2) + expect(results[2]?.[1].id).toBe(3) + expect(results[0]?.[1].registerTypes).toContain('holding_registers') }) it('records errors for failed register type reads', async () => { @@ -1159,9 +1306,9 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].errorMessage.coils).toBe('coils failed') - expect(results[0][1].registerTypes).toContain('holding_registers') - expect(results[0][1].registerTypes).not.toContain('coils') + expect(results[0]?.[1].errorMessage.coils).toBe('coils failed') + expect(results[0]?.[1].registerTypes).toContain('holding_registers') + expect(results[0]?.[1].registerTypes).not.toContain('coils') }) // ! Coverage-only: exercises scan-stop check after coils @@ -1211,8 +1358,8 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].errorMessage.discrete_inputs).toBe('discrete failed') - expect(results[0][1].registerTypes).not.toContain('discrete_inputs') + expect(results[0]?.[1].errorMessage.discrete_inputs).toBe('discrete failed') + expect(results[0]?.[1].registerTypes).not.toContain('discrete_inputs') }) // ! Coverage-only: exercises scan-stop check after discrete_inputs @@ -1266,7 +1413,7 @@ describe('ModbusClient', () => { // Should have scanned far fewer than 100 units const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBeLessThan(100) - expect(client.state.scanningUniId).toBe(false) + expect(client.state.scanningUnitIds).toBe(false) }) it('scans all four register types', async () => { @@ -1294,10 +1441,10 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].registerTypes).toContain('coils') - expect(results[0][1].registerTypes).toContain('discrete_inputs') - expect(results[0][1].registerTypes).toContain('holding_registers') - expect(results[0][1].registerTypes).toContain('input_registers') + expect(results[0]?.[1].registerTypes).toContain('coils') + expect(results[0]?.[1].registerTypes).toContain('discrete_inputs') + expect(results[0]?.[1].registerTypes).toContain('holding_registers') + expect(results[0]?.[1].registerTypes).toContain('input_registers') }) it('emits scan progress', async () => { @@ -1320,7 +1467,7 @@ describe('ModbusClient', () => { const progress = getWindowCalls('scan_progress') expect(progress.length).toBeGreaterThan(0) // Last progress should be 100 - expect(progress.at(-1)![1]).toBe(100) + expect(progress.at(-1)?.[1]).toBe(100) }) // ! Coverage-only: exercises FALSE branch of registerTypes.includes('holding_registers') @@ -1341,9 +1488,9 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].registerTypes).toContain('coils') - expect(results[0][1].registerTypes).toContain('input_registers') - expect(results[0][1].registerTypes).not.toContain('holding_registers') + expect(results[0]?.[1].registerTypes).toContain('coils') + expect(results[0]?.[1].registerTypes).toContain('input_registers') + expect(results[0]?.[1].registerTypes).not.toContain('holding_registers') // holding_registers should never have been read expect(mockModbusRTU.readHoldingRegisters).not.toHaveBeenCalled() }) @@ -1366,9 +1513,9 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].errorMessage.holding_registers).toBe('holding reg failed') - expect(results[0][1].registerTypes).toContain('input_registers') - expect(results[0][1].registerTypes).not.toContain('holding_registers') + expect(results[0]?.[1].errorMessage.holding_registers).toBe('holding reg failed') + expect(results[0]?.[1].registerTypes).toContain('input_registers') + expect(results[0]?.[1].registerTypes).not.toContain('holding_registers') }) // ! Coverage-only: exercises scan-stop check after holding_registers @@ -1421,9 +1568,9 @@ describe('ModbusClient', () => { const results = getWindowCalls('scan_unit_id_result') expect(results.length).toBe(1) - expect(results[0][1].errorMessage.input_registers).toBe('input reg failed') - expect(results[0][1].registerTypes).toContain('holding_registers') - expect(results[0][1].registerTypes).not.toContain('input_registers') + expect(results[0]?.[1].errorMessage.input_registers).toBe('input reg failed') + expect(results[0]?.[1].registerTypes).toContain('holding_registers') + expect(results[0]?.[1].registerTypes).not.toContain('input_registers') }) // ! Coverage-only: exercises scan-stop check after input_registers @@ -1496,9 +1643,9 @@ describe('ModbusClient', () => { await scanPromise // setID must have been called before the first read - const setIdOrder = mockModbusRTU.setID.mock.invocationCallOrder[0] - const readOrder = mockModbusRTU.readHoldingRegisters.mock.invocationCallOrder[0] - expect(setIdOrder).toBeLessThan(readOrder) + expect(firstCallOrder(mockModbusRTU.setID, 'setID')).toBeLessThan( + firstCallOrder(mockModbusRTU.readHoldingRegisters, 'readHoldingRegisters') + ) }) it('scans address range and sends non-zero data', async () => { @@ -1619,7 +1766,7 @@ describe('ModbusClient', () => { const dataCalls = getWindowCalls('register_data') expect(dataCalls.length).toBeGreaterThan(0) // Only registers with bit=true should pass the filter (addresses 0 and 2) - const sentData = dataCalls[0][1] + const sentData = dataCalls[0]?.[1] expect(sentData.every((d: { bit: boolean }) => d.bit === true)).toBe(true) }) @@ -1709,7 +1856,7 @@ describe('ModbusClient', () => { describe('error event handler', () => { it('transitions to disconnected on error', () => { - clientEventHandlers['error']?.(new Error('Test error')) + fireClientEvent('error', new Error('Test error')) expect(getLastClientState().connectState).toBe('disconnected') diff --git a/src/main/modules/__tests__/modbusServer.test.ts b/src/main/modules/__tests__/modbusServer.test.ts index 79d94cd..cde623f 100644 --- a/src/main/modules/__tests__/modbusServer.test.ts +++ b/src/main/modules/__tests__/modbusServer.test.ts @@ -1,26 +1,56 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { UnitIdString, Windows } from '@shared' +import type { BaseDataType, RegisterParams, UnitIdString, Windows } from '@shared' import type { IServiceVector } from 'modbus-serial/ServerTCP' // Configurable port availability for net mock // Each entry is either a boolean (true=available) or a string error code (e.g. 'EACCES', 'EADDRINUSE') let portAvailableResults: (boolean | string)[] = [] +// What each ServerTCP bind does, in order: true emits `initialized`, a string +// emits `serverError` with that code, false emits neither so the timeout runs. +let bindResults: (boolean | string)[] = [] + // Mock modbus-serial before importing ModbusServer vi.mock('modbus-serial', () => ({ // Must use `function` (not arrow) so it can be called with `new` ServerTCP: vi.fn().mockImplementation(function () { - return { close: vi.fn((cb: (err: Error | null) => void) => cb(null)) } + const handlers: Record void> = {} + const entry = bindResults.shift() ?? true + + // The real constructor returns before `listen` finishes, so the event + // cannot fire until the caller has had the chance to register for it. + queueMicrotask(() => { + if (entry === true) handlers['initialized']?.() + else if (typeof entry === 'string') + handlers['serverError']?.(Object.assign(new Error(`listen ${entry}`), { code: entry })) + }) + + return { + on: vi.fn((event: string, handler: (err?: Error) => void) => { + handlers[event] = handler + }), + close: vi.fn((cb: (err: Error | null) => void) => cb(null)) + } }), ServerSerial: vi.fn().mockImplementation(function () { const handlers: Record void> = {} + // The SerialPort the library opens. `startRtuServer` reaches for it by name + // and registers on it, so a mock without one leaves those listeners out of + // every test. + const pathHandlers: Record void> = {} return { on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { handlers[event] = handler }), close: vi.fn((cb: (err: Error | null) => void) => cb(null)), - _handlers: handlers + _handlers: handlers, + _serverPath: { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + pathHandlers[event] = handler + }), + _handlers: pathHandlers + } } }) })) @@ -35,7 +65,7 @@ vi.mock('net', () => ({ handlers[event] = handler }), listen: vi.fn(() => { - const entry = portAvailableResults.length > 0 ? portAvailableResults.shift()! : true + const entry = portAvailableResults.shift() ?? true if (entry === true && handlers['listening']) { handlers['listening']() } else if (handlers['error']) { @@ -50,9 +80,76 @@ vi.mock('net', () => ({ } })) -import { ModbusServer, SERVER_DEVICE_FAILURE, ILLEGAL_DATA_ADDRESS } from '../mobusServer' +import { + ModbusServer, + SERVER_DEVICE_FAILURE, + ILLEGAL_DATA_ADDRESS, + GATEWAY_TARGET_FAILED, + BIND_TIMEOUT_MS +} from '../modbusServer' import { ServerTCP, ServerSerial } from 'modbus-serial' +/** Every handler `createServer` and `startRtuServer` put on their vector. */ +const VECTOR_HANDLERS = [ + 'getCoil', + 'getDiscreteInput', + 'getInputRegister', + 'getHoldingRegister', + 'setCoil', + 'setRegister' +] as const + +type ServerVector = Required> + +/** + * The vector the last server was built with, with its handlers resolved. + * + * A handler the server never set fails here saying which one, where + * `getHoldingRegister!(...)` said only that something was undefined. + */ +const lastVector = (constructor: typeof ServerTCP | typeof ServerSerial): ServerVector => { + const call = vi.mocked(constructor).mock.calls.at(-1) + if (!call) throw new Error('no server was constructed') + + const [vector] = call + const resolved: Partial = {} + for (const name of VECTOR_HANDLERS) { + const handler = vector[name] + if (!handler) throw new Error(`the server set no ${name}`) + Object.assign(resolved, { [name]: handler }) + } + return resolved as ServerVector +} + +/** What the last server constructor returned, or a failure saying there was none. */ +const lastInstance = (constructor: typeof ServerTCP | typeof ServerSerial) => { + const result = vi.mocked(constructor).mock.results.at(-1) + if (!result) throw new Error('no server was constructed') + return result.value +} + +/** A serial server as the mock builds it, with the handler records exposed. */ +type MockSerialServer = { + _handlers: Record void> + _serverPath: { _handlers: Record void> } +} + +/** + * Fire what `startRtuServer` registered on a server's serial port. + * + * A handler it never registered fails here saying which event, where + * `_handlers['close']()` would say only that something is not a function. + */ +const fireSerialPathEvent = ( + instance: MockSerialServer, + event: 'error' | 'close', + ...args: unknown[] +): void => { + const handler = instance._serverPath._handlers[event] + if (!handler) throw new Error(`the serial port got no '${event}' handler`) + handler(...args) +} + const createMockWindows = (): Windows => ({ send: vi.fn() }) as unknown as Windows describe('ModbusServer', () => { @@ -64,6 +161,7 @@ describe('ModbusServer', () => { beforeEach(() => { vi.useFakeTimers() portAvailableResults = [] + bindResults = [] vi.mocked(ServerTCP).mockClear() vi.mocked(ServerSerial).mockClear() windows = createMockWindows() @@ -335,7 +433,7 @@ describe('ModbusServer', () => { it('resets all registers occupied by a multi-register type (int32)', async () => { await server.createServer({ uuid, port: 5020 }) - const vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + const vector = lastVector(ServerTCP) // Add int32 at address 0 — occupies addresses 0 and 1 server.addRegister({ @@ -356,11 +454,11 @@ describe('ModbusServer', () => { // Verify both registers are set const cb0 = vi.fn() - await vector.getHoldingRegister!(0, 1, cb0) + await vector.getHoldingRegister(0, 1, cb0) expect(cb0).toHaveBeenCalledWith(null, 1) // high word const cb1 = vi.fn() - await vector.getHoldingRegister!(1, 1, cb1) + await vector.getHoldingRegister(1, 1, cb1) expect(cb1).toHaveBeenCalledWith(null, 4464) // low word // Remove the register @@ -374,17 +472,17 @@ describe('ModbusServer', () => { // Both addresses should be reset to 0 const cb0After = vi.fn() - await vector.getHoldingRegister!(0, 1, cb0After) + await vector.getHoldingRegister(0, 1, cb0After) expect(cb0After).toHaveBeenCalledWith(null, 0) const cb1After = vi.fn() - await vector.getHoldingRegister!(1, 1, cb1After) + await vector.getHoldingRegister(1, 1, cb1After) expect(cb1After).toHaveBeenCalledWith(null, 0) }) it('resets all registers occupied by a 64-bit type (double)', async () => { await server.createServer({ uuid, port: 5020 }) - const vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + const vector = lastVector(ServerTCP) // Add double at address 0 — occupies addresses 0, 1, 2, 3 server.addRegister({ @@ -415,7 +513,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) + await vector.getHoldingRegister(i, 1, cb) expect(cb).toHaveBeenCalledWith(null, 0) } }) @@ -869,7 +967,7 @@ describe('ModbusServer', () => { it('closes existing server before recreating', async () => { await server.createServer({ uuid, port: 5020 }) - const firstInstance = vi.mocked(ServerTCP).mock.results[0].value + const firstInstance = vi.mocked(ServerTCP).mock.results[0]?.value await server.createServer({ uuid, port: 5021 }) expect(firstInstance.close).toHaveBeenCalled() @@ -877,7 +975,7 @@ describe('ModbusServer', () => { it('emits error when closing existing server fails', async () => { await server.createServer({ uuid, port: 5020 }) - const firstInstance = vi.mocked(ServerTCP).mock.results[0].value + const firstInstance = vi.mocked(ServerTCP).mock.results[0]?.value firstInstance.close.mockImplementation((cb: (err: Error | null) => void) => cb(new Error('close error')) ) @@ -887,6 +985,45 @@ describe('ModbusServer', () => { expect(messages.some((m) => m[1].message === 'Error closing server')).toBe(true) }) + it('leaves a listener that is already on the requested port alone', async () => { + await server.createServer({ uuid, port: 5020 }) + const firstInstance = vi.mocked(ServerTCP).mock.results[0]?.value + + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + expect(firstInstance.close).not.toHaveBeenCalled() + }) + + it('binds again on the same port after the TCP servers were stopped', async () => { + await server.createServer({ uuid, port: 5020 }) + await server.stopAllTcpServers() + vi.mocked(ServerTCP).mockClear() + + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + }) + + it('moves on when the bind fails after the probe passed', async () => { + bindResults = ['EADDRINUSE'] + const port = await server.createServer({ uuid, port: 5020 }) + + expect(port).toBe(5021) + // The refused listener is closed rather than kept as if it were up. + expect(vi.mocked(ServerTCP).mock.results[0]?.value.close).toHaveBeenCalled() + }) + + it('moves on when the bind answers with neither event', async () => { + bindResults = [false] + const pending = server.createServer({ uuid, port: 5020 }) + await vi.advanceTimersByTimeAsync(BIND_TIMEOUT_MS) + + expect(await pending).toBe(5021) + }) + it('emits error and returns port when no port available after max attempts', async () => { portAvailableResults = new Array(10000).fill(false) const port = await server.createServer({ uuid, port: 5020 }) @@ -915,7 +1052,7 @@ describe('ModbusServer', () => { vi.mocked(ServerTCP).mockClear() await server.createServer({ uuid, port: 5020 }) // Only the new ServerTCP was created, no close on old - expect(vi.mocked(ServerTCP).mock.results[0].value.close).not.toHaveBeenCalled() + expect(vi.mocked(ServerTCP).mock.results[0]?.value.close).not.toHaveBeenCalled() }) it('emits error when server not found', async () => { @@ -926,7 +1063,7 @@ describe('ModbusServer', () => { it('emits error when close fails', async () => { await server.createServer({ uuid, port: 5020 }) - vi.mocked(ServerTCP).mock.results[0].value.close.mockImplementation( + vi.mocked(ServerTCP).mock.results[0]?.value.close.mockImplementation( (cb: (err: Error | null) => void) => cb(new Error('close error')) ) @@ -983,8 +1120,10 @@ describe('ModbusServer', () => { .filter((c) => c[0] === 'register_value') expect(newCalls.length).toBe(0) - // Server was recreated (ServerTCP called again) - expect(vi.mocked(ServerTCP).mock.calls.length).toBeGreaterThanOrEqual(2) + // The listener is left alone: a reset clears data the vectors read per + // request, and rebinding would drop whoever is connected. + expect(vi.mocked(ServerTCP).mock.calls.length).toBe(1) + expect(vi.mocked(ServerTCP).mock.results[0]?.value.close).not.toHaveBeenCalled() }) it('handles reset when no generators exist', async () => { @@ -1045,6 +1184,36 @@ describe('ModbusServer', () => { expect(messages.some((m) => m[1].message === 'Port 5020 is already in use')).toBe(true) }) + it('puts the server back on its old port when the new bind fails', async () => { + await server.createServer({ uuid, port: 5020 }) + ;(windows.send as ReturnType).mockClear() + vi.mocked(ServerTCP).mockClear() + + // The probe passes and the bind still fails, which is what happens when + // something takes the port between the two. + bindResults = ['EADDRINUSE', true] + const port = await server.setPort({ uuid, port: 5021 }) + + expect(port).toBe(5020) + expect(vi.mocked(ServerTCP).mock.calls[1]?.[1]).toEqual({ host: '0.0.0.0', port: 5020 }) + const messages = getWindowCalls('backend_message') + expect(messages.some((m) => m[1].message === 'Port 5021 is already in use')).toBe(true) + }) + + it('says so when the old port cannot be taken back either', async () => { + await server.createServer({ uuid, port: 5020 }) + ;(windows.send as ReturnType).mockClear() + + bindResults = ['EADDRINUSE', 'EADDRINUSE'] + const port = await server.setPort({ uuid, port: 5021 }) + + expect(port).toBe(5020) + const messages = getWindowCalls('backend_message') + expect( + messages.some((m) => m[1].message === 'The server could not be restarted on port 5020') + ).toBe(true) + }) + it('refuses port 0 and keeps the server where it is', async () => { await server.createServer({ uuid, port: 5020 }) ;(windows.send as ReturnType).mockClear() @@ -1070,7 +1239,7 @@ describe('ModbusServer', () => { it('closes existing server before binding new port', async () => { await server.createServer({ uuid, port: 5020 }) - const firstInstance = vi.mocked(ServerTCP).mock.results[0].value + const firstInstance = vi.mocked(ServerTCP).mock.results[0]?.value await server.setPort({ uuid, port: 5021 }) expect(firstInstance.close).toHaveBeenCalled() @@ -1101,7 +1270,7 @@ describe('ModbusServer', () => { it('emits success message and status on initialized event', async () => { await server.startRtuServer({ uuid, serialConfig }) - const instance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const instance = lastInstance(ServerSerial) instance._handlers['initialized']() const statusCalls = getWindowCalls('rtu_server_status') @@ -1114,7 +1283,7 @@ describe('ModbusServer', () => { it('emits error status on error event', async () => { await server.startRtuServer({ uuid, serialConfig }) - const instance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const instance = lastInstance(ServerSerial) instance._handlers['error'](new Error('port gone')) const statusCalls = getWindowCalls('rtu_server_status') @@ -1135,7 +1304,7 @@ describe('ModbusServer', () => { it('stops existing RTU server before starting new one', async () => { await server.startRtuServer({ uuid, serialConfig }) - const firstInstance = vi.mocked(ServerSerial).mock.results[0].value + const firstInstance = vi.mocked(ServerSerial).mock.results[0]?.value await server.startRtuServer({ uuid, serialConfig }) expect(firstInstance.close).toHaveBeenCalled() @@ -1163,11 +1332,82 @@ describe('ModbusServer', () => { await server.startRtuServer({ uuid, serialConfig }) // The vector passed to ServerSerial should read the same data - const vector = vi.mocked(ServerSerial).mock.calls.at(-1)![0] as IServiceVector + const vector = lastVector(ServerSerial) const cb = vi.fn() - await vector.getHoldingRegister!(0, 1, cb) + await vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) + + it('reports the port closing under a running server', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const instance = lastInstance(ServerSerial) + instance._handlers['initialized']() + ;(windows.send as ReturnType).mockClear() + + fireSerialPathEvent(instance, 'close', new Error('Disconnected')) + + expect(getWindowCalls('rtu_server_status').at(-1)?.[1].active).toBe(false) + const messageCalls = getWindowCalls('backend_message') + expect( + messageCalls.some((c) => c[1].message === 'RTU server disconnected from /dev/ttyUSB0') + ).toBe(true) + }) + + it('stops a disconnected server without claiming it was running', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const instance = lastInstance(ServerSerial) + instance._handlers['initialized']() + fireSerialPathEvent(instance, 'close', new Error('Disconnected')) + ;(windows.send as ReturnType).mockClear() + + await server.stopRtuServer() + + // `stopRtuServer` says 'RTU server stopped' for a server that was up, and + // the close above is what takes it down. + expect(getWindowCalls('backend_message')).toEqual([]) + }) + + it('says nothing about a close it asked for itself', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const instance = lastInstance(ServerSerial) + instance._handlers['initialized']() + + await server.stopRtuServer() + ;(windows.send as ReturnType).mockClear() + fireSerialPathEvent(instance, 'close', undefined) + + expect(getWindowCalls('backend_message')).toEqual([]) + expect(getWindowCalls('rtu_server_status')).toEqual([]) + }) + + it('reports one disconnect when the port both errors and closes', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const instance = lastInstance(ServerSerial) + instance._handlers['initialized']() + ;(windows.send as ReturnType).mockClear() + + // A port unplugged during a write emits both: `_write`'s callback carries + // the error into the stream and `_disconnected` closes it. + fireSerialPathEvent(instance, 'error', new Error('Disconnected')) + fireSerialPathEvent(instance, 'close', new Error('Disconnected')) + + const messageCalls = getWindowCalls('backend_message') + expect(messageCalls.map((c) => c[1].message)).toEqual(['RTU server error: Disconnected']) + }) + + it('leaves the running server alone when an earlier port closes', async () => { + await server.startRtuServer({ uuid, serialConfig }) + const replaced = lastInstance(ServerSerial) + + await server.startRtuServer({ uuid, serialConfig }) + lastInstance(ServerSerial)._handlers['initialized']() + ;(windows.send as ReturnType).mockClear() + + fireSerialPathEvent(replaced, 'close', new Error('Disconnected')) + + expect(getWindowCalls('rtu_server_status')).toEqual([]) + expect(getWindowCalls('backend_message')).toEqual([]) + }) }) describe('stopRtuServer', () => { @@ -1184,18 +1424,18 @@ describe('ModbusServer', () => { it('closes the RTU server and emits inactive status', async () => { await server.startRtuServer({ uuid, serialConfig }) - const instance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const instance = lastInstance(ServerSerial) await server.stopRtuServer() expect(instance.close).toHaveBeenCalled() const statusCalls = getWindowCalls('rtu_server_status') - expect(statusCalls.at(-1)![1].active).toBe(false) + expect(statusCalls.at(-1)?.[1].active).toBe(false) }) it('emits warning message only when server was active', async () => { await server.startRtuServer({ uuid, serialConfig }) - const instance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const instance = lastInstance(ServerSerial) // Simulate initialized → wasActive = true instance._handlers['initialized']() @@ -1218,7 +1458,7 @@ describe('ModbusServer', () => { it('silently ignores "Port is not open" errors', async () => { await server.startRtuServer({ uuid, serialConfig }) - const instance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const instance = lastInstance(ServerSerial) instance.close.mockImplementation((cb: (err: Error | null) => void) => cb(new Error('Port is not open')) ) @@ -1239,11 +1479,11 @@ describe('ModbusServer', () => { // Both servers closed const instances = vi.mocked(ServerTCP).mock.results - expect(instances[0].value.close).toHaveBeenCalled() + expect(instances[0]?.value.close).toHaveBeenCalled() // Recreating should work without close call on old server vi.mocked(ServerTCP).mockClear() await server.createServer({ uuid, port: 5020 }) - expect(vi.mocked(ServerTCP).mock.results[0].value.close).not.toHaveBeenCalled() + expect(vi.mocked(ServerTCP).mock.results[0]?.value.close).not.toHaveBeenCalled() }) it('preserves server data after stopping all TCP servers', async () => { @@ -1276,9 +1516,9 @@ describe('ModbusServer', () => { } }) - const vector = vi.mocked(ServerSerial).mock.calls.at(-1)![0] as IServiceVector + const vector = lastVector(ServerSerial) const cb = vi.fn() - await vector.getHoldingRegister!(0, 1, cb) + await vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) @@ -1299,7 +1539,7 @@ describe('ModbusServer', () => { } }) - const rtuInstance = vi.mocked(ServerSerial).mock.results.at(-1)!.value + const rtuInstance = lastInstance(ServerSerial) await server.deleteServer(uuid) expect(rtuInstance.close).toHaveBeenCalled() @@ -1307,42 +1547,42 @@ describe('ModbusServer', () => { }) describe('vector methods', () => { - let vector: IServiceVector + let vector: ServerVector beforeEach(async () => { await server.createServer({ uuid, port: 5020 }) - vector = vi.mocked(ServerTCP).mock.calls.at(-1)![0] + vector = lastVector(ServerTCP) }) describe('getCoil', () => { it('returns coil value for valid address and unitId', async () => { server.setBool({ uuid, unitId, registerType: 'coils', address: 5, state: true }) const cb = vi.fn() - await vector.getCoil!(5, 1, cb) + await vector.getCoil(5, 1, cb) expect(cb).toHaveBeenCalledWith(null, true) }) it('returns false for unset coil address', async () => { server.setBool({ uuid, unitId, registerType: 'coils', address: 0, state: false }) const cb = vi.fn() - await vector.getCoil!(0, 1, cb) + await vector.getCoil(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, false) }) it('returns error for invalid unitId (>255)', async () => { const cb = vi.fn() - await vector.getCoil!(0, 300, cb) + await vector.getCoil(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), false ) }) - it('returns error when no data exists for unitId', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() - await vector.getCoil!(0, 1, cb) + await vector.getCoil(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false ) }) @@ -1352,24 +1592,24 @@ describe('ModbusServer', () => { it('returns discrete input value', async () => { server.setBool({ uuid, unitId, registerType: 'discrete_inputs', address: 3, state: true }) const cb = vi.fn() - await vector.getDiscreteInput!(3, 1, cb) + await vector.getDiscreteInput(3, 1, cb) expect(cb).toHaveBeenCalledWith(null, true) }) it('returns error for invalid unitId', async () => { const cb = vi.fn() - await vector.getDiscreteInput!(0, 300, cb) + await vector.getDiscreteInput(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), false ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() - await vector.getDiscreteInput!(0, 1, cb) + await vector.getDiscreteInput(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), false ) }) @@ -1394,24 +1634,24 @@ describe('ModbusServer', () => { } }) const cb = vi.fn() - await vector.getInputRegister!(10, 1, cb) + await vector.getInputRegister(10, 1, cb) expect(cb).toHaveBeenCalledWith(null, 42) }) it('returns error for invalid unitId', async () => { const cb = vi.fn() - await vector.getInputRegister!(0, 300, cb) + await vector.getInputRegister(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() - await vector.getInputRegister!(0, 1, cb) + await vector.getInputRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 ) }) @@ -1436,24 +1676,24 @@ describe('ModbusServer', () => { } }) const cb = vi.fn() - await vector.getHoldingRegister!(0, 1, cb) + await vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith(null, 999) }) it('returns error for invalid unitId', async () => { const cb = vi.fn() - await vector.getHoldingRegister!(0, 300, cb) + await vector.getHoldingRegister(0, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 ) }) - it('returns error when no data exists', async () => { + it('refuses a unit id it does not host', async () => { const cb = vi.fn() - await vector.getHoldingRegister!(0, 1, cb) + await vector.getHoldingRegister(0, 1, cb) expect(cb).toHaveBeenCalledWith( - expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), 0 ) }) @@ -1466,7 +1706,7 @@ describe('ModbusServer', () => { ;(windows.send as ReturnType).mockClear() const cb = vi.fn() - await vector.setCoil!(10, true, 1, cb) + await vector.setCoil(10, true, 1, cb) expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( 'boolean_value', @@ -1474,20 +1714,26 @@ describe('ModbusServer', () => { ) }) - it('creates default data when unitId has no existing data', async () => { + it('refuses a unit id it does not host and leaves it unhosted', async () => { const cb = vi.fn() - await vector.setCoil!(5, true, 1, cb) - expect(cb).toHaveBeenCalledWith(null) + await vector.setCoil(5, true, 1, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) - // Verify the coil was set by reading it back + // The write must not have created the unit it was refused for. const getCb = vi.fn() - await vector.getCoil!(5, 1, getCb) - expect(getCb).toHaveBeenCalledWith(null, true) + await vector.getCoil(5, 1, getCb) + expect(getCb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + false + ) }) it('returns error for invalid unitId', async () => { const cb = vi.fn() - await vector.setCoil!(0, true, 300, cb) + await vector.setCoil(0, true, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 @@ -1517,7 +1763,7 @@ describe('ModbusServer', () => { ;(windows.send as ReturnType).mockClear() const cb = vi.fn() - await vector.setRegister!(20, 12345, 1, cb) + await vector.setRegister(20, 12345, 1, cb) expect(cb).toHaveBeenCalledWith(null) expect(windows.send).toHaveBeenCalledWith( 'register_value', @@ -1531,20 +1777,26 @@ describe('ModbusServer', () => { ) }) - it('creates default data when unitId has no existing data', async () => { + it('refuses a unit id it does not host and leaves it unhosted', async () => { const cb = vi.fn() - await vector.setRegister!(0, 500, 1, cb) - expect(cb).toHaveBeenCalledWith(null) + await vector.setRegister(0, 500, 1, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) - // Verify it was set + // The write must not have created the unit it was refused for. const getCb = vi.fn() - await vector.getHoldingRegister!(0, 1, getCb) - expect(getCb).toHaveBeenCalledWith(null, 500) + await vector.getHoldingRegister(0, 1, getCb) + expect(getCb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) }) it('returns error for invalid unitId', async () => { const cb = vi.fn() - await vector.setRegister!(0, 100, 300, cb) + await vector.setRegister(0, 100, 300, cb) expect(cb).toHaveBeenCalledWith( expect.objectContaining({ modbusErrorCode: SERVER_DEVICE_FAILURE }), 0 @@ -1552,4 +1804,335 @@ describe('ModbusServer', () => { }) }) }) + // ─── C5: which unit ids the server answers for ──────────────────────────── + + describe('the unit ids a server answers for', () => { + const serialConfig = { + com: '/dev/ttyUSB0', + options: { baudRate: '9600' as const, dataBits: 8, stopBits: 1, parity: 'none' as const } + } + + const hostUnit = (id: UnitIdString, address: number, value: number): void => + server.addRegister({ + uuid, + unitId: id, + littleEndian: false, + params: { + address, + registerType: 'holding_registers', + dataType: 'uint16', + comment: '', + value, + min: undefined, + max: undefined, + interval: undefined + } + }) + + const tcpVector = async (): Promise => { + await server.createServer({ uuid, port: 5020 }) + return lastVector(ServerTCP) + } + + const rtuVector = async (): Promise => { + await server.startRtuServer({ uuid, serialConfig }) + lastInstance(ServerSerial)._handlers['initialized']() + return lastVector(ServerSerial) + } + + describe('over TCP', () => { + it('answers for a unit it hosts', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 42) + }) + + it('refuses a unit it does not host, because silence would be a timeout', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 5, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) + }) + + it('treats unit 0 as an address like any other', async () => { + hostUnit('0', 0, 7) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 0, cb) + expect(cb).toHaveBeenCalledWith(null, 7) + }) + + it('sends a write to unit 0 to unit 0 alone', async () => { + hostUnit('0', 0, 7) + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const setCb = vi.fn() + await vector.setRegister(9, 111, 0, setCb) + expect(setCb).toHaveBeenCalledWith(null) + + const zeroCb = vi.fn() + await vector.getHoldingRegister(9, 0, zeroCb) + expect(zeroCb).toHaveBeenCalledWith(null, 111) + + const oneCb = vi.fn() + await vector.getHoldingRegister(9, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, 0) + }) + + it('answers an address past the top of the range with an address error', async () => { + hostUnit('1', 0, 42) + const vector = await tcpVector() + + const cb = vi.fn() + await vector.getHoldingRegister(70000, 1, cb) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: ILLEGAL_DATA_ADDRESS }), + 0 + ) + }) + }) + + describe('over RTU', () => { + it('answers for a unit it hosts', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 1, cb) + expect(cb).toHaveBeenCalledWith(null, 42) + }) + + it('says nothing for a unit it does not host, because the bus is shared', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 5, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('says nothing for a coil on a unit it does not host', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getCoil(0, 5, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('never reads unit 0, even when it holds data', async () => { + hostUnit('0', 0, 7) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.getHoldingRegister(0, 0, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('sends a write to unit 0 to every unit it hosts', async () => { + hostUnit('1', 0, 42) + hostUnit('2', 0, 43) + const vector = await rtuVector() + + await vector.setRegister(9, 4242, 0, vi.fn()) + + const oneCb = vi.fn() + await vector.getHoldingRegister(9, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, 4242) + + const twoCb = vi.fn() + await vector.getHoldingRegister(9, 2, twoCb) + expect(twoCb).toHaveBeenCalledWith(null, 4242) + }) + + it('does not acknowledge a write to unit 0', async () => { + hostUnit('1', 0, 42) + const vector = await rtuVector() + + const cb = vi.fn() + await vector.setRegister(9, 4242, 0, cb) + expect(cb).not.toHaveBeenCalled() + }) + + it('sends a coil write to unit 0 to every unit it hosts', async () => { + hostUnit('1', 0, 42) + hostUnit('2', 0, 43) + const vector = await rtuVector() + + await vector.setCoil(3, true, 0, vi.fn()) + + const oneCb = vi.fn() + await vector.getCoil(3, 1, oneCb) + expect(oneCb).toHaveBeenCalledWith(null, true) + + const twoCb = vi.fn() + await vector.getCoil(3, 2, twoCb) + expect(twoCb).toHaveBeenCalledWith(null, true) + }) + + it('creates nothing from a write to a unit it does not host', async () => { + hostUnit('1', 0, 42) + const rtu = await rtuVector() + const tcp = await tcpVector() + + await 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) + expect(cb).toHaveBeenCalledWith( + expect.objectContaining({ modbusErrorCode: GATEWAY_TARGET_FAILED }), + 0 + ) + }) + }) + + describe('the unit 0 warning', () => { + const warning = 'Unit 0 is the broadcast address on RTU. Its registers cannot be read.' + + const warnings = (): unknown[] => + getWindowCalls('backend_message').filter((c) => c[1].message === warning) + + it('warns when the port comes up on a config that already uses unit 0', async () => { + hostUnit('0', 0, 7) + await rtuVector() + + expect(warnings().length).toBe(1) + }) + + it('warns when unit 0 arrives after the port came up', async () => { + await rtuVector() + hostUnit('0', 0, 7) + + expect(warnings().length).toBe(1) + }) + + it('says nothing when unit 0 holds no data', async () => { + hostUnit('1', 0, 42) + await rtuVector() + + expect(warnings().length).toBe(0) + }) + + it('says it once', async () => { + hostUnit('0', 0, 7) + await rtuVector() + hostUnit('0', 1, 8) + hostUnit('0', 2, 9) + + expect(warnings().length).toBe(1) + }) + + it('says nothing on TCP', async () => { + hostUnit('0', 0, 7) + await tcpVector() + + expect(warnings().length).toBe(0) + }) + }) + }) + // ─── C1: removing a register erases what it occupied, and no more ───────── + + describe('removeRegister erases what the register occupied', () => { + const addRegister = ( + address: number, + dataType: BaseDataType, + extra: { value?: number; stringValue?: string; length?: number } = {} + ): void => { + const params: RegisterParams = { + address, + registerType: 'holding_registers', + dataType, + comment: '', + value: extra.value ?? 0, + stringValue: extra.stringValue, + length: extra.length, + min: undefined, + max: undefined, + interval: undefined + } + server.addRegister({ uuid, unitId, littleEndian: false, params }) + } + + const readHolding = async (vector: ServerVector, address: number): Promise => + new Promise((resolve) => + vector.getHoldingRegister(address, 1, (error, value) => resolve(error ? 'ERR' : value)) + ) + + it('leaves the register next to a deleted string alone', async () => { + // The width comes from the register, not from the type. + addRegister(20, 'double', { value: 1234.5 }) + addRegister(18, 'utf8', { stringValue: 'HAHA', length: 2 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = lastVector(ServerTCP) + + const before = await readHolding(vector, 20) + expect(before).not.toBe(0) + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 18, + dataType: 'utf8', + length: 2 + }) + + expect(await readHolding(vector, 18)).toBe(0) + expect(await readHolding(vector, 19)).toBe(0) + expect(await readHolding(vector, 20)).toBe(before) + }) + + it('erases every register a wide type occupied', async () => { + addRegister(30, 'double', { value: 1234.5 }) + addRegister(40, 'uint16', { value: 7 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = lastVector(ServerTCP) + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 30, + dataType: 'double' + }) + + for (const address of [30, 31, 32, 33]) { + expect(await readHolding(vector, address)).toBe(0) + } + expect(await readHolding(vector, 40)).toBe(7) + }) + + it('falls back to ten registers for a string that carries no length', async () => { + addRegister(0, 'utf8', { stringValue: 'HAHA' }) + addRegister(10, 'uint16', { value: 7 }) + + await server.createServer({ uuid, port: 5020 }) + const vector = lastVector(ServerTCP) + + server.removeRegister({ + uuid, + unitId, + registerType: 'holding_registers', + address: 0, + dataType: 'utf8' + }) + + expect(await readHolding(vector, 0)).toBe(0) + expect(await readHolding(vector, 9)).toBe(0) + expect(await readHolding(vector, 10)).toBe(7) + }) + }) }) diff --git a/src/main/modules/__tests__/privilegedPort.test.ts b/src/main/modules/__tests__/privilegedPort.test.ts index 78b9b6d..859648e 100644 --- a/src/main/modules/__tests__/privilegedPort.test.ts +++ b/src/main/modules/__tests__/privilegedPort.test.ts @@ -229,13 +229,13 @@ describe('applyPrivilegedPortFix', () => { expect(result.ok).toBe(true) expect(result.unprivilegedPortStart).toBe(502) - expect(execFileCalls[0].file).toBe('/usr/bin/pkexec') - expect(execFileCalls[0].args).toEqual(privilegedPortCommandArgs('session')) + expect(execFileCalls[0]?.file).toBe('/usr/bin/pkexec') + expect(execFileCalls[0]?.args).toEqual(privilegedPortCommandArgs('session')) }) it('runs the persist command when asked to', async () => { await applyPrivilegedPortFix('persist') - expect(execFileCalls[0].args).toEqual(privilegedPortCommandArgs('persist')) + expect(execFileCalls[0]?.args).toEqual(privilegedPortCommandArgs('persist')) }) it('says so when the user dismisses the PolicyKit prompt', async () => { diff --git a/src/main/modules/__tests__/serialGroup.test.ts b/src/main/modules/__tests__/serialGroup.test.ts index 8642c8f..1538dd9 100644 --- a/src/main/modules/__tests__/serialGroup.test.ts +++ b/src/main/modules/__tests__/serialGroup.test.ts @@ -11,15 +11,15 @@ let existingPaths: string[] = ['/usr/bin/pkexec'] let execExitCode = 0 const execFileCalls: { file: string; args: readonly string[] }[] = [] -// The groups this "session" holds, as gids. +// The groups this "session" holds, as group ids. let sessionGroups: number[] = [] // What /dev holds, and which of those entries refuse to open. let devEntries: string[] = ['ttyUSB0'] let unreadable: string[] = ['ttyUSB0'] -// The gid that owns each device node, which is where the group name comes from. -let deviceGid = 20 +// The group id that owns each device node, which is where the group name comes from. +let deviceGroupId = 20 vi.mock('fs/promises', () => ({ readFile: vi.fn(async () => { @@ -30,7 +30,7 @@ vi.mock('fs/promises', () => ({ access: vi.fn(async (path: string) => { if (unreadable.some((name) => path.endsWith(name))) throw new Error('EACCES') }), - stat: vi.fn(async () => ({ gid: deviceGid })) + stat: vi.fn(async () => ({ gid: deviceGroupId })) })) vi.mock('fs', () => ({ @@ -78,7 +78,7 @@ beforeEach(() => { sessionGroups = [] devEntries = ['ttyUSB0'] unreadable = ['ttyUSB0'] - deviceGid = 20 + deviceGroupId = 20 setPlatform('linux') process.getgroups = () => sessionGroups delete process.env.FLATPAK_ID @@ -91,14 +91,14 @@ afterEach(() => { }) describe('readGroupEntry', () => { - it('reads the gid and the members', async () => { + it('reads the group id and the members', async () => { groupFile = 'root:x:0:\ndialout:x:20:alice,bob\n' - expect(await readGroupEntry('dialout')).toEqual({ gid: 20, members: ['alice', 'bob'] }) + expect(await readGroupEntry('dialout')).toEqual({ groupId: 20, members: ['alice', 'bob'] }) }) it('reports no members rather than an empty name', async () => { groupFile = 'dialout:x:20:\n' - expect(await readGroupEntry('dialout')).toEqual({ gid: 20, members: [] }) + expect(await readGroupEntry('dialout')).toEqual({ groupId: 20, members: [] }) }) it('returns undefined for a group that is not there', async () => { @@ -109,6 +109,13 @@ describe('readGroupEntry', () => { groupFile = new Error('ENOENT') expect(await readGroupEntry('dialout')).toBeUndefined() }) + + it('skips a line with the name but no group id field', async () => { + // Without the field the parse would have read `undefined` as the group id and + // answered NaN, which the caller cannot tell from a real group. + groupFile = 'dialout:x\ndialout:x:20:alice\n' + expect(await readGroupEntry('dialout')).toEqual({ groupId: 20, members: ['alice'] }) + }) }) describe('findUnreadablePorts', () => { @@ -192,7 +199,7 @@ describe('getSerialGroupStatus', () => { it('names the group the device belongs to, not an assumed dialout', async () => { // A distribution that calls it uucp, with no dialout anywhere in sight. groupFile = 'root:x:0:\nuucp:x:14:someone\n' - deviceGid = 14 + deviceGroupId = 14 const status = await getSerialGroupStatus() @@ -202,7 +209,7 @@ describe('getSerialGroupStatus', () => { it('says nothing when the session holds the group the device belongs to', async () => { groupFile = 'root:x:0:\nuucp:x:14:jens\n' - deviceGid = 14 + deviceGroupId = 14 sessionGroups = [14] const status = await getSerialGroupStatus() @@ -211,9 +218,9 @@ describe('getSerialGroupStatus', () => { expect(status.pendingLogin).toBe(false) }) - it('advises nothing when the gid that owns the device has no name', async () => { + it('advises nothing when the groupId that owns the device has no name', async () => { groupFile = 'root:x:0:\ndialout:x:20:someone\n' - deviceGid = 999 + deviceGroupId = 999 const status = await getSerialGroupStatus() @@ -255,11 +262,11 @@ describe('applySerialGroupFix', () => { it('adds the user to the group the device belongs to', async () => { groupFile = 'root:x:0:\nuucp:x:14:jens\n' - deviceGid = 14 + deviceGroupId = 14 const result = await applySerialGroupFix() - expect(execFileCalls[0].args).toEqual(['usermod', '-aG', 'uucp', 'jens']) + expect(execFileCalls[0]?.args).toEqual(['usermod', '-aG', 'uucp', 'jens']) expect(result.ok).toBe(true) expect(result.message).toContain('uucp') }) diff --git a/src/main/modules/__tests__/valueGenerator.test.ts b/src/main/modules/__tests__/valueGenerator.test.ts index f6cdf21..4c70661 100644 --- a/src/main/modules/__tests__/valueGenerator.test.ts +++ b/src/main/modules/__tests__/valueGenerator.test.ts @@ -195,8 +195,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) - buf.writeUInt16BE(serverData.holding_registers[0], 0) - buf.writeUInt16BE(serverData.holding_registers[1], 2) + serverData.holding_registers + .slice(0, 2) + .forEach((register, i) => buf.writeUInt16BE(register, i * 2)) const readBack = buf.readFloatBE(0) expect(readBack).toBeCloseTo(33.33, 1) diff --git a/src/main/modules/lillteBigEndian.md b/src/main/modules/littleBigEndian.md similarity index 100% rename from src/main/modules/lillteBigEndian.md rename to src/main/modules/littleBigEndian.md diff --git a/src/main/modules/modbusClient.ts b/src/main/modules/modbusClient.ts index 2b9a855..442e434 100644 --- a/src/main/modules/modbusClient.ts +++ b/src/main/modules/modbusClient.ts @@ -68,7 +68,7 @@ export class ModbusClient { private _clientState: ClientState = { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } @@ -92,6 +92,17 @@ export class ModbusClient { this._appState = appState this._windows = windows + this._attachClientHandlers() + } + + /** + * Register the handlers that carry connection errors and auto-reconnect. + * + * These live on the `ModbusRTU` object rather than on the port, so a client + * that gets replaced comes back without them. Every site that assigns + * `this._client` calls this. + */ + private _attachClientHandlers = (): void => { this._client .on('error', (error) => { this._clientState.connectState = 'disconnected' @@ -105,7 +116,7 @@ export class ModbusClient { .on('close', () => { // If we were connected, go to 'connecting' and try to reconnect if (this._shouldAutoReconnect) { - // Remeber polling state before trying to reconnect + // Remember polling state before trying to reconnect this._reconnectWasPolling = this._clientState.polling // Only emit reconnecting message if not already in connecting state @@ -328,6 +339,7 @@ export class ModbusClient { this._emitMessage({ message, variant: 'warning', error: null }) resolve() this._client = new ModbusRTU() + this._attachClientHandlers() }) }, 5000) @@ -377,7 +389,6 @@ export class ModbusClient { this._client.setID(unitId) this._client.setTimeout(this._appState.registerConfig.timeout) - let errorMessage: string | undefined const data: RegisterData[] = [] const { type, address, length } = this._appState.registerConfig @@ -387,12 +398,14 @@ export class ModbusClient { : [] const groups = configGroups.length > 0 ? configGroups : ([[address, length]] as AddressGroup[]) - for (let gi = 0; gi < groups.length; gi++) { - const [a, l] = groups[gi] + for (const [groupIndex, [groupAddress, groupLength]] of groups.entries()) { + // Per group: `_logTransaction` 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 try { - const rows = await this._tryRead(type, a, l) - rows.forEach((r) => { - r.groupIndex = gi + const rows = await this._tryRead(type, groupAddress, groupLength) + rows.forEach((row) => { + row.groupIndex = groupIndex }) data.push(...rows) } catch (error) { @@ -404,29 +417,29 @@ export class ModbusClient { const mapping = this._appState.registerMapping?.[type] if (mapping) { for (const [addressKey, mapValue] of Object.entries(mapping)) { - const address = Number(addressKey) + const mappedAddress = Number(addressKey) if ( - address >= a && - address < a + l && + mappedAddress >= groupAddress && + mappedAddress < groupAddress + groupLength && mapValue?.dataType && mapValue.dataType !== 'none' ) { data.push({ - id: address, + id: mappedAddress, buffer: new Uint8Array(2), hex: '0000', words: undefined, bit: false, isScanned: false, error: errorMessage, - groupIndex: gi + groupIndex }) } } } } else { this._emitMessage({ - message: `${errorMessage} [addr:${a}, len:${l}, id:${this._appState.connectionConfig.unitId}]`, + message: `${errorMessage} [addr:${groupAddress}, len:${groupLength}, id:${this._appState.connectionConfig.unitId}]`, variant: 'error', error }) @@ -453,8 +466,8 @@ export class ModbusClient { // 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. Clearing - // it is ours to do, or a session grows one entry per request. + // 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. @@ -472,12 +485,14 @@ export class ModbusClient { const lastTransaction = rawTransactions.at(-1) if (!lastTransaction) return - // Clear the transactions so we don't log a transaction twice - // For example when encountering an error we would log the same last transaction again - this._client['_transactions'] = {} - const [transactionIdKey, rawTransaction] = lastTransaction + // 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 this._client['_transactions'][transactionIdKey] + const transaction: Transaction = { id: `${transactionIdKey}__${v4()}`, timestamp: DateTime.now().toMillis(), @@ -627,9 +642,21 @@ export class ModbusClient { try { if (single) { + // FC5 writes the first coil of the list, and the schema accepts an + // empty one, which would put `undefined` on the wire. + const [first] = value + if (first === undefined) { + this._emitMessage({ + message: 'No coil value to write', + variant: 'warning', + error: undefined + }) + return + } + // Wrtie single coil await new Promise((resolve, reject) => - this._client.writeFC5(unitId, address, value[0], (err, data) => { + this._client.writeFC5(unitId, address, first, (err, data) => { if (err) { reject(err) return @@ -667,7 +694,7 @@ export class ModbusClient { if (single && !['int16', 'uint16'].includes(dataType)) { this._emitMessage({ - message: 'Single register only supported fot 16 bit values', + message: 'Single register only supported for 16 bit values', variant: 'warning', error: undefined }) @@ -724,7 +751,7 @@ export class ModbusClient { } this._client.setTimeout(params.timeout) - this._clientState.scanningUniId = true + this._clientState.scanningUnitIds = true this._sendClientState() const { range } = params @@ -734,14 +761,14 @@ export class ModbusClient { for (let id = range[0]; id <= range[1]; id++) await this._scanUnitIds({ id, ...params }) - this._clientState.scanningUniId = false + this._clientState.scanningUnitIds = false this._sendClientState() } public stopScanningUnitIds = (): void => { // Set scanning unit id to false so the scanning is stopped // after the last asynchonous operation has completed. - this._clientState.scanningUniId = false + this._clientState.scanningUnitIds = false } private _scanUnitIds: ScanUnitIdFn = async ({ address, id, length, registerTypes }) => { @@ -760,7 +787,7 @@ export class ModbusClient { } } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -777,7 +804,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -793,7 +820,7 @@ export class ModbusClient { } await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -810,7 +837,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -827,7 +854,7 @@ export class ModbusClient { await this._sendScanProgress() } - if (!this._clientState.scanningUniId) { + if (!this._clientState.scanningUnitIds) { this._sendClientState() return } @@ -893,8 +920,8 @@ export class ModbusClient { this._logTransaction(errorMessage) if (!data) return - data = data.filter((d) => - ['coils', 'discrete_inputs'].includes(type) ? d.bit : d.hex !== '0000' + data = data.filter((row) => + ['coils', 'discrete_inputs'].includes(type) ? row.bit : row.hex !== '0000' ) this._sendData(data) } @@ -909,9 +936,9 @@ export class ModbusClient { public listSerialPorts = async (): Promise<{ path: string; manufacturer?: string }[]> => { try { const ports = await ModbusRTU.getPorts() - return ports.map((p) => ({ - path: p.path, - manufacturer: p.manufacturer ?? undefined + return ports.map((port) => ({ + path: port.path, + manufacturer: port.manufacturer ?? undefined })) } catch (error) { const message = humanizeSerialError(error as Error) @@ -925,7 +952,7 @@ export class ModbusClient { ): Promise<{ valid: boolean; message: string }> => { try { const ports = await ModbusRTU.getPorts() - const found = ports.some((p) => p.path.toLowerCase() === portPath.toLowerCase()) + const found = ports.some((port) => port.path.toLowerCase() === portPath.toLowerCase()) return { valid: found, message: found diff --git a/src/main/modules/mobusServer.ts b/src/main/modules/modbusServer.ts similarity index 62% rename from src/main/modules/mobusServer.ts rename to src/main/modules/modbusServer.ts index 4567236..fa5a2c7 100644 --- a/src/main/modules/mobusServer.ts +++ b/src/main/modules/modbusServer.ts @@ -21,7 +21,7 @@ import { ServerTCP, ServerSerial } from 'modbus-serial' import { Windows } from '@shared' import { ValueGenerator } from './modbusServer/valueGenerator' import type { IServiceVector, FCallbackVal } from 'modbus-serial' -import { getRegisterLength } from '@shared' +import { DEFAULT_UTF8_LENGTH, registerWidth } from '@shared' import net from 'net' const getDefaultGenerators = (): ValueGenerators => ({ @@ -53,10 +53,29 @@ export const GATEWAY_PATH_UNAVAILABLE = 10 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 type ValueGeneratorsUnitMap = Map @@ -77,6 +96,7 @@ export class ModbusServer { private _rtuServer: ServerSerial | null = null private _rtuUuid: string | null = null private _rtuActive: boolean = false + private _broadcastWarningSent: boolean = false private _windows: Windows // Map to store server data for each unit ID of a server UUID @@ -107,24 +127,76 @@ export class ModbusServer { } /** - * Returns a Modbus service vector for a given server UUID. + * 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): IServiceVector => ({ - getCoil: this._getCoil(uuid), - getDiscreteInput: this._getDiscreteInput(uuid), - getInputRegister: this._getInputRegister(uuid), - getHoldingRegister: this._getHoldingRegister(uuid), - setCoil: this._setCoil(uuid), - setRegister: this._setHoldingRegister(uuid) + private _getVector = (uuid: string, transport: ServerTransport): IServiceVector => ({ + getCoil: this._getCoil(uuid, transport), + getDiscreteInput: this._getDiscreteInput(uuid, transport), + getInputRegister: this._getInputRegister(uuid, transport), + getHoldingRegister: this._getHoldingRegister(uuid, transport), + setCoil: this._setCoil(uuid, transport), + setRegister: this._setHoldingRegister(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(transport: ServerTransport, cb: FCallbackVal, 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' + }) + } + /** * 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) } /** @@ -173,50 +245,97 @@ export class ModbusServer { } /** - * Creates or recreates a Modbus TCP server for the given UUID and port. - * If a server already exists, it is closed and replaced. - * Also ensures value generator maps are initialized for all unitIds. + * 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. + */ + private async _closeAndForget(uuid: string): Promise { + const existingServer = this._servers.get(uuid) + if (!existingServer) return + await new Promise((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 }) + + // // !Debug: Simulate connection loss by destroying incoming sockets after a delay. + // // - Short delay (e.g. 3000ms): triggers burst detection (reconnects fail within the 10s stability window) + // // - Long delay (e.g. 15000ms): allows stable connection, so the reconnect counter resets between drops + // const netServer = server['_server'] as net.Server + // netServer.on('connection', (sock) => { + // setTimeout(() => sock.destroy(), 15000) + // }) + + 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 `_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 => { // 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 - let server: ServerTCP | undefined - const existingServer = this._servers.get(uuid) - if (existingServer) { - await new Promise((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) - } + 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) { - server = new ServerTCP(this._getVector(uuid), { - host: '0.0.0.0', - port: actualPort - }) - - // // !Debug: Simulate connection loss by destroying incoming sockets after a delay. - // // - Short delay (e.g. 3000ms): triggers burst detection (reconnects fail within the 10s stability window) - // // - Long delay (e.g. 15000ms): allows stable connection, so the reconnect counter resets between drops - // const netServer = server['_server'] as net.Server - // netServer.on('connection', (sock) => { - // setTimeout(() => sock.destroy(), 15000) - // }) - - this._servers.set(uuid, server) - this._port.set(uuid, actualPort) - return actualPort + const bind = await this._bindServer(uuid, actualPort) + if (bind.ok) return actualPort } actualPort++ } @@ -237,20 +356,11 @@ export class ModbusServer { await this.stopRtuServer() } - const server = this._servers.get(uuid) - if (!server) { + if (!this._servers.has(uuid)) { this._emitMessage({ message: `No server found for UUID ${uuid}`, variant: 'error' }) return } - await new Promise((resolve) => { - server.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) - }) - this._servers.delete(uuid) - this._port.delete(uuid) + await this._closeAndForget(uuid) const unitIdGenerators = this._generatorMap.get(uuid) if (unitIdGenerators) { this._disposeAllGenerators(unitIdGenerators) @@ -259,8 +369,12 @@ export class ModbusServer { } /** - * Resets the server for a given UUID. - * Disposes all value generators, clears server data, and recreates the server. + * 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 => { const unitIdGenerators = this._generatorMap.get(uuid) @@ -311,14 +425,14 @@ export class ModbusServer { // Ensure server data map for this server and unitId const perUnitMap = this._ensureInnerMap(this._serverData, uuid) const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() - if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) + this._setServerData(uuid, unitId, serverData) // If a fixed value is provided, set the register directly const fixedValue = !interval && value !== undefined if (fixedValue) { const registers = dataType === 'utf8' - ? createStringRegisters(stringValue ?? '', length ?? 10) + ? createStringRegisters(stringValue ?? '', length ?? DEFAULT_UTF8_LENGTH) : createRegisters(dataType, value, littleEndian) registers.forEach((register, index) => { const registerAddress = address + index @@ -366,14 +480,15 @@ export class ModbusServer { unitId, registerType, address, - dataType + dataType, + length }: RemoveRegisterParams): void => { const perUnitMap = this._ensureInnerMap(this._serverData, uuid) const serverData = perUnitMap.get(unitId) ?? getDefaultServerData() if (!perUnitMap.has(unitId)) perUnitMap.set(unitId, serverData) // Reset all registers occupied by this data type - const registerCount = getRegisterLength(dataType, address) + const registerCount = registerWidth(dataType, length) for (let i = 0; i < registerCount; i++) { serverData[registerType][address + i] = 0 } @@ -486,9 +601,13 @@ export class ModbusServer { public startRtuServer = async ({ uuid, serialConfig }: StartRtuServerParams): Promise => { if (!serialConfig.com.trim()) return await this.stopRtuServer() + this._broadcastWarningSent = false try { - this._rtuServer = new ServerSerial(this._getVector(uuid), { + // 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(this._getVector(uuid, 'rtu'), { path: serialConfig.com, baudRate: Number(serialConfig.options.baudRate), dataBits: serialConfig.options.dataBits as 8 | 7 | 6 | 5, @@ -497,9 +616,11 @@ export class ModbusServer { }) this._rtuUuid = uuid - // Catch open errors on _serverPath (SerialPort) — prevents unhandled promise rejection + // The SerialPort under the server. Its `error` listener catches open + // failures, which would otherwise surface as an unhandled rejection. // eslint-disable-next-line @typescript-eslint/no-explicit-any const serverPath = (this._rtuServer as any)._serverPath + const rtuServer = this._rtuServer if (serverPath && typeof serverPath.on === 'function') { serverPath.on('error', (err: Error) => { this._rtuActive = false @@ -509,6 +630,28 @@ export class ModbusServer { }) this._windows.send('rtu_server_status', { active: false }) }) + + // `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. + serverPath.on('close', (err?: Error) => { + // A close this process caused is already reported. `stopRtuServer` + // clears both fields before it closes the port, and the `error` + // listener above clears `_rtuActive` for the write path, where one + // unplug emits both events. + if (this._rtuServer !== rtuServer || !this._rtuActive) return + this._rtuActive = false + this._emitMessage({ + message: `RTU server disconnected from ${serialConfig.com}`, + variant: 'error', + error: err + }) + this._windows.send('rtu_server_status', { active: false }) + }) } this._rtuServer.on('initialized', () => { @@ -518,6 +661,7 @@ export class ModbusServer { variant: 'success' }) this._windows.send('rtu_server_status', { active: true }) + this._warnBroadcastUnit(uuid) }) this._rtuServer.on('error', (err) => { @@ -546,6 +690,7 @@ export class ModbusServer { this._rtuServer = null this._rtuUuid = null this._rtuActive = false + this._broadcastWarningSent = false this._windows.send('rtu_server_status', { active: false }) if (wasActive) { this._emitMessage({ message: 'RTU server stopped', variant: 'warning' }) @@ -615,26 +760,23 @@ export class ModbusServer { } // Port is confirmed available — now close the existing server - const existingServer = this._servers.get(uuid) - if (existingServer) { - await new Promise((resolve) => { - existingServer.close((err) => { - if (err) - this._emitMessage({ message: 'Error closing server', variant: 'error', error: err }) - resolve() - }) + 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' }) - this._servers.delete(uuid) - this._port.delete(uuid) } - - const server = new ServerTCP(this._getVector(uuid), { - host: '0.0.0.0', - port: requestedPort - }) - this._servers.set(uuid, server) - this._port.set(uuid, requestedPort) - return requestedPort + return currentPort } // ------------------------------------------------------------------------- @@ -645,10 +787,13 @@ export class ModbusServer { * Returns the value of a coil for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getCoil: (uuid: string) => IServiceVector['getCoil'] = - (uuid) => async (address, unitIdNumber, cb) => { + private _getCoil: (uuid: string, transport: ServerTransport) => IServiceVector['getCoil'] = + (uuid, transport) => async (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) + // 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, false) const value = this._serverData.get(uuid)?.get(unitId.data)?.coils[address] if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) @@ -660,10 +805,15 @@ export class ModbusServer { * Returns the value of a discrete input for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getDiscreteInput: (uuid: string) => IServiceVector['getDiscreteInput'] = - (uuid) => async (address, unitIdNumber, cb) => { + private _getDiscreteInput: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getDiscreteInput'] = + (uuid, transport) => async (address, unitIdNumber, cb) => { const unitId = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitId.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, false) + if (this._isBroadcast(transport, unitId.data)) return + if (!this._hostsUnit(uuid, unitId.data)) return this._refuseUnit(transport, cb, false) const value = this._serverData.get(uuid)?.get(unitId.data)?.discrete_inputs[address] if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, false) @@ -675,51 +825,89 @@ export class ModbusServer { * Returns the value of an input register for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getInputRegister: (uuid: string) => IServiceVector['getInputRegister'] = - (uuid) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) - - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.input_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) - - cb(null, value) - } + private _getInputRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getInputRegister'] = (uuid, transport) => async (address, unitId, cb) => { + const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) + if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + if (this._isBroadcast(transport, unitIdSafe.data)) return + if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) + + const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.input_registers[address] + if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + + cb(null, value) + } /** * Returns the value of a holding register for a given address and unitId. * Calls the callback with the value or a Modbus error. */ - private _getHoldingRegister: (uuid: string) => IServiceVector['getHoldingRegister'] = - (uuid) => async (address, unitId, cb) => { - const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) - if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + private _getHoldingRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['getHoldingRegister'] = (uuid, transport) => async (address, unitId, cb) => { + const unitIdSafe = UnitIdStringSchema.safeParse(String(unitId)) + if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) + if (this._isBroadcast(transport, unitIdSafe.data)) return + if (!this._hostsUnit(uuid, unitIdSafe.data)) return this._refuseUnit(transport, cb, 0) + + const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.holding_registers[address] + if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + + cb(null, value) + } - const value = this._serverData.get(uuid)?.get(unitIdSafe.data)?.holding_registers[address] - if (value === undefined) return this._mbError(ILLEGAL_DATA_ADDRESS, cb, 0) + /** + * Writes a coil into a unit this server hosts and tells the view. + */ + private _writeCoil(uuid: string, unitId: UnitIdString, address: number, value: boolean): void { + const serverData = this._serverData.get(uuid)?.get(unitId) + if (!serverData) return + serverData.coils[address] = value - cb(null, value) - } + const registerType: BooleanRegisters = 'coils' + this._windows.send('boolean_value', { uuid, unitId, registerType, address, value }) + } + + /** + * Writes a holding register into a unit this server hosts and tells the view. + */ + private _writeHoldingRegister( + uuid: string, + unitId: UnitIdString, + address: number, + raw: number + ): void { + const serverData = this._serverData.get(uuid)?.get(unitId) + if (!serverData) return + serverData.holding_registers[address] = raw + + const registerType: NumberRegisters = 'holding_registers' + this._windows.send('register_value', { uuid, unitId, registerType, address, raw }) + } /** * Sets the value of a coil for a given address and unitId. * Updates the server data and emits a value change event. */ - private _setCoil: (uuid: string) => IServiceVector['setCoil'] = - (uuid) => async (address, value, unitIdNumber, cb) => { + private _setCoil: (uuid: string, transport: ServerTransport) => IServiceVector['setCoil'] = + (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 - const currentServerData = this._serverData.get(uuid)?.get(unitId) ?? getDefaultServerData() - currentServerData.coils[address] = value - - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - perUnitMap.set(unitId, currentServerData) + // 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._writeCoil(uuid, hostedUnitId, address, value) + return + } - const registerType: BooleanRegisters = 'coils' - this._windows.send('boolean_value', { uuid, unitId, registerType, address, value }) + if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) + this._writeCoil(uuid, unitId, address, value) cb(null) } @@ -727,21 +915,24 @@ export class ModbusServer { * Sets the value of a holding register for a given address and unitId. * Updates the server data and emits a value change event. */ - private _setHoldingRegister: (uuid: string) => IServiceVector['setRegister'] = - (uuid) => async (address, raw, unitIdNumber, cb) => { + private _setHoldingRegister: ( + uuid: string, + transport: ServerTransport + ) => IServiceVector['setRegister'] = + (uuid, transport) => async (address, raw, unitIdNumber, cb) => { const unitIdSafe = UnitIdStringSchema.safeParse(String(unitIdNumber)) if (!unitIdSafe.success) return this._mbError(SERVER_DEVICE_FAILURE, cb, 0) const unitId = unitIdSafe.data - const currentServerData = this._serverData.get(uuid)?.get(unitId) ?? getDefaultServerData() - currentServerData.holding_registers[address] = raw - - const perUnitMap = this._ensureInnerMap(this._serverData, uuid) - perUnitMap.set(unitId, currentServerData) + if (this._isBroadcast(transport, unitId)) { + for (const hostedUnitId of this._serverData.get(uuid)?.keys() ?? []) + this._writeHoldingRegister(uuid, hostedUnitId, address, raw) + return + } - const registerType: NumberRegisters = 'holding_registers' - this._windows.send('register_value', { uuid, unitId, registerType, address, raw }) + if (!this._hostsUnit(uuid, unitId)) return this._refuseUnit(transport, cb, 0) + this._writeHoldingRegister(uuid, unitId, address, raw) cb(null) } diff --git a/src/main/modules/modbusServer/valueGenerator.ts b/src/main/modules/modbusServer/valueGenerator.ts index 98ebd58..a234ecb 100644 --- a/src/main/modules/modbusServer/valueGenerator.ts +++ b/src/main/modules/modbusServer/valueGenerator.ts @@ -7,6 +7,8 @@ import { ServerData, BaseDataType, RegisterParams, + RegisterValueGenerator, + registerWidth, UnitIdString } from '@shared' import { round } from 'lodash' @@ -25,7 +27,7 @@ type ValueGeneratorParams = { * ValueGenerator generates and updates Modbus register values at a set interval. * It supports various data types and updates the server data and notifies the frontend. */ -export class ValueGenerator { +export class ValueGenerator implements RegisterValueGenerator { private _uuid: string private _unitId: UnitIdString private _windows: Windows @@ -88,19 +90,7 @@ export class ValueGenerator { public dispose = (): void => { clearInterval(this._intervalTimer) - // Determine how many addresses to reset based on data type size - let size: number - if (['int16', 'uint16'].includes(this._dataType)) { - size = 1 - } else if (['uint32', 'int32', 'float', 'unix'].includes(this._dataType)) { - size = 2 - } else if (['int64', 'uint64', 'double', 'datetime'].includes(this._dataType)) { - size = 4 - } else if (this._dataType === 'utf8') { - size = this._length - } else { - size = 1 - } + const size = registerWidth(this._dataType, this._length) for (let i = 0; i < size; i++) { this._serverData[this._registerType][this._address + i] = 0 diff --git a/src/main/modules/serialGroup.ts b/src/main/modules/serialGroup.ts index 446f035..90ed55b 100644 --- a/src/main/modules/serialGroup.ts +++ b/src/main/modules/serialGroup.ts @@ -101,7 +101,7 @@ export const findUnreadablePorts = async (): Promise => { } interface GroupEntry { - gid: number + groupId: number members: string[] } @@ -110,10 +110,11 @@ export const readGroupEntry = async (group: string): Promise => { try { const file = await readFile(GROUP_FILE_PATH, 'utf8') for (const line of file.split('\n')) { + // A line with fewer fields than this is not a group entry. const [name, , id, members] = line.split(':') - if (Number.parseInt(id, 10) !== gid) continue - return { name, gid, members: (members ?? '').split(',').filter(Boolean) } + if (name === undefined || id === undefined) continue + if (Number.parseInt(id, 10) !== groupId) continue + return { name, groupId, members: (members ?? '').split(',').filter(Boolean) } } } catch { // Unreadable or not Linux. The caller treats that as nothing to report. @@ -145,13 +148,13 @@ export const readGroupByGid = async ( * * Asking the device rather than assuming `dialout` is the point: the name is a * distribution's choice, and guessing wrong means saying nothing on exactly the - * machines where it matters. Undefined when nothing refuses, or when the gid + * machines where it matters. Undefined when nothing refuses, or when the group id * that owns it has no name -- neither leaves anything useful to say. */ export const blockedPortGroup = async (): Promise<(GroupEntry & { name: string }) | undefined> => { for (const port of await findUnreadablePorts()) { try { - const group = await readGroupByGid((await stat(join(DEV_DIR, port))).gid) + const group = await readGroupById((await stat(join(DEV_DIR, port))).gid) if (group) return group } catch { // Gone between the scan and the stat. Try the next one. @@ -194,7 +197,7 @@ export const getSerialGroupStatus = async (): Promise => { // Holding the group and still being refused means something else is wrong -- // a device mode of 0600, say -- and the group is not the thing to talk about. - const heldNow = process.getgroups?.().includes(group.gid) ?? false + const heldNow = process.getgroups?.().includes(group.groupId) ?? false const listed = group.members.includes(username) return { diff --git a/src/main/state.ts b/src/main/state.ts index a018a37..80279c2 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -8,6 +8,30 @@ import { } from '@shared' import merge from 'deepmerge' +/** + * The same value without the keys whose value is `undefined`. + * + * `deepPartial()` keeps a key the payload set to `undefined` and `deepmerge` + * copies it over the stored one, so one explicit `undefined` leaves the config + * holding a value its own schema refuses. Electron's structured clone carries + * such a key across the IPC hop, so the schema cannot be the thing that stops + * it. + * + * Neither config holds an array today. An array is handed back whole anyway, + * because recursing into one would return its indices as an object and + * deepmerge would never see an array again. + */ +export const withoutUndefined = (value: T): T => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return value + + const kept: Record = {} + for (const [key, item] of Object.entries(value)) { + if (item === undefined) continue + kept[key] = withoutUndefined(item) + } + return kept as T +} + export interface State { connectionConfig: ConnectionConfig registerConfig: RegisterConfig @@ -27,14 +51,14 @@ export class AppState { public updateConnectionConfig(config: DeepPartial): void { this._connectionConfig = merge>( this._connectionConfig, - config + withoutUndefined(config) ) } public updateRegisterConfig(config: DeepPartial): void { this._registerConfig = merge>( this._registerConfig, - config + withoutUndefined(config) ) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 981dc6c..b5558cd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,8 +35,8 @@ type CamelCase = S extends `${infer Head}_${infer Tail}` * This code automatically converts snake_case IPC channels to camelCase methods. * * HOW IT WORKS: - * 1. Takes all channels from IPC_CHANNELS (e.g., 'get_connection_config') - * 2. Converts to camelCase (e.g., 'getConnectionConfig') + * 1. Takes all channels from IPC_CHANNELS (e.g., 'update_register_config') + * 2. Converts to camelCase (e.g., 'updateRegisterConfig') * 3. Creates a method that calls ipcInvoke with the original channel name * 4. Exposes on window.api with full TypeScript support * @@ -51,7 +51,7 @@ type CamelCase = S extends `${infer Head}_${infer Tail}` */ const handlers = Object.fromEntries( (Object.values(IPC_CHANNELS) as Array).map((channelName) => { - // channelName is a string like "get_connection_config" + // channelName is a string like "update_register_config" const methodName = snakeToCamel(channelName) as CamelCase return [ methodName, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d9d9881..a09e9b2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,11 +1,12 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from './context/layout.zustand' import Home from './containers/Home' import Client from './containers/Client' import Server from './containers/Server' import UpdateBanner from './components/UpdateBanner' -const App = (): JSX.Element => { +const App = meme((): JSX.Element => { const appType = useLayoutZustand((z) => z.appType) return ( @@ -24,6 +25,6 @@ const App = (): JSX.Element => { ) -} +}) export default App diff --git a/src/renderer/src/components/UpdateBanner.tsx b/src/renderer/src/components/UpdateBanner.tsx index 93369d8..9559a43 100644 --- a/src/renderer/src/components/UpdateBanner.tsx +++ b/src/renderer/src/components/UpdateBanner.tsx @@ -1,5 +1,10 @@ -import { Alert, AlertTitle, IconButton, Link, Collapse } from '@mui/material' +import Alert from '@mui/material/Alert' +import AlertTitle from '@mui/material/AlertTitle' +import Collapse from '@mui/material/Collapse' +import IconButton from '@mui/material/IconButton' +import Link from '@mui/material/Link' 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 @@ -9,7 +14,7 @@ interface GitHubRelease { html_url: string } -const UpdateBanner = (): JSX.Element | null => { +const UpdateBanner = meme((): JSX.Element | null => { const [showBanner, setShowBanner] = useState(false) const [latestVersion, setLatestVersion] = useState(null) const [releaseUrl, setReleaseUrl] = useState(null) @@ -35,6 +40,9 @@ const UpdateBanner = (): JSX.Element | null => { const release: GitHubRelease = await response.json() const latestTag = release.tag_name.replace(/^v/, '') // Remove 'v' prefix if present + // Asked for directly rather than read off the root store, so the + // banner stays testable on its own. The version cannot change while + // the app runs, so a second read costs nothing. const currentVersion = await window.api.getAppVersion() // Compare versions @@ -113,6 +121,6 @@ const UpdateBanner = (): JSX.Element | null => { ) -} +}) export default UpdateBanner diff --git a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx index e6155cf..875d671 100644 --- a/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx +++ b/src/renderer/src/components/client/ClientGrids/ClientGrids.tsx @@ -1,7 +1,8 @@ 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 { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import RegisterGrid from './RegisterGrid/RegisterGrid' /** @@ -12,10 +13,10 @@ import RegisterGrid from './RegisterGrid/RegisterGrid' * the scan dialog puts it back the old way for anyone who would rather not * watch. */ -const ClientGrids = (): JSX.Element | null => { +const ClientGrids = meme((): JSX.Element | null => { const showLog = useLayoutZustand((z) => z.showLog) const showWhileScanning = useLayoutZustand((z) => z.showGridWhileScanning) - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) if (scanning && !showWhileScanning) return null @@ -36,6 +37,6 @@ const ClientGrids = (): JSX.Element | null => { {showLog && !scanning && } ) -} +}) export default ClientGrids diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx similarity index 93% rename from src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx rename to src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx index 48123f4..80d51e9 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitIndicator/BitIndicator.tsx @@ -1,10 +1,14 @@ -import { SettingsOutlined } from '@mui/icons-material' -import { Box, Paper, TextField, Theme, Typography } from '@mui/material' +import SettingsOutlined from '@mui/icons-material/SettingsOutlined' +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { Theme } from '@mui/material/styles' import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useState } from 'react' import { BitColor } from '@shared' -import BitSettingsPopover from './BitSettingsPopover' +import BitSettingsPopover from '../BitSettingsPopover/BitSettingsPopover' interface BitIndicatorProps { bitIndex: number @@ -121,6 +125,7 @@ const BitIndicator = meme( {/* Comment — inline beside the circle and index */} {editing ? ( z.registerData.find((r) => r.id === address)?.words?.uint16 ?? 0 ) - const bitConfig = useRootZustand((z) => z.registerMapping[z.registerConfig.type][address]?.bitMap) - const setRegisterMapping = useRootZustand((z) => z.setRegisterMapping) + const bitConfig = useClientZustand( + (z) => z.registerMapping[z.registerConfig.type][address]?.bitMap + ) - const registerType = useRootZustand((z) => z.registerConfig.type) + const registerType = useClientZustand((z) => z.registerConfig.type) const writable = registerType === 'holding_registers' - const connectState = useRootZustand((z) => z.clientState.connectState) - const polling = useRootZustand((z) => z.clientState.polling) + const connectState = useClientZustand((z) => z.clientState.connectState) + const polling = useClientZustand((z) => z.clientState.polling) const canWrite = writable && connectState === 'connected' && !polling const handleToggle = useCallback( @@ -51,6 +52,7 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen const updateBitMap = useCallback( (bitIndex: number, patch: Record) => { + const clientZustand = useClientZustand.getState() const current = bitConfig ?? {} const entry = current[String(bitIndex)] ?? {} const updated: BitMapConfig = { @@ -66,9 +68,13 @@ const BitMapDetailPanel = meme(({ address }: BitMapDetailPanelProps): JSX.Elemen // Remove entry entirely if empty if (Object.keys(updatedEntry).length === 0) delete updated[String(bitIndex)] } - setRegisterMapping(address, 'bitMap', Object.keys(updated).length > 0 ? updated : undefined) + clientZustand.setRegisterMapping( + address, + 'bitMap', + Object.keys(updated).length > 0 ? updated : undefined + ) }, - [address, bitConfig, setRegisterMapping] + [address, bitConfig] ) const handleCommentChange = useCallback( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx deleted file mode 100644 index 590d01b..0000000 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Box, Paper, Popover, ToggleButton, ToggleButtonGroup } from '@mui/material' -import { alpha } from '@mui/material/styles' -import { BitColor } from '@shared' - -interface BitSettingsPopoverProps { - anchorEl: HTMLElement | null - onClose: () => void - color: BitColor | undefined - invert: boolean - onColorChange: (color: BitColor | undefined) => void - onInvertChange: (invert: boolean) => void -} - -const COLOR_OPTIONS: { value: BitColor; palette: 'success' | 'warning' | 'error' }[] = [ - { value: 'default', palette: 'success' }, - { value: 'warning', palette: 'warning' }, - { value: 'error', palette: 'error' } -] - -const BitSettingsPopover = ({ - anchorEl, - onClose, - color, - invert, - onColorChange, - onInvertChange -}: BitSettingsPopoverProps): JSX.Element => { - const selected = color ?? 'default' - - return ( - - - {/* Invert toggle */} - - onInvertChange(!invert)} - sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} - > - Invert - - - - {/* Color swatches */} - - {COLOR_OPTIONS.map(({ value, palette }) => { - const isSelected = selected === value - return ( - onColorChange(value === 'default' ? undefined : value)} - sx={(theme) => ({ - width: 16, - height: 16, - borderRadius: '50%', - bgcolor: theme.palette[palette].main, - cursor: 'pointer', - outline: isSelected - ? `2px solid ${theme.palette[palette].main}` - : '2px solid transparent', - outlineOffset: 2, - boxShadow: isSelected - ? `0 0 8px ${alpha(theme.palette[palette].main, 0.5)}` - : 'none', - transition: 'outline 0.15s, box-shadow 0.15s, transform 0.1s', - '&:hover': { - transform: 'scale(1.15)', - boxShadow: `0 0 8px ${alpha(theme.palette[palette].main, 0.4)}` - } - })} - /> - ) - })} - - - - ) -} - -export default BitSettingsPopover diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx new file mode 100644 index 0000000..5621d72 --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapDetailPanel/BitSettingsPopover/BitSettingsPopover.tsx @@ -0,0 +1,108 @@ +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import Popover from '@mui/material/Popover' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { alpha } from '@mui/material/styles' +import { meme } from '@renderer/components/shared/inputs/meme' +import { BitColor } from '@shared' + +interface BitSettingsPopoverProps { + anchorEl: HTMLElement | null + onClose: () => void + color: BitColor | undefined + invert: boolean + onColorChange: (color: BitColor | undefined) => void + onInvertChange: (invert: boolean) => void +} + +const COLOR_OPTIONS: { value: BitColor; palette: 'success' | 'warning' | 'error' }[] = [ + { value: 'default', palette: 'success' }, + { value: 'warning', palette: 'warning' }, + { value: 'error', palette: 'error' } +] + +const BitSettingsPopover = meme( + ({ + anchorEl, + onClose, + color, + invert, + onColorChange, + onInvertChange + }: BitSettingsPopoverProps): JSX.Element => { + const selected = color ?? 'default' + + return ( + + + {/* Invert toggle */} + + onInvertChange(!invert)} + sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} + > + Invert + + + + {/* Color swatches */} + + {COLOR_OPTIONS.map(({ value, palette }) => { + const isSelected = selected === value + return ( + onColorChange(value === 'default' ? undefined : value)} + sx={(theme) => ({ + width: 16, + height: 16, + borderRadius: '50%', + bgcolor: theme.palette[palette].main, + cursor: 'pointer', + outline: isSelected + ? `2px solid ${theme.palette[palette].main}` + : '2px solid transparent', + outlineOffset: 2, + boxShadow: isSelected + ? `0 0 8px ${alpha(theme.palette[palette].main, 0.5)}` + : 'none', + transition: 'outline 0.15s, box-shadow 0.15s, transform 0.1s', + '&:hover': { + transform: 'scale(1.15)', + boxShadow: `0 0 8px ${alpha(theme.palette[palette].main, 0.4)}` + } + })} + /> + ) + })} + + + + ) + } +) + +export default BitSettingsPopover diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx index 1c88d70..efc468f 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/BitMapRow/BitMapRow.tsx @@ -1,6 +1,7 @@ -import { GridRow, GridRowProps } from '@mui/x-data-grid' +import { GridRow, GridRowProps } from '@mui/x-data-grid/components' +import { meme } from '@renderer/components/shared/inputs/meme' import { useBitMapZustand } from '@renderer/context/bitmap.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { BITMAP_DATATYPE } from '@shared' import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' @@ -11,14 +12,14 @@ import BitMapDetailPanel from '../BitMapDetailPanel/BitMapDetailPanel' // the virtual-scroller height slot when expanded. // ───────────────────────────────────────────────────────────────────────────── -const BitMapRow = (props: GridRowProps): JSX.Element => { +const BitMapRow = meme((props: GridRowProps): JSX.Element => { const address = props.rowId as number const expandedAddress = useBitMapZustand((z) => z.expandedAddress) const isExpanded = expandedAddress === address const isBitmap = - useRootZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === + useClientZustand((z) => z.registerMapping[z.registerConfig.type][address]?.dataType) === BITMAP_DATATYPE if (!isBitmap) { @@ -50,6 +51,6 @@ const BitMapRow = (props: GridRowProps): JSX.Element => { )} ) -} +}) export default BitMapRow diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx index c521ec8..74807e3 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGrid.tsx @@ -1,19 +1,16 @@ -import { Paper, Typography } from '@mui/material' -import { useRootZustand } from '@renderer/context/root.zustand' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import { useClientZustand } from '@renderer/context/client.zustand' import { DateTime } from 'luxon' import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import useRegisterGridColumns from './columns' import RegisterGridToolbar from './RegisterGridToolbar/RegisterGridToolbar' -import { - DataGrid, - GridFilterModel, - GridFooterContainer, - GridLogicOperator, - GridPagination, - useGridApiRef -} from '@mui/x-data-grid' +import { useGridApiRef } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid/DataGrid' +import { GridFooterContainer, GridPagination } from '@mui/x-data-grid/components' +import { GridFilterModel, GridLogicOperator } from '@mui/x-data-grid/models' import { DataType, RegisterData } from '@shared' import { alpha } from '@mui/material/styles' import { showMapping } from '@renderer/context/data.zustand' @@ -24,7 +21,7 @@ import BitMapRow from './BitMapRow/BitMapRow' // // Footer const Footer = meme(() => { - const time = useRootZustand((z) => z.lastSuccessfulTransactionMillis) + const time = useClientZustand((z) => z.lastSuccessfulTransactionMillis) return ( @@ -43,21 +40,21 @@ const Footer = meme(() => { // // // DataGrid -const RegisterGridContent = (): JSX.Element => { +const RegisterGridContent = meme((): JSX.Element => { const registerData = useDataZustand((z) => z.registerData) - const registerMapping = useRootZustand((z) => z.registerMapping[z.registerConfig.type]) + const registerMapping = useClientZustand((z) => z.registerMapping[z.registerConfig.type]) const columns = useRegisterGridColumns() const apiRef = useGridApiRef() // When we read all configured registers, we hide the rows with undefined data type // So no empty rows are shown so all rows have a value to display. - const readConfiguration = useRootZustand((z) => z.readConfiguration) + const readConfiguration = useClientZustand((z) => z.readConfiguration) // 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 = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const prevReadConfigRef = useRef(readConfiguration) useEffect(() => { const filterModel: GridFilterModel = { @@ -77,6 +74,36 @@ const RegisterGridContent = (): JSX.Element => { prevReadConfigRef.current = readConfiguration }, [apiRef, readConfiguration]) + const handleRowUpdate = useCallback( + (newRow: RegisterData, oldRow: RegisterData): RegisterData => { + const clientZustand = useClientZustand.getState() + + // Update datatype + if (newRow['dataType'] && newRow['dataType'] !== oldRow['dataType']) { + clientZustand.setRegisterMapping(newRow.id, 'dataType', newRow['dataType']) + } + + // Update scaling factor + // This will ignore zero too, if you don't want to ignore zero compare with undefined + if (newRow['scalingFactor'] && newRow['scalingFactor'] !== oldRow['scalingFactor']) { + clientZustand.setRegisterMapping(newRow.id, 'scalingFactor', newRow['scalingFactor']) + } + + // Update comment + if (typeof newRow['comment'] === 'string' && newRow['comment'] !== oldRow['comment']) { + clientZustand.setRegisterMapping(newRow.id, 'comment', newRow['comment']) + } + + // Update group end + if (typeof newRow['groupEnd'] === 'boolean' && newRow['groupEnd'] !== oldRow['groupEnd']) { + clientZustand.setRegisterMapping(newRow.id, 'groupEnd', newRow['groupEnd']) + } + + return newRow + }, + [] + ) + return ( { // // // Row update - processRowUpdate={(newRow, oldRow) => { - const z = useRootZustand.getState() - - // Update datatype - if (newRow['dataType'] && newRow['dataType'] !== oldRow['dataType']) { - z.setRegisterMapping(newRow.id, 'dataType', newRow['dataType']) - } - - // Update scaling factor - // This will ignore zero too, if you don't want to ignore zero compare with undefined - if (newRow['scalingFactor'] && newRow['scalingFactor'] !== oldRow['scalingFactor']) { - const z = useRootZustand.getState() - z.setRegisterMapping(newRow.id, 'scalingFactor', newRow['scalingFactor']) - } - - // Update comment - if (typeof newRow['comment'] === 'string' && newRow['comment'] !== oldRow['comment']) { - const z = useRootZustand.getState() - z.setRegisterMapping(newRow.id, 'comment', newRow['comment']) - } - - // Update group end - if (typeof newRow['groupEnd'] === 'boolean' && newRow['groupEnd'] !== oldRow['groupEnd']) { - const z = useRootZustand.getState() - z.setRegisterMapping(newRow.id, 'groupEnd', newRow['groupEnd']) - } - - return newRow - }} + processRowUpdate={handleRowUpdate} /> ) -} +}) // // // // // DataGrid paper -const RegisterGrid = (): JSX.Element => { +const RegisterGrid = meme((): JSX.Element => { return ( ) -} +}) export default RegisterGrid diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx index 71bfe0b..6932f2d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearButton/ClearButton.tsx @@ -1,17 +1,18 @@ import Button from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' -const ClearButton = (): JSX.Element => { +const ClearButton = meme((): JSX.Element => { const noData = useDataZustand((z) => z.registerData.length === 0) - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const disabled = noData || polling - const setRegisterData = useDataZustand((z) => z.setRegisterData) - const handleClear = useCallback(() => { - setRegisterData([]) - }, [setRegisterData]) + const handleClear = useCallback((): void => { + const dataZustand = useDataZustand.getState() + dataZustand.setRegisterData([]) + }, []) return ( ) -} +}) export default ClearButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx index d9357a0..4543891 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearConfigButton/ClearConfigButton.tsx @@ -1,16 +1,17 @@ -import { Delete } from '@mui/icons-material' +import Delete from '@mui/icons-material/Delete' import IconButton from '@mui/material/IconButton' +import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useState } from 'react' -const ClearConfigButton = (): JSX.Element => { +const ClearConfigButton = meme((): JSX.Element => { const [warn, setWarn] = useState(false) const handleClick = useCallback(() => { - useRootZustand.getState().setName('') - useRootZustand.getState().clearRegisterMapping() - useRootZustand.getState().setReadConfiguration(false) + useClientZustand.getState().setName('') + useClientZustand.getState().clearRegisterMapping() + useClientZustand.getState().setReadConfiguration(false) }, []) return ( @@ -27,6 +28,6 @@ const ClearConfigButton = (): JSX.Element => { ) -} +}) export default ClearConfigButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx index 6382845..dc909ca 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ClearFiltersButton/ClearFiltersButton.tsx @@ -1,11 +1,12 @@ -import { FilterAltOff } from '@mui/icons-material' +import FilterAltOff from '@mui/icons-material/FilterAltOff' import IconButton from '@mui/material/IconButton' +import { useGridApiContext } from '@mui/x-data-grid' import { gridFilterActiveItemsSelector, gridFilterModelSelector, - useGridApiContext, useGridSelector -} from '@mui/x-data-grid' +} from '@mui/x-data-grid/hooks' +import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' // The grid sets a filter of its own while read configuration is on, to keep @@ -14,7 +15,7 @@ import { useCallback } from 'react' // survives a clear. Dropping it would fill the list with empty rows. const INTERNAL_FILTER_ID = 1 -const ClearFiltersButton = (): JSX.Element | null => { +const ClearFiltersButton = meme((): JSX.Element | null => { const apiRef = useGridApiContext() // Active items rather than the model: opening the filter panel already puts // an empty item in the model, and a form nobody has typed in yet is not a @@ -44,6 +45,6 @@ const ClearFiltersButton = (): JSX.Element | null => { ) -} +}) export default ClearFiltersButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx index 1f57d88..7dd2115 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/LoadButton/LoadButton.tsx @@ -1,7 +1,7 @@ -import { FileOpen } from '@mui/icons-material' +import FileOpen from '@mui/icons-material/FileOpen' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { migrateClientConfig } from '@shared' import { useSnackbar } from 'notistack' import { useRef, useState, useCallback } from 'react' @@ -21,7 +21,7 @@ const LoadButton = meme((): JSX.Element => { openingRef.current = true setOpening(true) - const state = useRootZustand.getState() + const clientZustand = useClientZustand.getState() const content = await file.text() @@ -31,9 +31,9 @@ const LoadButton = meme((): JSX.Element => { const { config, migrated, warning } = migrationResult // Set name, endianness and register mapping - if (config.name) state.setName(config.name) - state.setLittleEndian(config.littleEndian) - state.replaceRegisterMapping(config.registerMapping) + if (config.name) clientZustand.setName(config.name) + clientZustand.setLittleEndian(config.littleEndian) + clientZustand.replaceRegisterMapping(config.registerMapping) // Show success notification if (migrated) { @@ -67,7 +67,7 @@ const LoadButton = meme((): JSX.Element => { openingRef.current = false setOpening(false) showMapping() - useRootZustand.getState().setReadConfiguration(false) + useClientZustand.getState().setReadConfiguration(false) }, [enqueueSnackbar] ) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx index 5294903..58e481e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/LoadDummyDataButton/LoadDummyDataButton.tsx @@ -1,20 +1,20 @@ import { meme } from '@renderer/components/shared/inputs/meme' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { RegisterData, getDummyRegisterData } from '@shared' import { useCallback } from 'react' import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' import Button from '@mui/material/Button' const LoadDummyDataButton = meme(({ setAnchor }: SetAnchorProps) => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') + const disabled = useClientZustand((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 const loadDummy = useCallback(() => { - const state = useRootZustand.getState() - const { address, length } = state.registerConfig - const dataState = useDataZustand.getState() + const clientZustand = useClientZustand.getState() + const { address, length } = clientZustand.registerConfig + const dataZustand = useDataZustand.getState() const dummyData: RegisterData[] = [] let index = 0 @@ -23,7 +23,7 @@ const LoadDummyDataButton = meme(({ setAnchor }: SetAnchorProps) => { index++ } - dataState.setRegisterData(dummyData) + dataZustand.setRegisterData(dummyData) setAnchor(null) }, [setAnchor]) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx index ce71c7c..245653e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuButton.tsx @@ -1,4 +1,4 @@ -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' +import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useRef, useState } from 'react' import LoadDummyDataButton from './LoadDummyDataButton/LoadDummyDataButton' @@ -8,7 +8,7 @@ import ScanRegistersButton, { SetAnchorProps } from './ScanRegistersButton/ScanR import { ScanUnitIdsButton } from './ScanUnitIds/ScanUnitIds' import FormGroup from '@mui/material/FormGroup' import Button from '@mui/material/Button' -import { Settings } from '@mui/icons-material' +import Settings from '@mui/icons-material/Settings' import Popover from '@mui/material/Popover' const MenuContent = meme(({ setAnchor }: SetAnchorProps) => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx index c4e265e..7bd2e06 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuConnectionOptions/MenuConnectionOptions.tsx @@ -1,14 +1,21 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' +import { ChangeEvent, useCallback } from 'react' // RTU over TCP (encapsulated RTU) is a niche, TCP-family transport, so it lives // here in the options menu rather than as a third connection toggle. Only shown // when TCP is selected; serial RTU has no use for it. -const MenuConnectionOptions = (): JSX.Element | null => { - const protocol = useRootZustand((z) => z.connectionConfig.protocol) - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') +const MenuConnectionOptions = meme((): JSX.Element | null => { + const protocol = useClientZustand((z) => z.connectionConfig.protocol) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + + const handleChange = useCallback((event: ChangeEvent): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setProtocol(event.target.checked ? 'ModbusRtuOverTcp' : 'ModbusTcp') + }, []) if (protocol === 'ModbusRtu') return null @@ -26,11 +33,7 @@ const MenuConnectionOptions = (): JSX.Element | null => { // going to change. color="warning" checked={rtuOverTcp} - onChange={(e) => - useRootZustand - .getState() - .setProtocol(e.target.checked ? 'ModbusRtuOverTcp' : 'ModbusTcp') - } + onChange={handleChange} data-testid="rtu-over-tcp-checkbox" /> } @@ -39,6 +42,6 @@ const MenuConnectionOptions = (): JSX.Element | null => { ) -} +}) export default MenuConnectionOptions diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx index 9c5d2c3..4dfbf9c 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/MenuRegisterOptions/MenuRegisterOptions.tsx @@ -1,13 +1,25 @@ import Checkbox from '@mui/material/Checkbox' import Divider from '@mui/material/Divider' import FormControlLabel from '@mui/material/FormControlLabel' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' +import { ChangeEvent, useCallback } from 'react' -const MenuRegisterOptions = (): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) +const MenuRegisterOptions = meme((): JSX.Element | null => { + const type = useClientZustand((z) => z.registerConfig.type) - const advanceMode = useRootZustand((z) => z.registerConfig.advancedMode) - const show64BitValues = useRootZustand((z) => z.registerConfig.show64BitValues) + const advanceMode = useClientZustand((z) => z.registerConfig.advancedMode) + const show64BitValues = useClientZustand((z) => z.registerConfig.show64BitValues) + + const handleAdvancedChange = useCallback((event: ChangeEvent): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setAdvancedMode(event.target.checked) + }, []) + + const handle64BitChange = useCallback((event: ChangeEvent): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setShow64BitValues(event.target.checked) + }, []) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) if (!registers16Bit) return null @@ -19,7 +31,7 @@ const MenuRegisterOptions = (): JSX.Element | null => { useRootZustand.getState().setAdvancedMode(e.target.checked)} + onChange={handleAdvancedChange} data-testid="advanced-mode-checkbox" /> } @@ -31,7 +43,7 @@ const MenuRegisterOptions = (): JSX.Element | null => { useRootZustand.getState().setShow64BitValues(e.target.checked)} + onChange={handle64BitChange} data-testid="show-64bit-checkbox" /> } @@ -40,6 +52,6 @@ const MenuRegisterOptions = (): JSX.Element | null => { ) -} +}) export default MenuRegisterOptions diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx index c95be19..d90c2cd 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanProgress/ScanProgress.tsx @@ -1,26 +1,25 @@ -import { - Button, - IconButton, - InputBaseComponentProps, - LinearProgress, - TextField, - Tooltip, - Typography -} from '@mui/material' -import { Visibility, VisibilityOff } from '@mui/icons-material' +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 { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { useClientZustand } from '@renderer/context/client.zustand' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { ElementType, forwardRef } from 'react' import { IMaskInput, IMask } from 'react-imask' // Scan progress export const ScanProgress = meme(() => { - const scanning = useRootZustand( - (z) => z.clientState.scanningUniId || z.clientState.scanningRegisters + const scanning = useClientZustand( + (z) => z.clientState.scanningUnitIds || z.clientState.scanningRegisters ) - const scanProgress = useRootZustand((z) => z.scanProgress) + const scanProgress = useClientZustand((z) => z.scanProgress) return scanning ? ( ({ - useRootZustand: (selector: (state: Record) => unknown): unknown => +vi.mock('@renderer/context/client.zustand', () => ({ + useClientZustand: (selector: (state: Record) => unknown): unknown => selector({ clientState: {}, scanProgress: 0 }) })) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx index e0475f9..5e6a25e 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters.tsx @@ -1,12 +1,13 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { Box, Button, InputBaseComponentProps, Modal, Paper, TextField } from '@mui/material' +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 { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { ElementType, useCallback, useMemo } from 'react' -import { create } from 'zustand' -import { mutative } from 'zustand-mutative' import { maskInputProps } from '@renderer/components/shared/inputs/types' -import { MaskSetFn } from '@renderer/context/root.zustand.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' @@ -19,56 +20,16 @@ import { ScanTimeoutField } from '../../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' - -interface ScanRegistersZustand { - open: boolean - setOpen: (open: boolean) => void - address: number - setAddress: MaskSetFn - scanLength: number - setScanLength: MaskSetFn - chunkSize: number - setChunkSize: MaskSetFn - timeout: number - setTimeout: MaskSetFn -} -export const useScanRegistersZustand = create( - mutative((set) => ({ - open: false, - setOpen: (open) => - set((state) => { - state.open = open - }), - address: 0, - setAddress: (address) => - set((state) => { - state.address = Number(address) - }), - scanLength: 10000, - setScanLength: (scanLength) => - set((state) => { - state.scanLength = Number(scanLength) - }), - chunkSize: 100, - setChunkSize: (chunkSize) => - set((state) => { - state.chunkSize = Number(chunkSize) - }), - timeout: 500, - setTimeout: (timeout) => - set((state) => { - state.timeout = Number(timeout) - }) - })) -) +import { useScanRegistersZustand } from './scanRegisters.zustand' // // // Unit ID field (syncs with main connection config) -const UnitIdField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) - const unitId = useRootZustand((z) => String(z.connectionConfig.unitId)) - const setUnitId = useRootZustand((z) => z.setUnitId) +const UnitIdField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) + const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) + + const setUnitId = useClientZustand.getState().setUnitId return ( { }} /> ) -} +}) // // // Address field with base toggle -const AddressField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const AddressField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const address = useScanRegistersZustand((z) => z.address) - const setAddress = useScanRegistersZustand((z) => z.setAddress) + + const setAddress = useScanRegistersZustand.getState().setAddress return ( { baseTestId="scan-base" /> ) -} +}) // // // Scan Length field -const ScanLengthField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const ScanLengthField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const scanLength = useScanRegistersZustand((z) => String(z.scanLength)) - const setScanLength = useScanRegistersZustand((z) => z.setScanLength) + + const setScanLength = useScanRegistersZustand.getState().setScanLength return ( { }} /> ) -} +}) // // // Chunk Size field -const ChunkSizeField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const ChunkSizeField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const chunkSize = useScanRegistersZustand((z) => String(z.chunkSize)) - const setChunkSize = useScanRegistersZustand((z) => z.setChunkSize) - const type = useRootZustand((z) => z.registerConfig.type) + const type = useClientZustand((z) => z.registerConfig.type) const isCoilType = ['coils', 'discrete_inputs'].includes(type) const max = isCoilType ? 2000 : 125 + const setChunkSize = useScanRegistersZustand.getState().setChunkSize + return ( { }} /> ) -} +}) // // // Timeout field -const TimeoutField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const TimeoutField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const timeout = useScanRegistersZustand((z) => z.timeout) - const setTimeout = useScanRegistersZustand((z) => z.setTimeout) + + const setTimeout = useScanRegistersZustand.getState().setTimeout return ( { testId="scan-timeout-input" /> ) -} +}) // // @@ -192,30 +157,34 @@ const TimeoutField = (): JSX.Element => { // zero. So the length of the grid data is the count of what the scan turned // up, and it means that while a scan is running, since the same list holds // polled data the rest of the time. -const FoundCount = (): JSX.Element | null => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const FoundCount = meme((): JSX.Element | null => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const count = useDataZustand((z) => z.registerData.length) if (!scanning) return null return -} +}) // // // Show the grid while scanning -const GridToggle = (): JSX.Element => { +const GridToggle = meme((): JSX.Element => { const shown = useLayoutZustand((z) => z.showGridWhileScanning) - const toggle = useLayoutZustand((z) => z.toggleShowGridWhileScanning) - return -} + const handleToggle = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.toggleShowGridWhileScanning() + }, []) + + return +}) // // // Scan button -const ScanButton = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) +const ScanButton = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const scan = useCallback(async () => { if (scanning) { @@ -225,19 +194,19 @@ const ScanButton = (): JSX.Element => { window.api.stopPolling() - const state = useScanRegistersZustand.getState() - const rootState = useRootZustand.getState() - const dataState = useDataZustand.getState() - rootState.setReadConfiguration(false) + const scanRegistersZustand = useScanRegistersZustand.getState() + const clientZustand = useClientZustand.getState() + const dataZustand = useDataZustand.getState() + clientZustand.setReadConfiguration(false) // 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 (!rootState.registerConfig.advancedMode) rootState.setAdvancedMode(true) - rootState.clearScanUnitIdResults() - rootState.setScanProgress(0) + if (!clientZustand.registerConfig.advancedMode) clientZustand.setAdvancedMode(true) + clientZustand.clearScanUnitIdResults() + clientZustand.setScanProgress(0) dropPendingScanRows() - dataState.setRegisterData([]) + dataZustand.setRegisterData([]) - const { address, scanLength, chunkSize, timeout } = state + const { address, scanLength, chunkSize, timeout } = scanRegistersZustand await window.api.scanRegisters({ addressRange: [address, address + scanLength - 1], @@ -254,7 +223,7 @@ const ScanButton = (): JSX.Element => { {text} ) -} +}) // // @@ -262,11 +231,11 @@ const ScanButton = (): JSX.Element => { const ScanRegisters = meme(() => { const open = useScanRegistersZustand((z) => z.open) - const scanning = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) const handleClose = useCallback(() => { - const rootState = useRootZustand.getState() - if (rootState.clientState.scanningRegisters) return + const clientZustand = useClientZustand.getState() + if (clientZustand.clientState.scanningRegisters) return useScanRegistersZustand.getState().setOpen(false) }, []) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts new file mode 100644 index 0000000..ecdb4ee --- /dev/null +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { MaskSetFn } from '@renderer/context/client.zustand.types' +import { create } from 'zustand' +import { mutative } from 'zustand-mutative' + +interface ScanRegistersZustand { + open: boolean + setOpen: (open: boolean) => void + address: number + setAddress: MaskSetFn + scanLength: number + setScanLength: MaskSetFn + chunkSize: number + setChunkSize: MaskSetFn + timeout: number + setTimeout: MaskSetFn +} +export const useScanRegistersZustand = create( + mutative((set) => ({ + open: false, + setOpen: (open) => + set((state) => { + state.open = open + }), + address: 0, + setAddress: (address) => + set((state) => { + state.address = Number(address) + }), + scanLength: 10000, + setScanLength: (scanLength) => + set((state) => { + state.scanLength = Number(scanLength) + }), + chunkSize: 100, + setChunkSize: (chunkSize) => + set((state) => { + state.chunkSize = Number(chunkSize) + }), + timeout: 500, + setTimeout: (timeout) => + set((state) => { + state.timeout = Number(timeout) + }) + })) +) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx index 2ecefa0..035ad77 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegistersButton.tsx @@ -1,7 +1,7 @@ import Button from '@mui/material/Button' -import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' +import { useScanRegistersZustand } from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/scanRegisters.zustand' import { meme } from '@renderer/components/shared/inputs/meme' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' export interface SetAnchorProps { @@ -9,8 +9,8 @@ export interface SetAnchorProps { } const ScanRegistersButton = meme(({ setAnchor }: SetAnchorProps) => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') - const type = useRootZustand((z) => z.registerConfig.type) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') + const type = useClientZustand((z) => z.registerConfig.type) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) const handleOpen = useCallback(() => { diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx index 39a2a15..61d0a0a 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds.tsx @@ -1,22 +1,21 @@ -import { - alpha, - Box, - Button, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' -import { DataGrid } from '@mui/x-data-grid' +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 ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { alpha } from '@mui/material/styles' +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 { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' +import { RegisterType } from '@shared' import { ElementType, useCallback, useMemo } from 'react' import useScanUnitIdColumns from './_columns' -import { useScanUnitIdZustand } from './_zustand' +import { useScanUnitIdZustand } from './scanUnitIds.zustand' import { ScanCloseButton, ScanProgress, ScanTimeoutField } from '../ScanProgress/ScanProgress' import { meme } from '@renderer/components/shared/inputs/meme' import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' @@ -24,10 +23,11 @@ import { SetAnchorProps } from '../ScanRegistersButton/ScanRegistersButton' // // // Start Unit ID field -const StartUnitIdField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const StartUnitIdField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const startUnitId = useScanUnitIdZustand((z) => String(z.startUnitId)) - const setStartUnitId = useScanUnitIdZustand((z) => z.setStartUnitId) + + const setStartUnitId = useScanUnitIdZustand.getState().setStartUnitId return ( { }} /> ) -} +}) // // // Count field -const CountField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const CountField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const count = useScanUnitIdZustand((z) => String(z.count)) - const setCount = useScanUnitIdZustand((z) => z.setCount) + + const setCount = useScanUnitIdZustand.getState().setCount return ( { }} /> ) -} +}) // // // Address field with base toggle -const AddressField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const AddressField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const address = useScanUnitIdZustand((z) => z.address) - const setAddress = useScanUnitIdZustand((z) => z.setAddress) + + const setAddress = useScanUnitIdZustand.getState().setAddress return ( { baseTestId="scan-unitid-base" /> ) -} +}) // // // Length field -const LengthField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const LengthField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const length = useScanUnitIdZustand((z) => String(z.length)) - const setLength = useScanUnitIdZustand((z) => z.setLength) + + const setLength = useScanUnitIdZustand.getState().setLength return ( { }} /> ) -} +}) // // // Timeout field -const TimeoutField = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const TimeoutField = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const timeout = useScanUnitIdZustand((z) => z.timeout) - const setTimeout = useScanUnitIdZustand((z) => z.setTimeout) + + const setTimeout = useScanUnitIdZustand.getState().setTimeout return ( { testId="scan-unitid-timeout-input" /> ) -} +}) // // // Select register types -const SelectRegisterTypes = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) +const SelectRegisterTypes = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) - const setRegisterTypes = useScanUnitIdZustand((z) => z.setRegisterTypes) + + const handleChange = useCallback((_event: unknown, value: RegisterType[]): void => { + const scanUnitIdZustand = useScanUnitIdZustand.getState() + scanUnitIdZustand.setRegisterTypes(value) + }, []) return ( { color="primary" size="small" value={registerTypes} - onChange={(_, rt) => setRegisterTypes(rt)} + onChange={handleChange} aria-label="Register types to scan" > {/* The same short names the result columns carry, so the button you @@ -185,13 +193,13 @@ const SelectRegisterTypes = (): JSX.Element => { ) -} +}) // // Scan button -const ScanButton = (): JSX.Element => { - const scanning = useRootZustand((z) => z.clientState.scanningUniId) - const polling = useRootZustand((z) => z.clientState.polling) +const ScanButton = meme((): JSX.Element => { + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) + const polling = useClientZustand((z) => z.clientState.polling) const disabled = useScanUnitIdZustand((z) => z.registerTypes.length === 0) const scan = useCallback(() => { @@ -202,12 +210,12 @@ const ScanButton = (): JSX.Element => { window.api.stopPolling() - const state = useScanUnitIdZustand.getState() - const rootState = useRootZustand.getState() - rootState.clearScanUnitIdResults() - rootState.setScanProgress(0) + const scanUnitIdZustand = useScanUnitIdZustand.getState() + const clientZustand = useClientZustand.getState() + clientZustand.clearScanUnitIdResults() + clientZustand.setScanProgress(0) - const { address, length, startUnitId, count, registerTypes, timeout } = state + const { address, length, startUnitId, count, registerTypes, timeout } = scanUnitIdZustand window.api.scanUnitIds({ address, @@ -232,13 +240,13 @@ const ScanButton = (): JSX.Element => { {text} ) -} +}) // // // Scan result grid const ScanResultGrid = meme(() => { - const scanResults = useRootZustand((z) => z.scanUnitIdResults) + const scanResults = useClientZustand((z) => z.scanUnitIdResults) const registerTypes = useScanUnitIdZustand((z) => z.registerTypes) const columns = useScanUnitIdColumns() @@ -292,8 +300,8 @@ const ScanResultGrid = meme(() => { // // // Scan unit ids button -export const ScanUnitIdsButton = ({ setAnchor }: SetAnchorProps): JSX.Element => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') +export const ScanUnitIdsButton = meme(({ setAnchor }: SetAnchorProps): JSX.Element => { + const disabled = useClientZustand((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. @@ -314,7 +322,7 @@ export const ScanUnitIdsButton = ({ setAnchor }: SetAnchorProps): JSX.Element => Scan Unit ID{`'`}s ) -} +}) // // @@ -326,19 +334,19 @@ export const ScanUnitIdsButton = ({ setAnchor }: SetAnchorProps): JSX.Element => */ const ScanUnitIds = meme(() => { const open = useScanUnitIdZustand((z) => z.open) - const setOpen = useScanUnitIdZustand((z) => z.setOpen) // Don't close while scanning - const scanning = useRootZustand((z) => z.clientState.scanningUniId) + const scanning = useClientZustand((z) => z.clientState.scanningUnitIds) const handleClose = useCallback(() => { - const currentRootState = useRootZustand.getState() - if (currentRootState.clientState.scanningUniId) return + const clientZustand = useClientZustand.getState() + const scanUnitIdZustand = useScanUnitIdZustand.getState() + if (clientZustand.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. - currentRootState.clearScanUnitIdResults() - setOpen(false) - }, [setOpen]) + clientZustand.clearScanUnitIdResults() + scanUnitIdZustand.setOpen(false) + }, []) return ( { }) import { render, screen, fireEvent } from '@testing-library/react' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import MenuRegisterOptions from '../MenuRegisterOptions/MenuRegisterOptions' import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOptions' @@ -23,18 +23,18 @@ import MenuConnectionOptions from '../MenuConnectionOptions/MenuConnectionOption // leave a stray separator. These tests guard that null-behaviour and the // RTU-over-TCP toggle without needing a real Modbus server. -const seed = (partial: Parameters[0]): void => { - useRootZustand.setState(partial as never) +const seed = (partial: Parameters[0]): void => { + useClientZustand.setState(partial as never) } beforeEach(() => { window.api = { updateConnectionConfig: vi.fn() } as never - useRootZustand.setState({ + useClientZustand.setState({ ready: true, clientState: { connectState: 'disconnected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } } as never) @@ -43,7 +43,7 @@ beforeEach(() => { describe('MenuRegisterOptions', () => { it('renders advanced/64-bit options with a trailing divider for 16-bit register types', () => { seed({ - registerConfig: { ...useRootZustand.getState().registerConfig, type: 'holding_registers' } + registerConfig: { ...useClientZustand.getState().registerConfig, type: 'holding_registers' } }) const { container } = render() @@ -54,7 +54,7 @@ describe('MenuRegisterOptions', () => { }) it('renders nothing (no options, no divider) for non-16-bit register types', () => { - seed({ registerConfig: { ...useRootZustand.getState().registerConfig, type: 'coils' } }) + seed({ registerConfig: { ...useClientZustand.getState().registerConfig, type: 'coils' } }) const { container } = render() @@ -66,7 +66,7 @@ describe('MenuRegisterOptions', () => { describe('MenuConnectionOptions', () => { it('renders the RTU-over-TCP checkbox with a trailing divider when TCP is selected', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } }) const { container } = render() @@ -78,7 +78,7 @@ describe('MenuConnectionOptions', () => { it('checks the box when the protocol is RTU over TCP', () => { seed({ connectionConfig: { - ...useRootZustand.getState().connectionConfig, + ...useClientZustand.getState().connectionConfig, protocol: 'ModbusRtuOverTcp' } }) @@ -90,7 +90,7 @@ describe('MenuConnectionOptions', () => { it('renders nothing (no checkbox, no divider) for serial RTU', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusRtu' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusRtu' } }) const { container } = render() @@ -101,26 +101,26 @@ describe('MenuConnectionOptions', () => { it('toggles the protocol between TCP and RTU-over-TCP via the checkbox', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' } + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' } }) render() fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useRootZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusRtuOverTcp') expect(window.api.updateConnectionConfig).toHaveBeenCalledWith({ protocol: 'ModbusRtuOverTcp' }) fireEvent.click(screen.getByTestId('rtu-over-tcp-checkbox')) - expect(useRootZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') + expect(useClientZustand.getState().connectionConfig.protocol).toBe('ModbusTcp') }) it('disables the checkbox while not disconnected', () => { seed({ - connectionConfig: { ...useRootZustand.getState().connectionConfig, protocol: 'ModbusTcp' }, + connectionConfig: { ...useClientZustand.getState().connectionConfig, protocol: 'ModbusTcp' }, clientState: { connectState: 'connected', polling: false, - scanningUniId: false, + scanningUnitIds: false, scanningRegisters: false } } as never) diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx index b790273..8f612b2 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/PollButton/PollButton.tsx @@ -1,11 +1,12 @@ import Button, { ButtonProps } from '@mui/material/Button' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback } from 'react' -const PollButton = (): JSX.Element => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'connected') +const PollButton = meme((): JSX.Element => { + const disabled = useClientZustand((z) => z.clientState.connectState !== 'connected') - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const togglePolling = useCallback(() => { polling ? window.api.stopPolling() : window.api.startPolling() }, [polling]) @@ -25,6 +26,6 @@ const PollButton = (): JSX.Element => { Poll ) -} +}) export default PollButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx index 85969c2..2c8da99 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RawButton/RawButton.tsx @@ -1,12 +1,19 @@ import Button from '@mui/material/Button' import { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useCallback } from 'react' -const RawButton = (): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) +const RawButton = meme((): JSX.Element | null => { + const type = useClientZustand((z) => z.registerConfig.type) const showRawValues = useLayoutZustand((z) => z.showClientRawValues) + const handleClick = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.toggleShowClientRawValues() + }, []) + if (!['input_registers', 'holding_registers'].includes(type)) return null const variant: ButtonProps['variant'] = showRawValues ? 'contained' : 'outlined' @@ -18,11 +25,11 @@ const RawButton = (): JSX.Element | null => { size="small" color={color} variant={variant} - onClick={useLayoutZustand.getState().toggleShowClientRawValues} + onClick={handleClick} > RAW ) -} +}) export default RawButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx index 156be64..3de969d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ReadButton/ReadButton.tsx @@ -1,9 +1,10 @@ import Button, { ButtonProps } from '@mui/material/Button' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useRef, useState } from 'react' -const ReadButton = (): JSX.Element => { - const disabled = useRootZustand( +const ReadButton = meme((): JSX.Element => { + const disabled = useClientZustand( (z) => z.clientState.connectState !== 'connected' || z.clientState.polling ) @@ -34,6 +35,6 @@ const ReadButton = (): JSX.Element => { Read ) -} +}) export default ReadButton 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 eddf447..96fb211 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/RegisterGridToolbar.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { meme } from '@renderer/components/shared/inputs/meme' import PollButton from './PollButton/PollButton' import ReadButton from './ReadButton/ReadButton' @@ -12,11 +12,17 @@ import ShowLogButton from './ShowLogButton/ShowLogButton' import MenuButton from './MenuButton/MenuButton' import RawButton from './RawButton/RawButton' import ClearFiltersButton from './ClearFiltersButton/ClearFiltersButton' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import TextField from '@mui/material/TextField' +import { ChangeEvent, useCallback } from 'react' const ClientConfigName = meme(() => { - const name = useRootZustand((z) => z.name ?? '') + const name = useClientZustand((z) => z.name ?? '') + + const handleChange = useCallback((event: ChangeEvent): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setName(event.target.value) + }, []) return ( { color="primary" placeholder="Client Configuration Name" value={name} - onChange={(e) => useRootZustand.getState().setName(e.target.value)} + onChange={handleChange} /> ) }) @@ -36,7 +42,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 = useRootZustand((z) => z.clientState.scanningRegisters) + const scanning = useClientZustand((z) => z.clientState.scanningRegisters) return ( { - const saveRegisterConfig = useCallback(async () => { - const z = useRootZustand.getState() - const { name } = z + const saveRegisterConfig = useCallback(() => { + const clientZustand = useClientZustand.getState() + const { name } = clientZustand - const registerMapping = structuredClone(z.registerMapping) + const registerMapping = structuredClone(clientZustand.registerMapping) const registerMappingKeys = Object.keys(registerMapping) as RegisterType[] registerMappingKeys.forEach((key) => { Object.keys(registerMapping[key]).forEach((register) => { @@ -21,14 +22,14 @@ const SaveButton = meme(() => { }) }) - // Get app version - const modbuxVersion = await window.api.getAppVersion() + // The store reads the version once at startup; it cannot change after that + const modbuxVersion = useLayoutZustand.getState().version const registerMapConfig: RegisterMapConfig = { version: 2, modbuxVersion, name, - littleEndian: z.registerConfig.littleEndian, + littleEndian: clientZustand.registerConfig.littleEndian, registerMapping } @@ -42,7 +43,7 @@ const SaveButton = meme(() => { const { connectionConfig: { unitId } - } = useRootZustand.getState() + } = useClientZustand.getState() const idText = `_id${unitId}` diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx index f58b1f5..dab5f36 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ShowLogButton/ShowLogButton.tsx @@ -1,18 +1,24 @@ import Button, { ButtonProps } from '@mui/material/Button' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' +import { useCallback } from 'react' -const ShowLogButton = (): JSX.Element => { +const ShowLogButton = meme((): JSX.Element => { const showLog = useLayoutZustand((z) => z.showLog) - const toggleShowLog = useLayoutZustand((z) => z.toggleShowLog) + + const handleClick = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.toggleShowLog() + }, []) const variant: ButtonProps['variant'] = showLog ? 'contained' : 'outlined' const text = showLog ? 'Hide Log' : 'Show Log' return ( - ) -} +}) export default ShowLogButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx index b8ad2e8..137100d 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/TimeSettings/TimeSettings.tsx @@ -1,31 +1,53 @@ -import { Timer } from '@mui/icons-material' +import Timer from '@mui/icons-material/Timer' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import Paper from '@mui/material/Paper' import Popover from '@mui/material/Popover' import { meme } from '@renderer/components/shared/inputs/meme' import SliderComponent from '@renderer/components/shared/SliderComponent' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { useCallback, useState } from 'react' // Polling interval slider -const PollRate = (): JSX.Element => { - const value = useRootZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) - const setValue = useRootZustand((z) => z.setPollRate) +const PollRate = meme((): JSX.Element => { + const value = useClientZustand((z) => Math.floor(z.registerConfig.pollRate / 1000)) - return setValue(v * 1000)} /> -} + const handleChange = useCallback((seconds: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setPollRate(seconds * 1000) + }, []) + + return ( + + ) +}) // Read Timeout slider -const Timeout = (): JSX.Element => { - const value = useRootZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) - const setValue = useRootZustand((z) => z.setTimeout) +const Timeout = meme((): JSX.Element => { + const value = useClientZustand((z) => Math.floor(z.registerConfig.timeout / 1000)) - return setValue(v * 1000)} /> -} + const handleChange = useCallback((seconds: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setTimeout(seconds * 1000) + }, []) + + return ( + + ) +}) const TimeSettings = meme(() => { - const polling = useRootZustand((z) => z.clientState.polling) + const polling = useClientZustand((z) => z.clientState.polling) const [anchorEl, setAnchorEl] = useState(null) const handleOpenMenu = useCallback( diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx index 42de1f6..38969a0 100644 --- a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx +++ b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/ToggleEndianButton/ToggleEndianButton.tsx @@ -2,12 +2,19 @@ import ToggleButton from '@mui/material/ToggleButton' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import Tooltip from '@mui/material/Tooltip' import EndianTable from '@renderer/components/shared/inputs/EndianTable' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useCallback } from 'react' -const ToggleEndianButton = (): JSX.Element | null => { - const type = useRootZustand((z) => z.registerConfig.type) - const littleEndian = useRootZustand((z) => z.registerConfig.littleEndian) - const setLittleEndian = useRootZustand((z) => z.setLittleEndian) +const ToggleEndianButton = meme((): JSX.Element | null => { + const type = useClientZustand((z) => z.registerConfig.type) + const littleEndian = useClientZustand((z) => z.registerConfig.littleEndian) + + const handleChange = useCallback((_event: unknown, value: boolean | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setLittleEndian(value) + }, []) const registers16Bit = ['input_registers', 'holding_registers'].includes(type) if (!registers16Bit) return null @@ -24,7 +31,7 @@ const ToggleEndianButton = (): JSX.Element | null => { exclusive color="primary" value={littleEndian} - onChange={(_, v) => v !== null && setLittleEndian(v)} + onChange={handleChange} > { ) -} +}) export default ToggleEndianButton diff --git a/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/components/index.tsx b/src/renderer/src/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/components/index.tsx deleted file mode 100644 index e69de29..0000000 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 8659e32..c237032 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 @@ -1,78 +1,23 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { Publish } from '@mui/icons-material' -import { - Box, - Button, - ButtonGroup, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' +import Publish from '@mui/icons-material/Publish' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import ButtonGroup from '@mui/material/ButtonGroup' +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 ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useDataZustand } from '@renderer/context/data.zustand' import { useMinMaxInteger } from '@renderer/hooks' -import { BaseDataType, BaseDataTypeSchema, notEmpty, RegisterType } from '@shared' +import { notEmpty, RegisterType } from '@shared' import { ElementType, forwardRef, RefObject, useCallback, useEffect, useMemo } from 'react' import { IMaskInput, IMask } from 'react-imask' -import { create } from 'zustand' -import { mutative } from 'zustand-mutative' - -interface ValueInputZusand { - dataType: BaseDataType - setDataType: (dataType: BaseDataType) => void - value: string - valid: boolean - setValue: MaskSetFn - address: number - setAddress: (address: number) => void - coilFunction: 5 | 15 - setCoilFunction: (coilFunction: 5 | 15) => void - coils: boolean[] - initCoils: (coils: boolean[]) => void - setCoils: (coil: boolean, index: number) => void -} - -const useValueInputZustand = create( - mutative((set) => ({ - dataType: 'int16', - setDataType: (dataType) => - set((state) => { - state.dataType = dataType - }), - value: '0', - valid: true, - setValue: (value, valid) => - set((state) => { - state.value = value - state.valid = !!valid - }), - address: 0, - setAddress: (address: number) => - set((state) => { - state.address = address - }), - coilFunction: 5, - setCoilFunction: (coilFunction: 5 | 15) => - set((state) => { - state.coilFunction = coilFunction - }), - coils: [], - initCoils: (coils) => - set((state) => { - state.coils = coils - }), - setCoils: (coil, index) => - set((state) => { - state.coils[index] = coil - }) - })) -) +import { seedCoils, useValueInputZustand, writeDataTypeFor } from './writeModal.zustand' const ValueInputForward = forwardRef((props, ref) => { const { set, ...other } = props @@ -106,7 +51,8 @@ const ValueInput = meme(ValueInputForward) const ValueInputComponent = meme(({ address }: { address: number }) => { const value = useValueInputZustand((z) => z.value) const valid = useValueInputZustand((z) => z.valid) - const setValue = useValueInputZustand((z) => z.setValue) + + const setValue = useValueInputZustand.getState().setValue return ( { ) }) -const DataTypeSelect = meme(({ address }: { address: number }) => { +export const DataTypeSelect = meme(({ address }: { address: number }) => { const dataType = useValueInputZustand((z) => z.dataType) - const setDataType = useValueInputZustand((z) => z.setDataType) - // Set the data type based on the address if it's defined in the register mapping + const setDataType = useValueInputZustand.getState().setDataType + + // The type comes from the register mapping, and an address the mapping says + // nothing about gets the default rather than the last address's type. useEffect(() => { + const valueInputZustand = useValueInputZustand.getState() const { registerMapping, registerConfig: { type } - } = useRootZustand.getState() + } = useClientZustand.getState() - const dataType = registerMapping[type][address]?.dataType - if (!dataType) return - - const result = BaseDataTypeSchema.safeParse(dataType) - if (result.success) setDataType(result.data) - }, [address, setDataType]) + valueInputZustand.setDataType(writeDataTypeFor(registerMapping[type][address]?.dataType)) + }, [address]) return }) -const WriteRegistersButton = meme(() => { +export const WriteRegistersButton = meme(() => { const address = useValueInputZustand((z) => z.address) const dataType = useValueInputZustand((z) => z.dataType) const value = useValueInputZustand((z) => z.value) + const valid = useValueInputZustand((z) => z.valid) const handleWrite = useCallback( (single: boolean) => { @@ -166,9 +112,12 @@ const WriteRegistersButton = meme(() => { [address, dataType, value] ) + // An empty field is `Number('')`, which is 0, and 0 is a value the device + // accepts without complaint. The mask says whether anything was typed, so the + // buttons say what the red box already says. const singleDisabled = useMemo(() => { - return !['int16', 'uint16'].includes(dataType) - }, [dataType]) + return !valid || !['int16', 'uint16'].includes(dataType) + }, [valid, dataType]) return ( @@ -184,6 +133,7 @@ const WriteRegistersButton = meme(() => { ) -} +}) // // // // // Clears the transaction log -const ClearButton = (): JSX.Element => { - const clear = useRootZustand((z) => z.clearTransactions) +const ClearButton = meme((): JSX.Element => { + const handleClick = useCallback((): void => { + const clientZustand = useClientZustand.getState() + clientZustand.clearTransactions() + }, []) + return ( - ) -} +}) // // @@ -72,7 +81,7 @@ const CustomFooter = (): JSX.Element => { const TransactionGridContent = meme(() => { const api = useGridApiRef() - const transactions = useRootZustand((z) => z.transactions) + const transactions = useClientZustand((z) => z.transactions) const columns = useTransactionGridColumns() return ( @@ -114,7 +123,7 @@ const TransactionGridContent = meme(() => { // // // DataGrid paper -const TransactionGrid = (): JSX.Element => { +const TransactionGrid = meme((): JSX.Element => { return ( { ) -} +}) export default TransactionGrid diff --git a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx index b293ddb..9604fd5 100644 --- a/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx +++ b/src/renderer/src/components/client/ClientGrids/TransactionGrid/_columns.tsx @@ -1,10 +1,10 @@ -import { Box } from '@mui/material' -import { GridColDef } from '@mui/x-data-grid' +import Box from '@mui/material/Box' +import { GridColDef } from '@mui/x-data-grid/models' import { Transaction } from '@shared' import { DateTime } from 'luxon' import { useMemo } from 'react' -const typestampColumn: GridColDef = { +const timestampColumn: GridColDef = { field: 'timestamp', headerName: 'Timestamp', hideable: false, @@ -97,7 +97,7 @@ const errorMessageColumn: GridColDef = { const useTransactionGridColumns = (): GridColDef[] => { return useMemo(() => { return [ - typestampColumn, + timestampColumn, unitIdColumn, addressColumn, // lengthColumn, diff --git a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx index 6d89cf9..586297b 100644 --- a/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/ConnectionConfig.tsx @@ -1,19 +1,17 @@ -import { - Box, - Button, - ButtonProps, - CircularProgress, - InputBaseComponentProps, - TextField, - ToggleButton, - ToggleButtonGroup, - Tooltip -} from '@mui/material' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import { ButtonProps } from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import Tooltip from '@mui/material/Tooltip' import RtuConfig from './RtuConfig/RtuConfig' import SerialGroupModal from '@renderer/components/client/SerialGroupModal/SerialGroupModal' -import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/_zustand' +import { useSerialGroupZustand } from '@renderer/components/client/SerialGroupModal/serialGroupModal.zustand' import TcpConfig from './TcpConfig/TcpConfig' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { Protocol } from '@shared' import { ElementType, useCallback } from 'react' import { maskInputProps } from '@renderer/components/shared/inputs/types' @@ -23,8 +21,13 @@ import { meme } from '@renderer/components/shared/inputs/meme' // Protocol const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const setProtocol = useRootZustand((z) => z.setProtocol) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + + const handleChange = useCallback((_event: unknown, value: Protocol | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setProtocol(value) + }, []) // RTU over TCP is a TCP-family transport (toggled from the options menu), // so the TCP button stays highlighted for it -- but in warning colour, since @@ -55,7 +58,7 @@ const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { exclusive color="primary" value={toggleValue} - onChange={(_, v) => v !== null && setProtocol(v)} + onChange={handleChange} > {rtuOverTcp ? ( @@ -72,15 +75,14 @@ const ProtocolSelect = meme(({ protocol }: { protocol: Protocol }) => { }) const ConnectButton = meme(() => { - const connectState = useRootZustand((z) => z.clientState.connectState) - const setRegisterData = useDataZustand((z) => z.setRegisterData) + const connectState = useClientZustand((z) => z.clientState.connectState) const action = useCallback(async (): Promise => { - const currentConnectedState = useRootZustand.getState().clientState.connectState + const currentConnectedState = useClientZustand.getState().clientState.connectState if (['connecting', 'connected'].includes(currentConnectedState)) { window.api.disconnect() - if (!useRootZustand.getState().readConfiguration) { - setRegisterData([]) + if (!useClientZustand.getState().readConfiguration) { + useDataZustand.getState().setRegisterData([]) } return } @@ -88,13 +90,13 @@ const ConnectButton = meme(() => { if (currentConnectedState === 'disconnected') { // On RTU the port can be there and still refuse to open. Ask first and // say why, rather than let the connect fail on a permission error. - if (useRootZustand.getState().connectionConfig.protocol === 'ModbusRtu') { + if (useClientZustand.getState().connectionConfig.protocol === 'ModbusRtu') { const blocked = await useSerialGroupZustand.getState().check(true) if (blocked) return } window.api.connect() } - }, [setRegisterData]) + }, []) const disabled = ['disconnecting'].includes(connectState) @@ -134,7 +136,9 @@ const ConnectButton = meme(() => { // // Unit Id const UnitId = meme(() => { - const unitId = useRootZustand((z) => String(z.connectionConfig.unitId)) + const unitId = useClientZustand((z) => String(z.connectionConfig.unitId)) + + const setUnitId = useClientZustand.getState().setUnitId return ( { slotProps={{ input: { inputComponent: UnitIdInput as unknown as ElementType, - inputProps: maskInputProps({ set: useRootZustand.getState().setUnitId }) + inputProps: maskInputProps({ set: setUnitId }) } }} /> @@ -155,7 +159,7 @@ const UnitId = meme(() => { }) const ConnectionConfig = meme(() => { - const protocol = useRootZustand((z) => z.connectionConfig.protocol) + const protocol = useClientZustand((z) => z.connectionConfig.protocol) return ( <> {/* RTU over TCP reuses the TCP host/port inputs; only serial RTU uses the COM form. */} diff --git a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx index 6256a2d..fb40087 100644 --- a/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/RtuConfig/RtuConfig.tsx @@ -1,5 +1,10 @@ -import { Autocomplete, Box, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material' -import { CheckCircleOutlined, Refresh } from '@mui/icons-material' +import Autocomplete from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' +import Refresh from '@mui/icons-material/Refresh' import { meme } from '@renderer/components/shared/inputs/meme' import { BaudRateSelect, @@ -10,22 +15,34 @@ import { StopBitsSelect, useComInputWidth } from '@renderer/components/shared/inputs/SerialPortInputs' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import type { SerialPortOptions } from 'modbus-serial/ModbusRTU' import { useSnackbar } from 'notistack' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' // // // COM Port Input const ComInput = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const com = useRootZustand((z) => z.connectionConfig.rtu.com) - const comValid = useRootZustand((z) => z.valid.com) - const loading = useRootZustand((z) => z.serialPortsLoading) - const ports = useRootZustand((z) => z.serialPorts) + const disabled = useClientZustand((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) + const ports = useClientZustand((z) => z.serialPorts) const inputWidth = useComInputWidth(ports) + // 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) + }, []) + + const handleChange = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const clientZustand = useClientZustand.getState() + clientZustand.setCom(value, true) + }, []) + return ( { options={ports.map((p) => p.path)} value={com} data-testid="rtu-com-input" - onInputChange={(_event, newValue) => - useRootZustand.getState().setCom(newValue, newValue.trim().length > 0) - } - onChange={(_event, newValue) => { - if (newValue) useRootZustand.getState().setCom(newValue, true) - }} + onInputChange={handleInputChange} + onChange={handleChange} sx={{ width: inputWidth, maxWidth: 220 }} renderInput={(params) => ( @@ -55,20 +68,20 @@ const ComInput = meme(() => { // // COM Port Actions const ComActions = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const com = useRootZustand((z) => z.connectionConfig.rtu.com) - const loading = useRootZustand((z) => z.serialPortsLoading) - const validating = useRootZustand((z) => z.serialPortValidating) + const disabled = useClientZustand((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) const { enqueueSnackbar } = useSnackbar() const onRefresh = (): void => { - useRootZustand.getState().refreshSerialPorts() + useClientZustand.getState().refreshSerialPorts() } const onValidate = async (): Promise => { if (!com || com.trim() === '') return - const result = await useRootZustand.getState().validateSerialPort(com) - useRootZustand.getState().setCom(com, result.valid) + const result = await useClientZustand.getState().validateSerialPort(com) + useClientZustand.getState().setCom(com, result.valid) enqueueSnackbar({ message: result.message, variant: result.valid ? 'success' : 'warning' @@ -116,11 +129,11 @@ const ComActions = meme(() => { // // // COM Port (composite) -const Com = (): JSX.Element => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') +const Com = meme((): JSX.Element => { + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') useEffect(() => { - if (!disabled) useRootZustand.getState().refreshSerialPorts() + if (!disabled) useClientZustand.getState().refreshSerialPorts() }, [disabled]) return ( @@ -129,50 +142,54 @@ const Com = (): JSX.Element => { ) -} +}) // // // Selects (thin wrappers over shared components) const ClientBaudRateSelect = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const baudRate = useRootZustand((z) => z.connectionConfig.rtu.options.baudRate) - const setBaudRate = useRootZustand((z) => z.setBaudRate) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const baudRate = useClientZustand((z) => z.connectionConfig.rtu.options.baudRate) + + const setBaudRate = useClientZustand.getState().setBaudRate return }) const ClientParitySelect = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const parity = useRootZustand((z) => z.connectionConfig.rtu.options.parity ?? 'none') - const setParity = useRootZustand((z) => z.setParity) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const parity = useClientZustand((z) => z.connectionConfig.rtu.options.parity ?? 'none') - return ( - setParity(v as SerialPortOptions['parity'])} - disabled={disabled} - /> - ) + const setParity = useClientZustand.getState().setParity + + return }) const ClientDataBitsSelect = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const dataBits = useRootZustand((z) => z.connectionConfig.rtu.options.dataBits) - const setDataBits = useRootZustand((z) => z.setDataBits) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const dataBits = useClientZustand((z) => z.connectionConfig.rtu.options.dataBits) - return + const handleChange = useCallback((value: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setDataBits(value as SerialPortOptions['dataBits']) + }, []) + + return }) const ClientStopBitsSelect = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const stopBits = useRootZustand((z) => z.connectionConfig.rtu.options.stopBits) - const setStopBits = useRootZustand((z) => z.setStopBits) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const stopBits = useClientZustand((z) => z.connectionConfig.rtu.options.stopBits) + + const handleChange = useCallback((value: number): void => { + const clientZustand = useClientZustand.getState() + clientZustand.setStopBits(value as SerialPortOptions['stopBits']) + }, []) - return + return }) -const RtuConfig = (): JSX.Element => { +const RtuConfig = meme((): JSX.Element => { return ( @@ -186,5 +203,5 @@ const RtuConfig = (): JSX.Element => { ) -} +}) export default RtuConfig diff --git a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx index f9941b7..b78352b 100644 --- a/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx +++ b/src/renderer/src/components/client/ConnectionConfig/TcpConfig/TcpConfig.tsx @@ -1,17 +1,20 @@ -import { TextField, Box, InputBaseComponentProps } from '@mui/material' +import Box from '@mui/material/Box' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' import HostInput from '@renderer/components/shared/inputs/HostInput' 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 { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' import { ElementType } from 'react' // Host const Host = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const host = useRootZustand((z) => z.connectionConfig.tcp.host) - const hostValid = useRootZustand((z) => z.valid.host) - const setHost = useRootZustand((z) => z.setHost) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const host = useClientZustand((z) => z.connectionConfig.tcp.host) + const hostValid = useClientZustand((z) => z.valid.host) + + const setHost = useClientZustand.getState().setHost return ( { // // Port const Port = meme(() => { - const disabled = useRootZustand((z) => z.clientState.connectState !== 'disconnected') - const port = useRootZustand((z) => String(z.connectionConfig.tcp.options.port)) + const disabled = useClientZustand((z) => z.clientState.connectState !== 'disconnected') + const port = useClientZustand((z) => String(z.connectionConfig.tcp.options.port)) + + const setPort = useClientZustand.getState().setPort return ( { slotProps={{ input: { inputComponent: UIntInput as unknown as ElementType, - inputProps: maskInputProps({ set: useRootZustand.getState().setPort }) + inputProps: maskInputProps({ set: setPort }) } }} /> ) }) -const TcpConfig = (): JSX.Element => { +const TcpConfig = meme((): JSX.Element => { return ( @@ -67,5 +72,5 @@ const TcpConfig = (): JSX.Element => { ) -} +}) export default TcpConfig diff --git a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx index 97eaa54..38ab7b5 100644 --- a/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx +++ b/src/renderer/src/components/client/RegisterConfig/RegisterConfig.tsx @@ -1,21 +1,19 @@ -import { List } from '@mui/icons-material' -import { - MenuItem, - FormControl, - InputLabel, - Select, - TextField, - Box, - ToggleButtonGroup, - ToggleButton, - InputBaseComponentProps -} from '@mui/material' +import List from '@mui/icons-material/List' +import Box from '@mui/material/Box' +import FormControl from '@mui/material/FormControl' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import AddressBaseInput from '@renderer/components/shared/inputs/AddressBaseInput' import LengthInput from '@renderer/components/shared/inputs/LengthInput' import { meme } from '@renderer/components/shared/inputs/meme' import { maskInputProps } from '@renderer/components/shared/inputs/types' import { useDataZustand } from '@renderer/context/data.zustand' -import { useRootZustand } from '@renderer/context/root.zustand' +import { flushRegisterMappingToMain, useClientZustand } from '@renderer/context/client.zustand' import { RegisterType } from '@shared' import { showMapping } from '@renderer/context/data.zustand' import { ElementType, useCallback, useEffect } from 'react' @@ -23,13 +21,13 @@ import { ElementType, useCallback, useEffect } from 'react' // Protocol const TypeSelect = meme(() => { const labelId = 'register-type-select' - const type = useRootZustand((z) => z.registerConfig.type) + const type = useClientZustand((z) => z.registerConfig.type) const handleChange = useCallback((type: RegisterType) => { - if (!useRootZustand.getState().readConfiguration) { + if (!useClientZustand.getState().readConfiguration) { useDataZustand.getState().setRegisterData([]) } - useRootZustand.getState().setType(type) + useClientZustand.getState().setType(type) }, []) return ( @@ -56,9 +54,10 @@ const TypeSelect = meme(() => { // // Address const Address = meme(() => { - const address = useRootZustand((z) => z.registerConfig.address) - const setAddress = useRootZustand((z) => z.setAddress) - const readConfiguration = useRootZustand((z) => z.readConfiguration) + const address = useClientZustand((z) => z.registerConfig.address) + const readConfiguration = useClientZustand((z) => z.readConfiguration) + + const setAddress = useClientZustand.getState().setAddress return ( { // // Length const Length = meme(() => { - const length = useRootZustand((z) => String(z.registerConfig.length)) - const lengthValid = useRootZustand((z) => z.valid.lenght) - const setLength = useRootZustand((z) => z.setLength) - const address = useRootZustand((z) => z.registerConfig.address) - const readConfiguration = useRootZustand((z) => z.readConfiguration) + const length = useClientZustand((z) => String(z.registerConfig.length)) + const lengthValid = useClientZustand((z) => z.valid.lenght) + const address = useClientZustand((z) => z.registerConfig.address) + const readConfiguration = useClientZustand((z) => z.readConfiguration) + + const setLength = useClientZustand.getState().setLength return ( { }) const ReadConfiguration = meme(() => { - const readConfiguration = useRootZustand((z) => !!z.readConfiguration) + const readConfiguration = useClientZustand((z) => !!z.readConfiguration) const handleChange = useCallback((_: 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 if (toggleState) { - window.api.setRegisterMapping(useRootZustand.getState().registerMapping) + flushRegisterMappingToMain() showMapping() } - useRootZustand.getState().setReadConfiguration(toggleState) + useClientZustand.getState().setReadConfiguration(toggleState) }, []) - const disabled = useRootZustand( + const disabled = useClientZustand( (z) => Object.keys(z.registerMapping[z.registerConfig.type]).length === 0 ) useEffect(() => { if (!disabled) return - const state = useRootZustand.getState() - if (disabled && state.readConfiguration) state.setReadConfiguration(false) + const clientZustand = useClientZustand.getState() + if (disabled && clientZustand.readConfiguration) clientZustand.setReadConfiguration(false) }, [disabled]) return ( diff --git a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx index 4fbb169..a2fc617 100644 --- a/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/SerialGroupModal.tsx @@ -1,18 +1,17 @@ -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Typography -} from '@mui/material' +import Alert from '@mui/material/Alert' +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 DialogTitle from '@mui/material/DialogTitle' +import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' -import { useRootZustand } from '@renderer/context/root.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' import { SerialGroupStatus, serialGroupCommandDisplay } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' -import { useSerialGroupZustand } from './_zustand' +import { useSerialGroupZustand } from './serialGroupModal.zustand' /** * Linux serial group modal @@ -59,7 +58,7 @@ const decline = (): void => { // // // The command, built from whoever is logged in -const Command = (): JSX.Element => { +const Command = meme((): JSX.Element => { const username = useSerialGroupZustand((z) => z.status?.username) // The group the refusing device actually belongs to, not an assumed dialout. const group = useSerialGroupZustand((z) => z.status?.group) @@ -69,12 +68,12 @@ const Command = (): JSX.Element => { testId="serial-group-command" /> ) -} +}) // // // After the command has run: in the file, not yet in the session -const PendingLogin = (): JSX.Element => { +const PendingLogin = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) return ( { in before Modbux can open a port. ) -} +}) // // // Before it has run: what is wrong, and what will fix it -const Explanation = (): JSX.Element => { +const Explanation = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) const username = useSerialGroupZustand((z) => z.status?.username) // A string or null, so it compares by value like any other primitive. @@ -123,29 +122,29 @@ const Explanation = (): JSX.Element => { ) -} +}) // // // Body -const Body = (): JSX.Element => { +const Body = meme((): JSX.Element => { const done = useSerialGroupZustand((z) => z.done) return done ? : -} +}) // // // Buttons -const NotNowButton = (): JSX.Element => { +const NotNowButton = meme((): JSX.Element => { const busy = useSerialGroupZustand((z) => z.busy) return ( ) -} +}) -const RunCommandButton = (): JSX.Element | null => { +const RunCommandButton = meme((): JSX.Element | null => { const busy = useSerialGroupZustand((z) => z.busy) const blocked = useSerialGroupZustand((z) => blockedReason(z.status)) const { enqueueSnackbar } = useSnackbar() @@ -171,22 +170,26 @@ const RunCommandButton = (): JSX.Element | null => { {busy ? 'Waiting for authorization…' : 'Run command'} ) -} +}) + +const LaterButton = meme((): JSX.Element => { + const handleClick = useCallback((): void => { + const serialGroupZustand = useSerialGroupZustand.getState() + serialGroupZustand.setOpen(false) + }, []) -const LaterButton = (): JSX.Element => { - const setOpen = useSerialGroupZustand((z) => z.setOpen) return ( - ) -} +}) -const LogoutButton = (): JSX.Element => { - const setOpen = useSerialGroupZustand((z) => z.setOpen) +const LogoutButton = meme((): JSX.Element => { const { enqueueSnackbar } = useSnackbar() const logout = useCallback(async (): Promise => { + const serialGroupZustand = useSerialGroupZustand.getState() const asked = await window.api.requestLogout() if (!asked) { enqueueSnackbar({ @@ -194,17 +197,17 @@ const LogoutButton = (): JSX.Element => { variant: 'info' }) } - setOpen(false) - }, [enqueueSnackbar, setOpen]) + serialGroupZustand.setOpen(false) + }, [enqueueSnackbar]) return ( ) -} +}) -const Actions = (): JSX.Element => { +const Actions = meme((): JSX.Element => { const done = useSerialGroupZustand((z) => z.done) return ( @@ -221,32 +224,32 @@ const Actions = (): JSX.Element => { )} ) -} +}) // // // Title -const Title = (): JSX.Element => { +const Title = meme((): JSX.Element => { const group = useSerialGroupZustand((z) => z.status?.group) return Serial ports need the {group} group -} +}) // // // MAIN -interface Props { +interface SerialGroupModalProps { /** True while RTU is the selected transport. The check runs then, and only then. */ active: boolean } -const SerialGroupModal = ({ active }: Props): JSX.Element | null => { +const SerialGroupModal = meme(({ active }: SerialGroupModalProps): JSX.Element | null => { const open = useSerialGroupZustand((z) => z.open) const hasStatus = useSerialGroupZustand((z) => z.status !== null) // Which ports exist, as one string so it compares by value. Selecting RTU is // not the only moment this matters: plugging an adapter in afterwards is the // other one, and the list only changes when something is refreshed. - const ports = useRootZustand((z) => z.serialPorts.map((p) => p.path).join(',')) + const ports = useClientZustand((z) => z.serialPorts.map((p) => p.path).join(',')) useEffect(() => { if (!active) return @@ -265,14 +268,19 @@ const SerialGroupModal = ({ active }: Props): JSX.Element | null => { } }, [active, ports]) + // Read busy rather than subscribe to it: the shell has no other reason to + // re-render while the command runs. + const handleClose = useCallback((): void => { + const serialGroupZustand = useSerialGroupZustand.getState() + if (!serialGroupZustand.busy) decline() + }, []) + if (!hasStatus) return null return ( !useSerialGroupZustand.getState().busy && decline()} + onClose={handleClose} maxWidth="sm" fullWidth data-testid="serial-group-modal" @@ -284,6 +292,6 @@ const SerialGroupModal = ({ active }: Props): JSX.Element | null => { ) -} +}) export default SerialGroupModal diff --git a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx index 206f14c..c5aa85a 100644 --- a/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx +++ b/src/renderer/src/components/client/SerialGroupModal/__tests__/SerialGroupModal.test.tsx @@ -1,20 +1,30 @@ // @vitest-environment happy-dom /// -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { create } from 'zustand' import type { SerialGroupStatus, SerialGroupFixResult } from '@shared' // ─── Store stub ────────────────────────────────────────────────────── // The real root store registers ipcRenderer listeners on import, which is far // more machinery than this component needs. Only the port list matters here. -const rootState = { serialPorts: [] as { path: string }[] } +interface RootStub { + serialPorts: { path: string }[] +} -vi.mock('@renderer/context/root.zustand', () => ({ - useRootZustand: Object.assign( - (selector: (state: typeof rootState) => unknown) => selector(rootState), - { getState: () => rootState } +// A real store, not a plain object: a plugged-in adapter reaches the component +// by the store notifying it, and a stub that is only read during a render can +// only be driven by re-rendering the parent, which memo refuses. +const useRootStub = create(() => ({ serialPorts: [] })) + +vi.mock('@renderer/context/client.zustand', () => ({ + useClientZustand: Object.assign( + (selector: (state: RootStub) => unknown) => useRootStub(selector), + { + getState: (): RootStub => useRootStub.getState() + } ) })) @@ -26,7 +36,7 @@ vi.mock('notistack', () => ({ })) import SerialGroupModal from '../SerialGroupModal' -import { useSerialGroupZustand } from '../_zustand' +import { useSerialGroupZustand } from '../serialGroupModal.zustand' // ─── window.api stub ───────────────────────────────────────────────── @@ -75,7 +85,7 @@ describe('SerialGroupModal', () => { done: false, declined: false }) - rootState.serialPorts = [] + useRootStub.setState({ serialPorts: [] }) mockGetStatus.mockResolvedValue(needsMembership) mockApplyFix.mockResolvedValue(okResult) mockRequestLogout.mockResolvedValue(true) @@ -206,14 +216,13 @@ describe('SerialGroupModal', () => { it('checks again when an adapter is plugged in while RTU is already selected', async () => { // Nothing plugged in: every port opens, so there is nothing to say. mockGetStatus.mockResolvedValue({ ...needsMembership, needsMembership: false }) - const { rerender } = render() + render() await waitFor(() => expect(mockGetStatus).toHaveBeenCalledTimes(1)) expect(screen.queryByTestId('serial-group-modal')).not.toBeInTheDocument() // Refreshing the list is how a newly plugged adapter shows up. mockGetStatus.mockResolvedValue(needsMembership) - rootState.serialPorts = [{ path: '/dev/ttyACM0' }] - rerender() + act(() => useRootStub.setState({ serialPorts: [{ path: '/dev/ttyACM0' }] })) expect(await screen.findByTestId('serial-group-modal')).toBeInTheDocument() }) diff --git a/src/renderer/src/components/client/SerialGroupModal/_zustand.ts b/src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts similarity index 100% rename from src/renderer/src/components/client/SerialGroupModal/_zustand.ts rename to src/renderer/src/components/client/SerialGroupModal/serialGroupModal.zustand.ts diff --git a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx index 911f7f9..7b043c7 100644 --- a/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx +++ b/src/renderer/src/components/server/OpenSaveClear/OpenSaveClear.tsx @@ -1,6 +1,10 @@ -import { FileOpen, Save, Delete } from '@mui/icons-material' -import { Box, IconButton } from '@mui/material' +import Delete from '@mui/icons-material/Delete' +import FileOpen from '@mui/icons-material/FileOpen' +import Save from '@mui/icons-material/Save' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' import { meme } from '@renderer/components/shared/inputs/meme' +import { useLayoutZustand } from '@renderer/context/layout.zustand' import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, migrateServerConfig } from '@shared' import { ServerConfig, ServerRegistersPerUnit, UnitIdStringSchema } from '@shared' @@ -30,17 +34,17 @@ const useOpen: UseOpenHook = () => { openingRef.current = true setOpening(true) - const state = useServerZustand.getState() + const serverZustand = useServerZustand.getState() // Reset the server before opening a new configuration // This way we can ensure that the server is in a clean state // When unitId's are configured which are not present in the file // there would be remaining registers in the server because // they are now overwritten - await window.api.resetServer(state.selectedUuid) + await window.api.resetServer(serverZustand.selectedUuid) // Also clean the zustand state // to ensure that the state is in a clean state - state.clean(state.selectedUuid) + serverZustand.clean(serverZustand.selectedUuid) const content = await file.text() @@ -50,8 +54,8 @@ const useOpen: UseOpenHook = () => { const { config, migrated, warning, wasMixedEndianness } = migrationResult // Set name and littleEndian - state.setName(config.name) - state.setLittleEndian(config.littleEndian) + serverZustand.setName(config.name) + serverZustand.setLittleEndian(config.littleEndian) // Load all unit configs for (const unitId of UnitIdStringSchema.options) { @@ -59,7 +63,7 @@ const useOpen: UseOpenHook = () => { if (!serverRegisters) continue const hasConfig = checkHasConfig(serverRegisters) if (!hasConfig) continue - state.replaceServerRegisters(unitId, serverRegisters) + serverZustand.replaceServerRegisters(unitId, serverRegisters) await new Promise((r) => setTimeout(r, 1)) } @@ -102,7 +106,7 @@ const useOpen: UseOpenHook = () => { } // Synchronize only the selected server after opening the configuration - await state.init(state.selectedUuid) + await serverZustand.init(serverZustand.selectedUuid) openingRef.current = false setOpening(false) @@ -121,10 +125,10 @@ type UseSaveHook = () => { } const useSave: UseSaveHook = () => { - const save = useCallback(async () => { - const z = useServerZustand.getState() - const { serverRegisters, selectedUuid, littleEndian } = z - const name = z.name[selectedUuid] ?? '' + const save = useCallback(() => { + const serverZustand = useServerZustand.getState() + const { serverRegisters, selectedUuid, littleEndian } = serverZustand + const name = serverZustand.name[selectedUuid] ?? '' const serverRegistersPerUnit: ServerRegistersPerUnit = {} @@ -136,8 +140,8 @@ const useSave: UseSaveHook = () => { serverRegistersPerUnit[unitId] = registers }) - // Get app version - const modbuxVersion = await window.api.getAppVersion() + // The store reads the version once at startup; it cannot change after that + const modbuxVersion = useLayoutZustand.getState().version const config: ServerConfig = { version: 2, @@ -171,17 +175,17 @@ const OpenSaveClear = meme(() => { const { save } = useSave() const clear = useCallback(async () => { - const state = useServerZustand.getState() - state.setName('') + const serverZustand = useServerZustand.getState() + serverZustand.setName('') // Reset the server before opening a new configuration // This way we can ensure that the server is in a clean state // When unitId's are configured which are not present in the file // there would be remaining registers in the server because // they are now overwritten - await window.api.resetServer(state.selectedUuid) + await window.api.resetServer(serverZustand.selectedUuid) // Also clean the zustand state // to ensure that the state is in a clean state - state.clean(state.selectedUuid) + serverZustand.clean(serverZustand.selectedUuid) }, []) return ( diff --git a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx index eb1069e..9c9296e 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/PrivilegedPortModal.tsx @@ -1,17 +1,16 @@ -import { - Alert, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - ToggleButton, - ToggleButtonGroup, - Typography -} from '@mui/material' +import Alert from '@mui/material/Alert' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import FormControlLabel from '@mui/material/FormControlLabel' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import Typography from '@mui/material/Typography' import CommandBlock from '@renderer/components/shared/CommandBlock' +import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' import { PrivilegedPortFixMode, @@ -20,8 +19,8 @@ import { UNPRIVILEGED_PORT_START_TARGET } from '@shared' import { useSnackbar } from 'notistack' -import { useCallback, useEffect } from 'react' -import { usePrivilegedPortZustand } from './_zustand' +import { ChangeEvent, useCallback, useEffect } from 'react' +import { usePrivilegedPortZustand } from './privilegedPortModal.zustand' /** * Linux privileged port modal @@ -63,15 +62,15 @@ const close = (): void => { // // // Title -const Title = (): JSX.Element => { +const Title = meme((): JSX.Element => { const port = usePrivilegedPortZustand((z) => z.status?.port) return Port {port} needs a system setting -} +}) // // // What is in the way -const Explanation = (): JSX.Element => { +const Explanation = meme((): JSX.Element => { const port = usePrivilegedPortZustand((z) => z.status?.port) const floor = usePrivilegedPortZustand((z) => z.status?.unprivilegedPortStart) @@ -88,14 +87,19 @@ const Explanation = (): JSX.Element => { : `Until that floor is lowered, Modbux cannot use it and clients looking for ${port} will not find it.`} ) -} +}) // // // Permanently or until reboot, driving both the command shown and the one run -const ModeToggle = (): JSX.Element => { +const ModeToggle = meme((): JSX.Element => { const mode = usePrivilegedPortZustand((z) => z.mode) - const setMode = usePrivilegedPortZustand((z) => z.setMode) + + const handleChange = useCallback((_event: unknown, value: PrivilegedPortFixMode | null): void => { + if (!value) return + const privilegedPortZustand = usePrivilegedPortZustand.getState() + privilegedPortZustand.setMode(value) + }, []) return ( { exclusive color="primary" value={mode} - onChange={(_, value: PrivilegedPortFixMode | null) => value && setMode(value)} + onChange={handleChange} sx={{ mb: 1.5 }} > @@ -114,12 +118,12 @@ const ModeToggle = (): JSX.Element => { ) -} +}) // // // The command, which follows the toggle so the two cannot drift apart -const Command = (): JSX.Element => { +const Command = meme((): JSX.Element => { const mode = usePrivilegedPortZustand((z) => z.mode) const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) @@ -130,14 +134,18 @@ const Command = (): JSX.Element => { testId="privileged-port-command" /> ) -} +}) // // // Don't ask again -const DontAskCheckbox = (): JSX.Element => { +const DontAskCheckbox = meme((): JSX.Element => { const dontAsk = usePrivilegedPortZustand((z) => z.dontAsk) - const setDontAsk = usePrivilegedPortZustand((z) => z.setDontAsk) + + const handleChange = useCallback((event: ChangeEvent): void => { + const privilegedPortZustand = usePrivilegedPortZustand.getState() + privilegedPortZustand.setDontAsk(event.target.checked) + }, []) return ( { setDontAsk(e.target.checked)} + onChange={handleChange} data-testid="privileged-port-dont-ask" /> } label={Don't ask again} /> ) -} +}) // // // Body -const Body = (): JSX.Element => { +const Body = meme((): JSX.Element => { const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) return ( @@ -186,21 +194,21 @@ const Body = (): JSX.Element => { ) -} +}) // // // Buttons -const CancelButton = (): JSX.Element => { +const CancelButton = meme((): JSX.Element => { const busy = usePrivilegedPortZustand((z) => z.busy) return ( ) -} +}) -const RunCommandButton = (): JSX.Element | null => { +const RunCommandButton = meme((): JSX.Element | null => { const busy = usePrivilegedPortZustand((z) => z.busy) const blocked = usePrivilegedPortZustand((z) => blockedReason(z.status)) const { enqueueSnackbar } = useSnackbar() @@ -210,6 +218,10 @@ const RunCommandButton = (): JSX.Element | null => { setBusy(true) try { const result = await window.api.applyPrivilegedPortFix(mode) + // undefined means the payload was refused at the boundary, which already + // sent its own message. Saying so twice helps nobody. + if (!result) return + enqueueSnackbar({ message: result.message, variant: result.ok ? 'success' : 'warning' }) if (!result.ok) return @@ -231,19 +243,22 @@ const RunCommandButton = (): JSX.Element | null => { {busy ? 'Waiting for authorization…' : 'Run command'} ) -} +}) // // // MAIN -const PrivilegedPortModal = (): JSX.Element | null => { +const PrivilegedPortModal = meme((): JSX.Element | null => { const open = usePrivilegedPortZustand((z) => z.open) const hasStatus = usePrivilegedPortZustand((z) => z.status !== null) const ready = useServerZustand((z) => !!z.ready[z.selectedUuid]) useEffect(() => { - // The popped-out server window would otherwise show a second copy. - if (window.api.isServerWindow) return + // Whichever window is showing the server view asks, and never both: the + // main window is put on the client view for as long as a server window + // exists, so this is mounted once. Asking only the main window left the + // question unasked in split view, which is the state a user who splits + // from Home is in from the start. if (localStorage.getItem(DISMISS_KEY) === 'true') return if (!ready) return @@ -286,6 +301,6 @@ const PrivilegedPortModal = (): JSX.Element | null => { ) -} +}) export default PrivilegedPortModal 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 ea76976..c769e7c 100644 --- a/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx +++ b/src/renderer/src/components/server/PrivilegedPortModal/__tests__/PrivilegedPortModal.test.tsx @@ -32,7 +32,7 @@ vi.mock('notistack', () => ({ })) import PrivilegedPortModal from '../PrivilegedPortModal' -import { usePrivilegedPortZustand } from '../_zustand' +import { usePrivilegedPortZustand } from '../privilegedPortModal.zustand' // ─── window.api stub ───────────────────────────────────────────────── @@ -103,10 +103,10 @@ describe('PrivilegedPortModal', () => { expect(screen.queryByTestId('privileged-port-modal')).not.toBeInTheDocument() }) - it('stays closed in the popped-out server window', async () => { + it('asks in the popped-out server window, which is the only one showing the server', async () => { window.api.isServerWindow = true render() - await waitFor(() => expect(mockGetStatus).not.toHaveBeenCalled()) + expect(await screen.findByTestId('privileged-port-modal')).toBeInTheDocument() }) it('stays closed once dismissed for good', async () => { diff --git a/src/renderer/src/components/server/PrivilegedPortModal/_zustand.ts b/src/renderer/src/components/server/PrivilegedPortModal/privilegedPortModal.zustand.ts similarity index 100% rename from src/renderer/src/components/server/PrivilegedPortModal/_zustand.ts rename to src/renderer/src/components/server/PrivilegedPortModal/privilegedPortModal.zustand.ts diff --git a/src/renderer/src/components/server/SelectServer/SelectServer.tsx b/src/renderer/src/components/server/SelectServer/SelectServer.tsx index fc966af..7abf759 100644 --- a/src/renderer/src/components/server/SelectServer/SelectServer.tsx +++ b/src/renderer/src/components/server/SelectServer/SelectServer.tsx @@ -1,4 +1,5 @@ -import { Add, Delete } from '@mui/icons-material' +import Add from '@mui/icons-material/Add' +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' @@ -26,15 +27,21 @@ const SelectServer = meme(() => { const addDisabled = useServerZustand((z) => Object.keys(z.uuids).length >= 10) const addServer = useCallback(async () => { - const z = useServerZustand.getState() - const newPort = findAvailablePort(Object.values(z.port).map((v) => Number(v))) + const serverZustand = useServerZustand.getState() + const newPort = findAvailablePort(Object.values(serverZustand.port).map((v) => Number(v))) if (!newPort) throw new Error('No available port') - z.createServer({ port: newPort, uuid: v4() }) + serverZustand.createServer({ port: newPort, uuid: v4() }) }, []) const deleteServer = useCallback(() => { - const z = useServerZustand.getState() - z.deleteServer(z.selectedUuid) + const serverZustand = useServerZustand.getState() + serverZustand.deleteServer(serverZustand.selectedUuid) + }, []) + + const handleSelect = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const serverZustand = useServerZustand.getState() + serverZustand.setSelectedUuid(value) }, []) if (serverMode === 'rtu') return null @@ -67,10 +74,7 @@ const SelectServer = meme(() => { color="primary" value={selectedUuid} exclusive - onChange={(_, v) => { - if (!v) return - useServerZustand.getState().setSelectedUuid(v) - }} + onChange={handleSelect} > {serverUuids.map((uuid) => ( diff --git a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx index c84dc25..55613e1 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerConfig.tsx @@ -1,11 +1,9 @@ import FormControl from '@mui/material/FormControl' -import { - TextField, - Box, - InputBaseComponentProps, - ToggleButtonGroup, - ToggleButton -} from '@mui/material' +import Box from '@mui/material/Box' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import InputLabel from '@mui/material/InputLabel' import { meme } from '@renderer/components/shared/inputs/meme' import { MaskInputProps, maskInputProps } from '@renderer/components/shared/inputs/types' @@ -13,21 +11,22 @@ import { useServerZustand } from '@renderer/context/server.zustand' import { checkHasConfig, ServerMode } from '@shared' import { ElementType, forwardRef } from 'react' import { IMaskInput, IMask } from 'react-imask' -import Select from '@mui/material/Select' +import Select, { SelectChangeEvent } from '@mui/material/Select' import { UnitIdString, UnitIdStringSchema } from '@shared' import MenuItem from '@mui/material/MenuItem' -import React, { useState } from 'react' -import ServerRtuConfig from './ServerRtuConfig' +import React, { useCallback, useState } from 'react' +import ServerRtuConfig from './ServerRtuConfig/ServerRtuConfig' const ModeToggle = meme(() => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') const handleModeChange = async (_: React.MouseEvent, value: ServerMode | null): Promise => { + const serverZustand = useServerZustand.getState() if (!value || value === serverMode) return if (value === 'rtu') { - await useServerZustand.getState().switchToRtu() + await serverZustand.switchToRtu() } else { - await useServerZustand.getState().switchToTcp() + await serverZustand.switchToTcp() } } @@ -52,9 +51,14 @@ const ModeToggle = meme(() => { const EndianToggle = meme(() => { const selectedUuid = useServerZustand((z) => z.selectedUuid) const littleEndian = useServerZustand((z) => z.littleEndian[selectedUuid] ?? false) - const setLittleEndian = useServerZustand((z) => z.setLittleEndian) const ready = useServerZustand((z) => z.ready[selectedUuid]) + const handleChange = useCallback((_event: unknown, value: boolean | null): void => { + if (value === null) return + const serverZustand = useServerZustand.getState() + serverZustand.setLittleEndian(value) + }, []) + return ( { color="primary" value={littleEndian} disabled={!ready} - onChange={(_, v) => v !== null && setLittleEndian(v)} + onChange={handleChange} > { }) const labelId = 'unit-id-select' + const handleChange = useCallback((event: SelectChangeEvent): void => { + const serverZustand = useServerZustand.getState() + const result = UnitIdStringSchema.safeParse(event.target.value) + if (result.success) serverZustand.setUnitId(result.data) + }, []) + return ( Unit ID @@ -130,10 +140,7 @@ const UnitId = meme(() => { labelId={labelId} value={unitId} label="Unit ID" - onChange={(e) => { - const result = UnitIdStringSchema.safeParse(e.target.value) - if (result.success) useServerZustand.getState().setUnitId(result.data) - }} + onChange={handleChange} slotProps={{ input: { sx: { pr: 0, pl: 1 } } }} > {UnitIdStringSchema.options.map((unitId) => ( @@ -148,7 +155,7 @@ const UnitId = meme(() => { const PortInput = forwardRef((props, ref) => { const { set, ...other } = props - const portFromStore = useServerZustand((z) => z.port[z.selectedUuid]) + const portFromStore = useServerZustand((z) => z.port[z.selectedUuid] ?? '') const [localPort, setLocalPort] = useState(portFromStore) // Sync localPort with store if store changes (e.g. after backend update) @@ -187,6 +194,8 @@ PortInput.displayName = 'PortInput' const Port = meme(() => { const port = useServerZustand((z) => z.port[z.selectedUuid]) + const setPort = useServerZustand.getState().setPort + return ( { slotProps={{ input: { inputComponent: PortInput as unknown as ElementType, - inputProps: maskInputProps({ - set: useServerZustand.getState().setPort - }) + inputProps: maskInputProps({ set: setPort }) } }} /> @@ -210,7 +217,7 @@ const Port = meme(() => { // // // Server Config -const ServerConfig = (): JSX.Element => { +const ServerConfig = meme((): JSX.Element => { const serverMode = useServerZustand((z) => z.serverMode ?? 'tcp') return ( @@ -221,6 +228,6 @@ const ServerConfig = (): JSX.Element => { ) -} +}) export default ServerConfig diff --git a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx similarity index 76% rename from src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx rename to src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx index 3e94e1e..61ee1de 100644 --- a/src/renderer/src/components/server/ServerConfig/ServerRtuConfig.tsx +++ b/src/renderer/src/components/server/ServerConfig/ServerRtuConfig/ServerRtuConfig.tsx @@ -1,12 +1,12 @@ -import { - alpha, - Autocomplete, - Box, - CircularProgress, - ToggleButton, - ToggleButtonGroup -} from '@mui/material' -import { Refresh, Usb, UsbOff } from '@mui/icons-material' +import Autocomplete from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { alpha } from '@mui/material/styles' +import Refresh from '@mui/icons-material/Refresh' +import Usb from '@mui/icons-material/Usb' +import UsbOff from '@mui/icons-material/UsbOff' import { meme } from '@renderer/components/shared/inputs/meme' import { BaudRateSelect, @@ -19,7 +19,7 @@ import { } from '@renderer/components/shared/inputs/SerialPortInputs' import { useServerZustand } from '@renderer/context/server.zustand' import { ModbusBaudRate } from '@shared' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' // // @@ -37,12 +37,22 @@ const ComInput = meme(() => { }, [comFromStore]) const applyOnBlur = (): void => { + const serverZustand = useServerZustand.getState() if (localCom !== comFromStore) { - useServerZustand.getState().setServerCom(localCom) - useServerZustand.getState().applyServerCom() + serverZustand.setServerCom(localCom) + serverZustand.applyServerCom() } } + // Picking from the dropdown applies at once; typing waits for the blur. + const handleChange = useCallback((_event: unknown, value: string | null): void => { + if (!value) return + const serverZustand = useServerZustand.getState() + setLocalCom(value) + serverZustand.setServerCom(value) + serverZustand.applyServerCom() + }, []) + const comLabel = comFromStore ? `COM ${comFromStore}` : 'COM Port' const comError = !comFromStore || comFromStore.trim().length === 0 @@ -53,14 +63,7 @@ const ComInput = meme(() => { value={localCom} data-testid="server-rtu-com-input" onInputChange={(_event, newValue) => setLocalCom(newValue)} - onChange={(_event, newValue) => { - if (newValue) { - setLocalCom(newValue) - // Dropdown selection: apply immediately - useServerZustand.getState().setServerCom(newValue) - useServerZustand.getState().applyServerCom() - } - }} + onChange={handleChange} onBlur={applyOnBlur} sx={{ width: inputWidth, maxWidth: 220 }} renderInput={(params) => ( @@ -159,7 +162,7 @@ const ComActions = meme(() => { // // // COM Port (composite) -const Com = (): JSX.Element => { +const Com = meme((): JSX.Element => { useEffect(() => { useServerZustand.getState().refreshServerSerialPorts() }, []) @@ -171,7 +174,7 @@ const Com = (): JSX.Element => { ) -} +}) // // @@ -179,10 +182,12 @@ const Com = (): JSX.Element => { const ServerBaudRateSelect = meme(() => { const baudRate = useServerZustand((z) => z.serialConfig?.options.baudRate ?? '9600') + const setServerBaudRate = useServerZustand.getState().setServerBaudRate + return ( useServerZustand.getState().setServerBaudRate(v)} + onChange={setServerBaudRate} testId="server-rtu-baudrate-select" /> ) @@ -191,22 +196,22 @@ const ServerBaudRateSelect = meme(() => { const ServerParitySelect = meme(() => { const parity = useServerZustand((z) => z.serialConfig?.options.parity ?? 'none') + const setServerParity = useServerZustand.getState().setServerParity + return ( - useServerZustand.getState().setServerParity(v)} - testId="server-rtu-parity-select" - /> + ) }) const ServerDataBitsSelect = meme(() => { const dataBits = useServerZustand((z) => z.serialConfig?.options.dataBits ?? 8) + const setServerDataBits = useServerZustand.getState().setServerDataBits + return ( useServerZustand.getState().setServerDataBits(v)} + onChange={setServerDataBits} testId="server-rtu-databits-select" /> ) @@ -215,16 +220,18 @@ const ServerDataBitsSelect = meme(() => { const ServerStopBitsSelect = meme(() => { const stopBits = useServerZustand((z) => z.serialConfig?.options.stopBits ?? 1) + const setServerStopBits = useServerZustand.getState().setServerStopBits + return ( useServerZustand.getState().setServerStopBits(v)} + onChange={setServerStopBits} testId="server-rtu-stopbits-select" /> ) }) -const ServerRtuConfig = (): JSX.Element => { +const ServerRtuConfig = meme((): JSX.Element => { return ( @@ -238,6 +245,6 @@ const ServerRtuConfig = (): JSX.Element => { ) -} +}) export default ServerRtuConfig diff --git a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx index 15dbb6c..fc10957 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerBooleans/ServerBooleans.tsx @@ -1,5 +1,10 @@ import { DeleteFilled, PlusCircleOutlined } from '@ant-design/icons' -import { Box, IconButton, InputBaseComponentProps, Paper, TextField, alpha } from '@mui/material' +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 { deepEqual } from 'fast-equals' @@ -10,6 +15,7 @@ import useServerGridZustand from '../serverGrid.zustand' import ServerBit from '../shared/ServerBit' import UIntInput from '@renderer/components/shared/inputs/UintInput' import { maskInputProps } from '@renderer/components/shared/inputs/types' +import { gridSurface } from '@renderer/theme' interface ServerBooleanProps { name: string @@ -122,8 +128,12 @@ const ServerBoolList = meme(({ type }: Omit) => { // ─── Inline Add bar ────────────────────────────────────────────────────────── const getRegs = (type: BooleanRegisters): Record => { - const z = useServerZustand.getState() - return z.serverRegisters[z.selectedUuid]?.[z.getUnitId(z.selectedUuid)]?.[type] ?? {} + const serverZustand = useServerZustand.getState() + return ( + serverZustand.serverRegisters[serverZustand.selectedUuid]?.[ + serverZustand.getUnitId(serverZustand.selectedUuid) + ]?.[type] ?? {} + ) } const nextFree = (from: number, regs: Record): number => { @@ -222,7 +232,7 @@ const ServerBooleans = meme(({ name, type }: ServerBooleanProps) => { flex: 1, width: '100%', height: '100%', - backgroundColor: '#2A2A2A', + backgroundColor: gridSurface, fontSize: '0.95em', position: 'relative' }} diff --git a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx index 4102d84..f64f9f7 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerGrid.tsx @@ -1,9 +1,10 @@ import ServerBooleans from './ServerBooleans/ServerBooleans' import ServerRegisters from './ServerRegisters/ServerRegisters' -import AddRegister from './ServerRegisters/AddRegister' +import AddRegister from './ServerRegisters/AddRegister/AddRegister' import Box from '@mui/material/Box' +import { meme } from '@renderer/components/shared/inputs/meme' -const ServerGrid = (): JSX.Element => { +const ServerGrid = meme((): JSX.Element => { return ( { ) -} +}) export default ServerGrid diff --git a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx index 682032d..08b30e8 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerPartTitle/ServerPartTitle.tsx @@ -1,17 +1,19 @@ import { DeleteFilled, PlusCircleFilled } from '@ant-design/icons' -import { alpha, Box, IconButton } from '@mui/material' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import { alpha } from '@mui/material/styles' import { RegisterType } from '@shared' import { useCallback } from 'react' import { meme } from '@renderer/components/shared/inputs/meme' import { useServerZustand } from '@renderer/context/server.zustand' -import { useAddRegisterZustand } from '../ServerRegisters/addRegister.zustand' +import { useAddRegisterZustand } from '../ServerRegisters/AddRegister/addRegister.zustand' import useServerGridZustand from '../serverGrid.zustand' const AddButton = meme(({ type }: { type: RegisterType }) => { const handleClick = useCallback(() => { + const addRegisterZustand = useAddRegisterZustand.getState() if (type === 'input_registers' || type === 'holding_registers') { - const setRegisterType = useAddRegisterZustand.getState().setRegisterType - setRegisterType(type) + addRegisterZustand.setRegisterType(type) } // For bools, the inline add bar in ServerBooleans handles adding }, [type]) @@ -35,12 +37,12 @@ const AddButton = meme(({ type }: { type: RegisterType }) => { const DeleteButton = meme(({ registerType }: { registerType: RegisterType }) => { const handleClick = useCallback(() => { - const state = useServerZustand.getState() + const serverZustand = useServerZustand.getState() if (registerType === 'coils' || registerType === 'discrete_inputs') { - state.resetBools(registerType) + serverZustand.resetBools(registerType) } if (registerType === 'input_registers' || registerType === 'holding_registers') { - state.resetRegisters(registerType) + serverZustand.resetRegisters(registerType) } }, [registerType]) @@ -72,6 +74,12 @@ const ServerPartTitleName = meme( const amount = Object.keys(z.serverRegisters[uuid]?.[unitId]?.[registerType] ?? {}).length return amount }) + + const handleClick = useCallback((): void => { + const serverGridZustand = useServerGridZustand.getState() + serverGridZustand.toggleCollapse(registerType) + }, [registerType]) + return ( useServerGridZustand.getState().toggleCollapse(registerType)} + onClick={handleClick} > {name} ({amount}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister.tsx deleted file mode 100644 index 1434ca7..0000000 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import { - Box, - Button, - FormControl, - FormHelperText, - InputBaseComponentProps, - Modal, - Paper, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography -} from '@mui/material' -import { useAddRegisterZustand } from './addRegister.zustand' -import { meme } from '@renderer/components/shared/inputs/meme' -import { maskInputProps, MaskInputProps } from '@renderer/components/shared/inputs/types' -import { ElementType, forwardRef, useCallback, useEffect, useState } from 'react' -import { IMask, IMaskInput } from 'react-imask' -import { AddRegisterParams, BaseDataType, notEmpty, RegisterParamsBasePart } from '@shared' -import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' -import { useMinMaxInteger } from '@renderer/hooks' -import { useServerZustand } from '@renderer/context/server.zustand' -import { Delete } from '@mui/icons-material' -import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' -import { DateTime } from 'luxon' - -// -// -// -// -// Address -const AddressInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - - // Set maximum address based on data type - const maxAddress = useAddRegisterZustand((z) => { - if (['int32', 'uint32', 'float', 'unix'].includes(z.dataType)) return 65534 - if (['int64', 'uint64', 'double', 'datetime'].includes(z.dataType)) return 65532 - if (z.dataType === 'utf8') return Math.max(0, 65535 - (Number(z.registerLength) || 10) + 1) - return 65535 - }) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -AddressInputForward.displayName = 'AddressInput' -const AddressInput = meme(AddressInputForward) - -const AddressField = meme(() => { - const address = useAddRegisterZustand((z) => String(z.address)) - const addressInUse = useAddRegisterZustand((z) => z.addressInUse) - const addressFitError = useAddRegisterZustand((z) => z.addressFitError) - const valid = useAddRegisterZustand((z) => z.valid.address) - const setAddress = useAddRegisterZustand((z) => z.setAddress) - - return ( - - , - inputProps: maskInputProps({ set: setAddress }) - } - }} - /> - {addressInUse && In use} - {addressFitError && Data type does not fit at this address} - - ) -}) - -// -// -// -// -// Data Type -const DataTypeSelect = meme(() => { - const dataType = useAddRegisterZustand((z) => z.dataType) - const setDataType = useAddRegisterZustand((z) => z.setDataType) - return -}) - -// -// -// -// -// Fixed Or Generator -const FixedOrGenerator = meme(() => { - const fixed = useAddRegisterZustand((z) => z.fixed) - const setFixed = useAddRegisterZustand((z) => z.setFixed) - const dataType = useAddRegisterZustand((z) => z.dataType) - - // UTF-8 and BITMAP are always fixed — hide toggle - if (dataType === 'utf8' || dataType === 'bitmap') return null - - return ( - v !== null && setFixed(v)} - sx={{ flex: 1 }} - > - - Fixed - - - Generator - - - ) -}) - -// -// -// -// -// Value Input -const ValueInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const { min, max, integer } = useMinMaxInteger(dataType) - - return ( - { - set(value, notEmpty(value)) - }} - /> - ) -}) - -ValueInputForward.displayName = 'ValueInput' -const ValueInput = meme(ValueInputForward) - -const ValueInputComponent = meme(() => { - const value = useAddRegisterZustand((z) => z.value) - const valid = useAddRegisterZustand((z) => z.valid.value) - const setValue = useAddRegisterZustand((z) => z.setValue) - - return ( - , - inputProps: maskInputProps({ set: setValue }) - } - }} - /> - ) -}) - -// -// -// -// -// Min/Max Masks - -const MinInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const maxValue = useAddRegisterZustand((z) => z.max) - const { min, max, integer } = useMinMaxInteger(dataType, 'min', maxValue) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -MinInputForward.displayName = 'MinInput' -const MinInput = meme(MinInputForward) - -const MaxInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - const dataType = useAddRegisterZustand((z) => z.dataType) - const minValue = useAddRegisterZustand((z) => z.min) - const { min, integer, max } = useMinMaxInteger(dataType, 'max', minValue) - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -MaxInputForward.displayName = 'MaxInput' -const MaxInput = meme(MaxInputForward) - -// -// -// Min Max components -const MinTextField = meme(() => { - const min = useAddRegisterZustand((z) => String(z.min)) - const valid = useAddRegisterZustand((z) => z.valid.min) - const setMin = useAddRegisterZustand((z) => z.setMin) - - return ( - , - inputProps: maskInputProps({ set: setMin }) - } - }} - /> - ) -}) - -const MaxTextField = meme(() => { - const max = useAddRegisterZustand((z) => String(z.max)) - const valid = useAddRegisterZustand((z) => z.valid.max) - const setMax = useAddRegisterZustand((z) => z.setMax) - - return ( - , - inputProps: maskInputProps({ set: setMax }) - } - }} - /> - ) -}) - -// -// -// -// -// Interval - -const IntervalInputForward = forwardRef((props, ref) => { - const { set, ...other } = props - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -IntervalInputForward.displayName = 'IntervalInput' -const IntervalInput = meme(IntervalInputForward) - -const IntervalTextField = meme(() => { - const interval = useAddRegisterZustand((z) => String(z.interval)) - const valid = useAddRegisterZustand((z) => z.valid.interval) - const setInterval = useAddRegisterZustand((z) => z.setInterval) - - return ( - , - inputProps: maskInputProps({ set: setInterval }) - } - }} - /> - ) -}) - -// -// -// -// -// DateTimePicker for unix/datetime fixed mode -const DateTimeField = meme(() => { - const value = useAddRegisterZustand((z) => z.value) - const showDatePickerUtc = useAddRegisterZustand((z) => z.showDatePickerUtc) - const setValue = useAddRegisterZustand((z) => z.setValue) - const setShowDatePickerUtc = useAddRegisterZustand((z) => z.setShowDatePickerUtc) - - const dateValue = value && value !== '0' ? DateTime.fromMillis(Number(value)) : DateTime.now() - - return ( - - { - if (dt && dt.isValid) { - setValue(String(dt.toMillis()), true) - } - }} - ampm={false} - slotProps={{ - textField: { - size: 'small', - sx: { minWidth: 220 }, - // v9 renders a PickersTextField here, not a Material TextField, so - // the html input is reached through its own nested slotProps. - slotProps: { htmlInput: { 'data-testid': 'add-reg-datetime-input' } } - } - }} - /> - - setShowDatePickerUtc(!showDatePickerUtc)} - > - UTC - - - - ) -}) - -// -// -// -// -// String value input for utf8 -const StringValueField = meme(() => { - const stringValue = useAddRegisterZustand((z) => z.stringValue) - const setStringValue = useAddRegisterZustand((z) => z.setStringValue) - const maxBytes = useAddRegisterZustand((z) => (Number(z.registerLength) || 10) * 2) - const valid = useAddRegisterZustand((z) => z.valid.stringValue) - - useEffect(() => { - // Reevaluate string length when changing register Length - setStringValue(useAddRegisterZustand.getState().stringValue) - }, [maxBytes, setStringValue]) - - const helperText = `${new TextEncoder().encode(stringValue).length} / ${maxBytes} bytes` - - return ( - setStringValue(e.target.value)} - helperText={helperText} - error={!valid} - /> - ) -}) - -// -// -// -// -// Register length input for utf8 -const RegisterLengthForward = forwardRef((props, ref) => { - const { set, ...other } = props - - return ( - set(value, notEmpty(value))} - /> - ) -}) - -RegisterLengthForward.displayName = 'RegisterLengthInput' -const RegisterLengthInput = meme(RegisterLengthForward) - -const RegisterLengthField = meme(() => { - const registerLength = useAddRegisterZustand((z) => z.registerLength) - const valid = useAddRegisterZustand((z) => z.valid.registerLength) - const setRegisterLength = useAddRegisterZustand((z) => z.setRegisterLength) - - return ( - , - inputProps: maskInputProps({ set: setRegisterLength }) - } - }} - /> - ) -}) - -// -// -// -// -// ValueParameters -const ValueParameters = meme(() => { - const fixed = useAddRegisterZustand((z) => z.fixed) - const dataType = useAddRegisterZustand((z) => z.dataType) - - // UTF-8: string input + register length - if (dataType === 'utf8') { - return ( - <> - - - - ) - } - - // Unix/datetime fixed: date picker - if (['unix', 'datetime'].includes(dataType) && fixed) { - return - } - - // Unix/datetime generator: only interval - if (['unix', 'datetime'].includes(dataType) && !fixed) { - return - } - - // Numeric fixed: value input - if (fixed) { - return - } - - // Numeric generator: min/max/interval - return ( - <> - - - - - ) -}) - -// -// -// -// -// Comment -const CommentField = meme(() => { - const comment = useAddRegisterZustand((z) => z.comment) - const setComment = useAddRegisterZustand((z) => z.setComment) - - return ( - setComment(e.target.value)} - /> - ) -}) - -// -// -// -// -// Toggle endianness button removed - now global per server - -// -// -// -// -// Shared submit logic — adds or edits the register, returns the address and dataType used -function submitRegister(isEdit: boolean): { address: number; dataType: BaseDataType } | undefined { - const { - fixed, - address, - value, - dataType, - registerType, - min, - max, - interval, - comment, - stringValue, - registerLength, - serverRegisterEdit - } = useAddRegisterZustand.getState() - if (!registerType) return undefined - - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) - - const littleEndian = z.littleEndian[uuid] ?? false - const commonParams: Omit = { uuid, unitId, littleEndian } - const baseRegisterParams: RegisterParamsBasePart = { - address: Number(address), - dataType, - comment, - registerType - } - - if (isEdit && serverRegisterEdit) { - const oldAddress = serverRegisterEdit.params.address - if (oldAddress !== Number(address)) { - z.removeRegister({ - uuid, - unitId, - address: oldAddress, - registerType, - dataType: serverRegisterEdit.params.dataType - }) - } - } - - if (dataType === 'utf8') { - // UTF-8: always fixed, pass stringValue and length - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - value: 0, - stringValue, - length: Number(registerLength) || 10 - } - }) - } else if (['unix', 'datetime'].includes(dataType)) { - if (fixed) { - // Fixed timestamp from date picker (value stored as ms) - const timestamp = dataType === 'unix' ? Math.floor(Number(value) / 1000) : Number(value) - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: timestamp } }) - } else { - // Generator: system time, only interval matters - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: 0, - max: 0, - interval: Number(interval) * 1000 - } - }) - } - } else if (fixed) { - z.addRegister({ ...commonParams, params: { ...baseRegisterParams, value: Number(value) } }) - } else { - z.addRegister({ - ...commonParams, - params: { - ...baseRegisterParams, - min: Number(min), - max: Number(max), - interval: Number(interval) * 1000 - } - }) - } - - return { address: Number(address), dataType } -} - -// Add buttons -const AddButtons = meme(() => { - const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) - const valid = useAddRegisterZustand((z) => { - if (z.dataType === 'utf8') { - return z.valid.address && z.valid.stringValue && z.valid.registerLength - } - if (['unix', 'datetime'].includes(z.dataType)) { - return z.fixed ? z.valid.address : z.valid.address && z.valid.interval - } - if (z.fixed) return z.valid.address && z.valid.value - return z.valid.address && z.valid.min && z.valid.max && z.valid.interval - }) - - const handleAddAndClose = useCallback(() => { - const result = submitRegister(edit) - if (!result) return - const state = useAddRegisterZustand.getState() - state.resetToDefaults() - state.setRegisterType(undefined) - }, [edit]) - - const handleAddAndNext = useCallback(() => { - const result = submitRegister(false) - if (!result) return - const { address, dataType } = result - const state = useAddRegisterZustand.getState() - const size = ['double', 'uint64', 'int64', 'datetime'].includes(dataType) - ? 4 - : ['uint32', 'int32', 'float', 'unix'].includes(dataType) - ? 2 - : dataType === 'utf8' - ? Number(state.registerLength) || 10 - : 1 - // Reset value and comment, keep dataType/LE/fixed/min/max/interval - state.setValue('0', true) - state.setComment('') - if (dataType === 'utf8') state.setStringValue('') - state.initNextUnusedAddress(address + size) - }, []) - - const handleEditSubmit = useCallback(() => { - const result = submitRegister(true) - if (!result) return - const state = useAddRegisterZustand.getState() - state.setRegisterType(undefined) - state.setEditRegister(undefined) - }, []) - - if (edit) { - return ( - - ) - } - - return ( - <> - - - - ) -}) - -const DeleteButton = meme(() => { - const [over, setOver] = useState(false) - const handleClick = useCallback(() => { - const { address, registerType, setRegisterType, setEditRegister } = - useAddRegisterZustand.getState() - if (!registerType) return - - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) - - const numericAddress = Number(address) - const entry = z.serverRegisters[uuid]?.[unitId]?.[registerType]?.[numericAddress] - const dataType = entry?.params?.dataType ?? 'uint16' - - z.removeRegister({ - uuid, - unitId, - address: numericAddress, - registerType, - dataType - }) - - setRegisterType(undefined) - setEditRegister(undefined) - }, []) - - return ( - - ) -}) - -// -// -// -// -// MAIN -const AddRegister = meme(() => { - const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) - const registerType = useAddRegisterZustand((z) => z.registerType) - const setRegisterType = useAddRegisterZustand((z) => z.setRegisterType) - const setEditRegister = useAddRegisterZustand((z) => z.setEditRegister) - - // Reset to defaults when opening in add mode - useEffect(() => { - if (!registerType) return - if (edit) return - const state = useAddRegisterZustand.getState() - state.resetToDefaults() - state.setRegisterType(registerType) - state.initNextUnusedAddress() - }, [registerType, edit]) - - // Populate fields when opening in edit mode - useEffect(() => { - const state = useAddRegisterZustand.getState() - if (!state.serverRegisterEdit) return - - const { - address, - comment, - dataType, - registerType, - interval, - max, - min, - value, - stringValue, - length - } = state.serverRegisterEdit.params - - state.setFixed(value !== undefined) - state.setAddress(String(address)) - state.setRegisterType(registerType) - state.setComment(comment) - state.setInterval(interval ? String(interval / 1000) : '1') - state.setMax(String(max)) - state.setMin(String(min)) - - if (dataType === 'utf8') { - state.setStringValue(stringValue ?? '') - state.setRegisterLength(String(length ?? 10), true) - state.setValue('0', true) - } else if (['unix', 'datetime'].includes(dataType) && value !== undefined) { - // Convert stored value back to ms for the date picker - const ms = dataType === 'unix' ? Number(value) * 1000 : Number(value) - state.setValue(String(ms), true) - } else { - state.setValue(String(value)) - } - - state.setDataType(dataType) - }, [edit]) - - return ( - { - setRegisterType(undefined) - setEditRegister(undefined) - }} - sx={{ - display: 'flex', - justifyContent: 'center', - pt: 2, - px: 2, - alignItems: 'center' - }} - slotProps={{ backdrop: { sx: { background: 'rgba(0,0,0,0.25)' } } }} - > - - - {edit ? 'Edit' : 'Add'}{' '} - {registerType === 'input_registers' ? 'Input Register' : 'Holding Register'} - - - - - - - - - - - - {edit && } - - - - ) -}) -export default AddRegister diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx new file mode 100644 index 0000000..327e7aa --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/AddRegister.tsx @@ -0,0 +1,124 @@ +import Box from '@mui/material/Box' +import Modal from '@mui/material/Modal' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useCallback, useEffect } from 'react' +import { FixedOrGenerator, ValueParameters } from './valueParameters' +import { AddressField, DataTypeSelect, CommentField } from './registerFields' +import { AddButtons, DeleteButton } from './addRegisterActions' +import { FIELD_DEFAULTS } from './addRegister.zustand.helpers' +import { DEFAULT_UTF8_LENGTH } from '@shared' + +const AddRegister = meme(() => { + const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) + const registerType = useAddRegisterZustand((z) => z.registerType) + + const handleClose = useCallback((): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setRegisterType(undefined) + addRegisterZustand.setEditRegister(undefined) + }, []) + + // Reset to defaults when opening in add mode + useEffect(() => { + if (!registerType) return + if (edit) return + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.resetToDefaults() + addRegisterZustand.setRegisterType(registerType) + addRegisterZustand.initNextUnusedAddress() + }, [registerType, edit]) + + // Populate fields when opening in edit mode + useEffect(() => { + const addRegisterZustand = useAddRegisterZustand.getState() + if (!addRegisterZustand.serverRegisterEdit) return + + const { + address, + comment, + dataType, + registerType, + interval, + max, + min, + value, + stringValue, + length + } = addRegisterZustand.serverRegisterEdit.params + + // The masked setters take the validity of what they are given as a second + // argument, and a stored register holds values that were valid when it was + // added. Left off, a field came up marked wrong, and only a field on screen + // had that corrected, by the mask under it reporting back on mount. + addRegisterZustand.setFixed(value !== undefined) + addRegisterZustand.setAddress(String(address), true) + addRegisterZustand.setRegisterType(registerType) + addRegisterZustand.setComment(comment) + addRegisterZustand.setInterval( + interval ? String(interval / 1000) : FIELD_DEFAULTS.interval, + true + ) + addRegisterZustand.setMax(max === undefined ? FIELD_DEFAULTS.max : String(max), true) + addRegisterZustand.setMin(min === undefined ? FIELD_DEFAULTS.min : String(min), true) + + if (dataType === 'utf8') { + addRegisterZustand.setStringValue(stringValue ?? '') + addRegisterZustand.setRegisterLength(String(length ?? DEFAULT_UTF8_LENGTH), true) + addRegisterZustand.setValue(FIELD_DEFAULTS.value, true) + } else if (['unix', 'datetime'].includes(dataType) && value !== undefined) { + // Convert stored value back to ms for the date picker + const ms = dataType === 'unix' ? Number(value) * 1000 : Number(value) + addRegisterZustand.setValue(String(ms), true) + } else { + addRegisterZustand.setValue(value === undefined ? FIELD_DEFAULTS.value : String(value), true) + } + + addRegisterZustand.setDataType(dataType) + + // The fields are set, so this records what the dialog opened with. The + // buttons compare against it to know whether anything has been typed. + addRegisterZustand.capturePristine() + }, [edit]) + + return ( + + + + {edit ? 'Edit' : 'Add'}{' '} + {registerType === 'input_registers' ? 'Input Register' : 'Holding Register'} + + + + + + + + + + + + {edit && } + + + + ) +}) + +export default AddRegister 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 new file mode 100644 index 0000000..7f48186 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/AddRegister.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom +/// +import { render, screen, within } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ServerRegister } from '@shared' + +// ─── Store stub ────────────────────────────────────────────────────── +// The real server store persists through window.api on import, which is far +// more machinery than the dialog's buttons need. + +const mockRemoveRegister = vi.fn() +const serverState = { + selectedUuid: 'main', + getUnitId: (): string => '0', + usedAddresses: { main: { '0': { holding_registers: [100, 101] } } }, + serverRegisters: {}, + littleEndian: {}, + removeRegister: mockRemoveRegister, + addRegister: vi.fn() +} + +vi.mock('@renderer/context/server.zustand', () => ({ + useServerZustand: Object.assign( + (selector: (state: typeof serverState) => unknown) => selector(serverState), + { getState: () => serverState } + ) +})) + +import AddRegister from '../AddRegister' +import { useAddRegisterZustand } from '../addRegister.zustand' + +const registerAt100: ServerRegister[number] = { + value: 0, + params: { + address: 100, + registerType: 'holding_registers', + dataType: 'uint32', + comment: 'flow rate', + value: 7, + min: undefined, + max: undefined, + interval: undefined + } +} + +/** The same address as a generator, which carries a range instead of a value. */ +const generatorAt100: ServerRegister[number] = { + value: 0, + params: { + address: 100, + registerType: 'holding_registers', + dataType: 'uint32', + comment: 'flow rate', + value: undefined, + min: 0, + max: 500, + interval: 5000 + } +} + +const submitButton = (): HTMLElement => screen.getByTestId('add-reg-submit-btn') +const removeButton = (): HTMLElement => screen.getByTestId('add-reg-remove-btn') + +/** The attribute sits on the MUI field, and what a user types into is inside it. */ +const fieldInput = (testId: string): HTMLElement => + within(screen.getByTestId(testId)).getByRole('textbox') + +/** The dialog opened on a register, which is what puts the two buttons up. */ +const renderEditing = (register: ServerRegister[number]): void => { + useAddRegisterZustand.getState().setEditRegister(register) + render() +} + +const allFieldsValid = { + address: true, + value: true, + min: true, + max: true, + interval: true, + registerLength: true, + stringValue: true +} + +describe('what the edit dialog opens with', () => { + beforeEach(() => { + useAddRegisterZustand.getState().resetToDefaults() + }) + + // A field marked wrong paints its label as an error, and the register on + // screen was valid when it was added. + it('marks nothing wrong when a fixed register opens', () => { + renderEditing(registerAt100) + + expect(useAddRegisterZustand.getState().valid).toEqual(allFieldsValid) + }) + + it('marks nothing wrong when a generator opens', () => { + renderEditing(generatorAt100) + + expect(useAddRegisterZustand.getState().valid).toEqual(allFieldsValid) + }) + + // The dialog offers both sets and the register carries one, so switching has + // to land on something submittable. + it('has a range ready when a fixed register is switched to Generator', async () => { + const user = userEvent.setup() + renderEditing(registerAt100) + + await user.click(screen.getByTestId('add-reg-generator-btn')) + + expect(fieldInput('add-reg-min-input')).toHaveValue('0') + expect(fieldInput('add-reg-max-input')).toHaveValue('1') + expect(submitButton()).toBeEnabled() + }) + + it('has a value ready when a generator is switched to Fixed', async () => { + const user = userEvent.setup() + renderEditing(generatorAt100) + + await user.click(screen.getByTestId('add-reg-fixed-btn')) + + expect(fieldInput('add-reg-value-input')).toHaveValue('0') + expect(submitButton()).toBeEnabled() + }) +}) + +describe('the edit dialog buttons', () => { + beforeEach(() => { + mockRemoveRegister.mockClear() + useAddRegisterZustand.getState().resetToDefaults() + }) + + it('offers Remove and not Submit Change while nothing has been typed', () => { + renderEditing(registerAt100) + + expect(submitButton()).toBeDisabled() + expect(removeButton()).toBeEnabled() + }) + + it('offers Submit Change and not Remove once the address is changed', async () => { + const user = userEvent.setup() + renderEditing(registerAt100) + + const address = fieldInput('add-reg-address-input') + await user.clear(address) + await user.type(address, '200') + + expect(submitButton()).toBeEnabled() + expect(removeButton()).toBeDisabled() + }) + + // Any field, not only the address the buttons were wrong about. + it('takes a comment as a change too', async () => { + const user = userEvent.setup() + renderEditing(registerAt100) + + await user.type(fieldInput('add-reg-comment-input'), '!') + + expect(submitButton()).toBeEnabled() + expect(removeButton()).toBeDisabled() + }) + + it('offers Remove again when the address is typed back', async () => { + const user = userEvent.setup() + renderEditing(registerAt100) + + const address = fieldInput('add-reg-address-input') + await user.clear(address) + await user.type(address, '200') + await user.clear(address) + await user.type(address, '100') + + expect(submitButton()).toBeDisabled() + expect(removeButton()).toBeEnabled() + }) + + it('removes the register it was opened on', async () => { + const user = userEvent.setup() + renderEditing(registerAt100) + + await user.click(removeButton()) + + expect(mockRemoveRegister).toHaveBeenCalledWith({ + uuid: 'main', + unitId: '0', + address: 100, + registerType: 'holding_registers', + dataType: 'uint32', + length: undefined + }) + }) +}) 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 new file mode 100644 index 0000000..6daf9f3 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.helpers.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest' +import { + isAddressInUse, + isFormDirty, + toFormSnapshot, + toRegisterParams, + type RegisterFormSnapshot, + type RegisterFormValues +} from '../addRegister.zustand.helpers' + +// ─── isAddressInUse ───────────────────────────────────────────────── + +describe('isAddressInUse', () => { + it('returns false when no addresses are used', () => { + expect(isAddressInUse([], 'int16', 0)).toBe(false) + }) + + it('returns true when exact address is used', () => { + expect(isAddressInUse([5], 'int16', 5)).toBe(true) + }) + + it('returns false when address is not used', () => { + expect(isAddressInUse([5], 'int16', 6)).toBe(false) + }) + + // Multi-register overlap + it('detects overlap for int32 (2 registers)', () => { + // INT32 at address 10 needs addresses 10, 11 + expect(isAddressInUse([11], 'int32', 10)).toBe(true) + expect(isAddressInUse([10], 'int32', 10)).toBe(true) + expect(isAddressInUse([12], 'int32', 10)).toBe(false) + }) + + it('detects overlap for int64 (4 registers)', () => { + // INT64 at address 100 needs 100, 101, 102, 103 + expect(isAddressInUse([103], 'int64', 100)).toBe(true) + expect(isAddressInUse([104], 'int64', 100)).toBe(false) + }) + + it('detects overlap for utf8 with custom length', () => { + // UTF-8 with length 3 at address 50 needs 50, 51, 52 + expect(isAddressInUse([52], 'utf8', 50, 3)).toBe(true) + expect(isAddressInUse([53], 'utf8', 50, 3)).toBe(false) + }) + + // Edit mode — exclude current register's addresses + it('excludes edit register addresses in edit mode', () => { + // Address 10 is used, but we're editing the register at 10 + const used = [10] + const edit = { dataType: 'int16' as const, address: 10 } + expect(isAddressInUse(used, 'int16', 10, undefined, edit)).toBe(false) + }) + + it('excludes multi-register edit addresses', () => { + // Addresses 10, 11 used by INT32, editing that same register + const used = [10, 11] + const edit = { dataType: 'int32' as const, address: 10 } + expect(isAddressInUse(used, 'int32', 10, undefined, edit)).toBe(false) + }) + + it('detects conflict even in edit mode when moving to occupied address', () => { + // Addresses 10, 11 (INT32 being edited) and 20 (another register) + const used = [10, 11, 20] + const edit = { dataType: 'int32' as const, address: 10 } + // Moving to address 20 should conflict + expect(isAddressInUse(used, 'int16', 20, undefined, edit)).toBe(true) + }) + + it('allows moving edit register to a free address', () => { + const used = [10, 11] + const edit = { dataType: 'int32' as const, address: 10 } + // Moving to address 50 is fine + expect(isAddressInUse(used, 'int32', 50, undefined, edit)).toBe(false) + }) + + it('handles edit register with utf8 length', () => { + // UTF-8 register at address 0 with length 5 occupies 0-4 + const used = [0, 1, 2, 3, 4, 10] + const edit = { dataType: 'utf8' as const, address: 0, length: 5 } + // Changing to address 0 with same size should be fine (it's the same register) + expect(isAddressInUse(used, 'utf8', 0, 5, edit)).toBe(false) + // But address 10 is still occupied by another register + expect(isAddressInUse(used, 'int16', 10, undefined, edit)).toBe(true) + }) + + // Edge cases + it('handles empty used addresses with edit register', () => { + const edit = { dataType: 'int16' as const, address: 5 } + expect(isAddressInUse([], 'int16', 0, undefined, edit)).toBe(false) + }) + + it('handles address at boundary (65535)', () => { + expect(isAddressInUse([], 'int16', 65535)).toBe(false) + expect(isAddressInUse([65535], 'int16', 65535)).toBe(true) + }) + + it('detects partial overlap when expanding data type in edit mode', () => { + // INT16 at address 10 being edited, but address 11 is used by another register + const used = [10, 11] + const edit = { dataType: 'int16' as const, address: 10 } + // Changing to INT32 at address 10 needs 10+11, but 11 belongs to another register + expect(isAddressInUse(used, 'int32', 10, undefined, edit)).toBe(true) + }) +}) + +// ─── isFormDirty ──────────────────────────────────────────────────── + +describe('isFormDirty', () => { + const opened: RegisterFormSnapshot = { + fixed: true, + address: '100', + value: '1234', + dataType: 'uint16', + min: '0', + max: '100', + interval: '5', + comment: 'flow rate', + stringValue: '', + registerLength: '' + } + + it('is clean while nothing has been typed', () => { + expect(isFormDirty({ ...opened }, opened)).toBe(false) + }) + + it('is dirty on a changed address', () => { + expect(isFormDirty({ ...opened, address: '200' }, opened)).toBe(true) + }) + + // Every field counts, not only the address the buttons were wrong about. + it('is dirty on a changed comment', () => { + expect(isFormDirty({ ...opened, comment: 'return temperature' }, opened)).toBe(true) + }) + + it('is dirty on a changed data type', () => { + expect(isFormDirty({ ...opened, dataType: 'int32' }, opened)).toBe(true) + }) + + it('is dirty on the generator toggle', () => { + expect(isFormDirty({ ...opened, fixed: false }, opened)).toBe(true) + }) + + // Typing a value back to what it was leaves nothing to submit, so the + // comparison has to be against the opened state rather than a latch. + it('is clean again when the change is typed back', () => { + const changed = { ...opened, address: '200' } + expect(isFormDirty({ ...changed, address: '100' }, opened)).toBe(false) + }) + + it('is clean in add mode, where nothing was captured', () => { + expect(isFormDirty({ ...opened, address: '200' }, undefined)).toBe(false) + }) +}) + +describe('toFormSnapshot', () => { + it('keeps the fields a user can change and drops the register type', () => { + const form: RegisterFormValues = { + fixed: false, + address: '7', + value: '0', + dataType: 'int32', + registerType: 'input_registers', + min: '1', + max: '9', + interval: '2', + comment: 'pressure', + stringValue: 'PUMP', + registerLength: '4' + } + + expect(toFormSnapshot(form)).toEqual({ + fixed: false, + address: '7', + value: '0', + dataType: 'int32', + min: '1', + max: '9', + interval: '2', + comment: 'pressure', + stringValue: 'PUMP', + registerLength: '4' + }) + }) +}) + +describe('toRegisterParams', () => { + const form: RegisterFormValues = { + fixed: true, + address: '40', + value: '1234', + dataType: 'uint16', + registerType: 'holding_registers', + min: '0', + max: '100', + interval: '5', + comment: 'flow rate', + stringValue: '', + registerLength: '' + } + + it('carries the address, type and comment through unchanged', () => { + expect(toRegisterParams(form)).toMatchObject({ + address: 40, + dataType: 'uint16', + registerType: 'holding_registers', + comment: 'flow rate' + }) + }) + + it('a fixed register keeps its value and gets no generator fields', () => { + const params = toRegisterParams(form) + expect(params).toMatchObject({ value: 1234 }) + expect(params).not.toHaveProperty('min') + expect(params).not.toHaveProperty('interval') + }) + + it('a generator turns the interval from seconds into milliseconds', () => { + expect(toRegisterParams({ ...form, fixed: false })).toMatchObject({ + min: 0, + max: 100, + interval: 5000 + }) + }) + + it('a fixed unix timestamp is stored in seconds', () => { + // The picker hands back milliseconds + expect(toRegisterParams({ ...form, dataType: 'unix', value: '1756742400000' })).toMatchObject({ + value: 1756742400 + }) + }) + + it('a fixed datetime keeps the milliseconds the picker gave it', () => { + expect( + toRegisterParams({ ...form, dataType: 'datetime', value: '1756742400000' }) + ).toMatchObject({ value: 1756742400000 }) + }) + + it('a generated timestamp reads the clock, so min and max are pinned to zero', () => { + expect( + toRegisterParams({ ...form, dataType: 'unix', fixed: false, min: '7', max: '9' }) + ).toMatchObject({ min: 0, max: 0, interval: 5000 }) + }) + + it('utf8 is always fixed and carries its string', () => { + expect( + toRegisterParams({ ...form, dataType: 'utf8', stringValue: 'PUMP-01', registerLength: '4' }) + ).toMatchObject({ value: 0, stringValue: 'PUMP-01', length: 4 }) + }) + + it('utf8 falls back to ten registers when no length was given', () => { + expect(toRegisterParams({ ...form, dataType: 'utf8', registerLength: '' })).toMatchObject({ + length: 10 + }) + }) +}) 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 new file mode 100644 index 0000000..93ac889 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/__tests__/addRegister.zustand.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ServerRegister } from '@shared' + +// ─── Store stub ────────────────────────────────────────────────────── +// The real server store persists through window.api on import, and the two +// actions below are all this file drives. + +const mockRemoveRegister = vi.fn() +const serverState = { + selectedUuid: 'main', + getUnitId: (): string => '0', + usedAddresses: {} as Record, + removeRegister: mockRemoveRegister +} + +vi.mock('@renderer/context/server.zustand', () => ({ + useServerZustand: Object.assign( + (selector: (state: typeof serverState) => unknown) => selector(serverState), + { getState: () => serverState } + ) +})) + +import { useAddRegisterZustand } from '../addRegister.zustand' +import { isFormDirty } from '../addRegister.zustand.helpers' + +const registerAt100: ServerRegister[number] = { + value: 0, + params: { + address: 100, + registerType: 'holding_registers', + dataType: 'uint32', + comment: 'flow rate', + value: 7, + min: undefined, + max: undefined, + interval: undefined + } +} + +/** What the edit effect does: fill the fields, then record what it filled. */ +const openEditOn = (register: ServerRegister[number]): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setEditRegister(register) + addRegisterZustand.setRegisterType(register.params.registerType) + addRegisterZustand.setAddress(String(register.params.address), true) + addRegisterZustand.setComment(register.params.comment) + addRegisterZustand.setDataType(register.params.dataType) + addRegisterZustand.capturePristine() +} + +describe('remove', () => { + beforeEach(() => { + mockRemoveRegister.mockClear() + useAddRegisterZustand.getState().resetToDefaults() + }) + + it('removes the register the dialog was opened on, not the address typed after', () => { + openEditOn(registerAt100) + useAddRegisterZustand.getState().setAddress('200', true) + + useAddRegisterZustand.getState().remove() + + expect(mockRemoveRegister).toHaveBeenCalledWith({ + uuid: 'main', + unitId: '0', + address: 100, + registerType: 'holding_registers', + dataType: 'uint32', + length: undefined + }) + }) + + it('removes nothing outside edit mode', () => { + useAddRegisterZustand.getState().setRegisterType('holding_registers') + useAddRegisterZustand.getState().setAddress('100', true) + + useAddRegisterZustand.getState().remove() + + expect(mockRemoveRegister).not.toHaveBeenCalled() + }) +}) + +describe('what the dialog opened with', () => { + beforeEach(() => { + useAddRegisterZustand.getState().resetToDefaults() + }) + + it('reads clean once the fields are filled and recorded', () => { + openEditOn(registerAt100) + + const state = useAddRegisterZustand.getState() + expect(isFormDirty(state, state.pristine)).toBe(false) + }) + + it('reads dirty after a field is typed into', () => { + openEditOn(registerAt100) + useAddRegisterZustand.getState().setComment('return temperature') + + const state = useAddRegisterZustand.getState() + expect(isFormDirty(state, state.pristine)).toBe(true) + }) + + // Opening a second register while the fields still hold the first one would + // otherwise compare the new register against the old one's values. + it('is dropped when another register is opened', () => { + openEditOn(registerAt100) + useAddRegisterZustand.getState().setEditRegister({ + ...registerAt100, + params: { ...registerAt100.params, address: 300 } + }) + + expect(useAddRegisterZustand.getState().pristine).toBeUndefined() + }) +}) 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 new file mode 100644 index 0000000..7994852 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.helpers.ts @@ -0,0 +1,147 @@ +import { + BaseDataType, + DataType, + NumberRegisters, + RegisterParams, + RegisterParamsBasePart, + registerWidth +} from '@shared' + +/** + * Pure function that checks whether an address (+ its data-type span) overlaps + * with already-used addresses, optionally excluding the addresses of the + * register currently being edited. + */ +export const isAddressInUse = ( + usedAddresses: number[], + dataType: DataType, + address: number, + length?: number, + editRegister?: { dataType: DataType; address: number; length?: number } +): boolean => { + const size = registerWidth(dataType, length) + const addressesNeeded = Array.from({ length: size }, (_, i) => address + i) + + if (editRegister) { + const editSize = registerWidth(editRegister.dataType, editRegister.length) + const editAddresses = Array.from({ length: editSize }, (_, i) => editRegister.address + i) + const filteredUsed = usedAddresses.filter((a) => !editAddresses.includes(a)) + return addressesNeeded.some((a) => filteredUsed.includes(Number(a))) + } + + return addressesNeeded.some((a) => usedAddresses.includes(Number(a))) +} + +/** What the add-register dialog holds, before any of it means anything. */ +export interface RegisterFormValues { + fixed: boolean + address: string + value: string + dataType: BaseDataType + registerType: NumberRegisters + min: string + max: string + interval: string + comment: string + stringValue: string + registerLength: string +} + +/** + * What a field shows when the register has nothing for it. + * + * 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. + */ +export const FIELD_DEFAULTS = { + value: '0', + min: '0', + max: '1', + interval: '1' +} + +/** + * 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 + * inside changes it. + */ +export type RegisterFormSnapshot = Omit + +/** What the dialog holds now, kept so a later state can be compared to it. */ +export const toFormSnapshot = (form: RegisterFormSnapshot): RegisterFormSnapshot => ({ + fixed: form.fixed, + address: form.address, + value: form.value, + dataType: form.dataType, + min: form.min, + max: form.max, + interval: form.interval, + comment: form.comment, + stringValue: form.stringValue, + registerLength: form.registerLength +}) + +/** + * Whether anything has been typed since the dialog opened. + * + * The comparison is against what the edit effect wrote into the fields, not + * against the register itself, because that effect converts on the way in: an + * interval is divided by a thousand, a unix value is multiplied by it, and a + * utf8 length that was never set becomes ten. Compared against the register, a + * conversion that does not round-trip would make the dialog dirty the moment it + * opened. + */ +export const isFormDirty = ( + form: RegisterFormSnapshot, + pristine: RegisterFormSnapshot | undefined +): boolean => { + if (!pristine) return false + const fields = Object.keys(pristine) as (keyof RegisterFormSnapshot)[] + return fields.some((field) => form[field] !== pristine[field]) +} + +/** + * Turns what the dialog holds into the params the server stores. + * + * Everything in the dialog is a string, and the conversions out of it are not + * uniform. An interval is typed in seconds and stored in milliseconds. A unix + * timestamp is stored in seconds while a datetime, picked in the same field, + * is stored in milliseconds. A utf8 register falls back to ten registers when + * no length was given. A generated timestamp reads the system clock, so its + * min and max carry nothing and are pinned to zero. + */ +export const toRegisterParams = (form: RegisterFormValues): RegisterParams => { + const base: RegisterParamsBasePart = { + address: Number(form.address), + dataType: form.dataType, + comment: form.comment, + registerType: form.registerType + } + + if (form.dataType === 'utf8') { + return { + ...base, + value: 0, + stringValue: form.stringValue, + length: Number(form.registerLength) || 10 + } + } + + if (['unix', 'datetime'].includes(form.dataType)) { + if (form.fixed) { + const picked = Number(form.value) + return { ...base, value: form.dataType === 'unix' ? Math.floor(picked / 1000) : picked } + } + return { ...base, min: 0, max: 0, interval: Number(form.interval) * 1000 } + } + + if (form.fixed) return { ...base, value: Number(form.value) } + + return { + ...base, + min: Number(form.min), + max: Number(form.max), + interval: Number(form.interval) * 1000 + } +} diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts similarity index 67% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.ts rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts index 2a78b4f..2c5291d 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.ts +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegister.zustand.ts @@ -1,17 +1,25 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { MaskSetFn } from '@renderer/context/root.zustand.types' +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 { create } from 'zustand' import { mutative } from 'zustand-mutative' -import { getRegisterSize, isAddressInUse } from './addRegister.zustand.helpers' +import { + FIELD_DEFAULTS, + isAddressInUse, + RegisterFormSnapshot, + toFormSnapshot, + toRegisterParams +} from './addRegister.zustand.helpers' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -85,11 +93,11 @@ const validateAddress = ( } } - const z = useServerZustand.getState() - const uuid = z.selectedUuid - const unitId = z.getUnitId(uuid) + const serverZustand = useServerZustand.getState() + const uuid = serverZustand.selectedUuid + const unitId = serverZustand.getUnitId(uuid) const addressNum = Number(address) - const length = dataType === 'utf8' ? Number(registerLength) || 10 : undefined + 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) @@ -109,6 +117,9 @@ interface AddRegisterZustand { registerType: NumberRegisters | undefined setRegisterType: (registerType: NumberRegisters | undefined) => void setEditRegister: (register: ServerRegister[number] | undefined) => void + /** What the fields held once the edit dialog had filled them. */ + pristine: RegisterFormSnapshot | undefined + capturePristine: () => void valid: { address: boolean value: boolean @@ -144,6 +155,17 @@ interface AddRegisterZustand { setShowDatePickerUtc: (utc: boolean) => void initNextUnusedAddress: (startFrom?: number) => void resetToDefaults: () => void + /** + * Writes what the dialog holds to the server, and answers where it landed. + * + * Undefined when there is no register type, which means nothing was written. + */ + submit: (isEdit: boolean) => { address: number; dataType: BaseDataType } | undefined + /** + * Removes the register the dialog was opened on, and nothing outside edit + * mode. + */ + remove: () => void } // ─── Store ─────────────────────────────────────────────────────────────────── @@ -161,6 +183,16 @@ export const useAddRegisterZustand = create set((state) => { state.serverRegisterEdit = register + // The fields still hold the previous register, so there is nothing to + // compare against until the edit effect has filled them again. + state.pristine = undefined + }), + + pristine: undefined, + + capturePristine: () => + set((state) => { + state.pristine = toFormSnapshot(getState()) }), valid: { @@ -207,14 +239,14 @@ export const useAddRegisterZustand = create set((state) => { state.value = value state.valid.value = !!valid }), - interval: '1', + interval: FIELD_DEFAULTS.interval, setInterval: (interval, valid) => set((state) => { state.interval = interval @@ -227,14 +259,14 @@ export const useAddRegisterZustand = create set((state) => { state.min = min state.valid.min = !!valid }), - max: '1', + max: FIELD_DEFAULTS.max, setMax: (max, valid) => set((state) => { state.max = max @@ -250,7 +282,7 @@ export const useAddRegisterZustand = create { const { registerLength } = getState() - const maxBytes = (Number(registerLength) || 10) * 2 + const maxBytes = (Number(registerLength) || DEFAULT_UTF8_LENGTH) * 2 const valid = new TextEncoder().encode(value).length <= maxBytes set((state) => { state.stringValue = value @@ -282,11 +314,11 @@ export const useAddRegisterZustand = create { + const form = getState() + const { registerType, serverRegisterEdit } = form + if (!registerType) return undefined + + const serverZustand = useServerZustand.getState() + const uuid = serverZustand.selectedUuid + const unitId = serverZustand.getUnitId(uuid) + + const params = toRegisterParams({ + fixed: form.fixed, + address: form.address, + value: form.value, + dataType: form.dataType, + registerType, + min: form.min, + max: form.max, + interval: form.interval, + comment: form.comment, + stringValue: form.stringValue, + registerLength: form.registerLength + }) + + // Moving an existing register means the old address has to go first + if (isEdit && serverRegisterEdit) { + const oldAddress = serverRegisterEdit.params.address + if (oldAddress !== params.address) { + serverZustand.removeRegister({ + uuid, + unitId, + address: oldAddress, + registerType, + dataType: serverRegisterEdit.params.dataType, + length: serverRegisterEdit.params.length + }) + } + } + + serverZustand.addRegister({ + uuid, + unitId, + littleEndian: serverZustand.littleEndian[uuid] ?? false, + params + }) + + return { address: params.address, dataType: form.dataType } + }, + + /** + * Beside `submit`, and for the same reason: it reads the dialog and writes + * through the server store. + * + * What it removes is the register the dialog was opened on, which the + * address field does not answer. That field is editable, and a changed one + * means the user is moving the register rather than naming another. + */ + remove: () => { + const { serverRegisterEdit } = getState() + if (!serverRegisterEdit) return + + const serverZustand = useServerZustand.getState() + const uuid = serverZustand.selectedUuid + const { address, registerType, dataType, length } = serverRegisterEdit.params + + serverZustand.removeRegister({ + uuid, + unitId: serverZustand.getUnitId(uuid), + address, + registerType, + dataType, + length + }) + }, + resetToDefaults: () => set((state) => { state.address = '0' state.dataType = 'int16' - state.value = '0' - state.min = '0' - state.max = '1' - state.interval = '1' + state.value = FIELD_DEFAULTS.value + state.min = FIELD_DEFAULTS.min + state.max = FIELD_DEFAULTS.max + state.interval = FIELD_DEFAULTS.interval state.comment = '' state.fixed = true state.stringValue = '' state.registerLength = '10' state.serverRegisterEdit = undefined + state.pristine = undefined state.addressInUse = false state.addressFitError = false state.valid = { diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx new file mode 100644 index 0000000..711cc82 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/addRegisterActions.tsx @@ -0,0 +1,131 @@ +/** + * The dialog's buttons, and the submit they share. + */ +import Button from '@mui/material/Button' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { useCallback, useState } from 'react' +import Delete from '@mui/icons-material/Delete' +import { registerWidth } from '@shared' +import { isFormDirty } from './addRegister.zustand.helpers' + +export const AddButtons = meme(() => { + const edit = useAddRegisterZustand((z) => z.serverRegisterEdit !== undefined) + const dirty = useAddRegisterZustand((z) => isFormDirty(z, z.pristine)) + const valid = useAddRegisterZustand((z) => { + if (z.dataType === 'utf8') { + return z.valid.address && z.valid.stringValue && z.valid.registerLength + } + if (['unix', 'datetime'].includes(z.dataType)) { + return z.fixed ? z.valid.address : z.valid.address && z.valid.interval + } + if (z.fixed) return z.valid.address && z.valid.value + return z.valid.address && z.valid.min && z.valid.max && z.valid.interval + }) + + const handleAddAndClose = useCallback(() => { + const result = useAddRegisterZustand.getState().submit(edit) + if (!result) return + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.resetToDefaults() + addRegisterZustand.setRegisterType(undefined) + }, [edit]) + + const handleAddAndNext = useCallback(() => { + const result = useAddRegisterZustand.getState().submit(false) + if (!result) return + const { address, dataType } = result + const addRegisterZustand = useAddRegisterZustand.getState() + const size = registerWidth(dataType, Number(addRegisterZustand.registerLength) || undefined) + // Reset value and comment, keep dataType/LE/fixed/min/max/interval + addRegisterZustand.setValue('0', true) + addRegisterZustand.setComment('') + if (dataType === 'utf8') addRegisterZustand.setStringValue('') + addRegisterZustand.initNextUnusedAddress(address + size) + }, []) + + const handleEditSubmit = useCallback(() => { + const result = useAddRegisterZustand.getState().submit(true) + if (!result) return + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setRegisterType(undefined) + addRegisterZustand.setEditRegister(undefined) + }, []) + + if (edit) { + return ( + + ) + } + + return ( + <> + + + + ) +}) + +/** + * Removes the register being edited. + * + * It goes disabled the moment a field is touched, so the two buttons never + * offer to do different things with what the dialog holds. A user who has + * typed a new address is moving the register, and Remove there answered for + * the typed address instead: the register stayed, whatever sat at the typed + * address went, and the dialog closed as though it had worked. + */ +export const DeleteButton = meme(() => { + const [over, setOver] = useState(false) + const dirty = useAddRegisterZustand((z) => isFormDirty(z, z.pristine)) + + const handleClick = useCallback(() => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.remove() + addRegisterZustand.setRegisterType(undefined) + addRegisterZustand.setEditRegister(undefined) + }, []) + + return ( + + ) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx new file mode 100644 index 0000000..127ce2a --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/maskedInputs.tsx @@ -0,0 +1,177 @@ +/** + * The six masked inputs, each an IMask wrapper behind a forwardRef. + * + * Six near-identical pairs, which is why they sit together: whatever is done + * to one of them should be done to all six, and that is easier to see here + * than spread through the dialog. + */ +import { useAddRegisterZustand } from './addRegister.zustand' +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 { notEmpty, registerWidth } from '@shared' +import { useMinMaxInteger } from '@renderer/hooks' + +const AddressInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + + // Set maximum address based on data type + const maxAddress = useAddRegisterZustand((z) => + Math.max(0, 65535 - registerWidth(z.dataType, Number(z.registerLength) || undefined) + 1) + ) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +AddressInputForward.displayName = 'AddressInput' + +export const AddressInput = meme(AddressInputForward) + +const ValueInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const { min, max, integer } = useMinMaxInteger(dataType) + + return ( + { + set(value, notEmpty(value)) + }} + /> + ) +}) + +ValueInputForward.displayName = 'ValueInput' + +export const ValueInput = meme(ValueInputForward) + +const MinInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const maxValue = useAddRegisterZustand((z) => z.max) + const { min, max, integer } = useMinMaxInteger(dataType, 'min', maxValue) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +MinInputForward.displayName = 'MinInput' + +export const MinInput = meme(MinInputForward) + +const MaxInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + const dataType = useAddRegisterZustand((z) => z.dataType) + const minValue = useAddRegisterZustand((z) => z.min) + const { min, integer, max } = useMinMaxInteger(dataType, 'max', minValue) + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +MaxInputForward.displayName = 'MaxInput' + +export const MaxInput = meme(MaxInputForward) + +// +// +// Interval + +const IntervalInputForward = forwardRef((props, ref) => { + const { set, ...other } = props + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +IntervalInputForward.displayName = 'IntervalInput' + +export const IntervalInput = meme(IntervalInputForward) + +const RegisterLengthForward = forwardRef((props, ref) => { + const { set, ...other } = props + + return ( + set(value, notEmpty(value))} + /> + ) +}) + +RegisterLengthForward.displayName = 'RegisterLengthInput' + +export const RegisterLengthInput = meme(RegisterLengthForward) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx new file mode 100644 index 0000000..bc70c0c --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/registerFields.tsx @@ -0,0 +1,87 @@ +/** + * What the register is: where it lives, how it is read, what it is called. + */ +import FormControl from '@mui/material/FormControl' +import FormHelperText from '@mui/material/FormHelperText' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import { useAddRegisterZustand } from './addRegister.zustand' +import { meme } from '@renderer/components/shared/inputs/meme' +import { maskInputProps } from '@renderer/components/shared/inputs/types' +import { ChangeEvent, ElementType, useCallback } from 'react' +import DataTypeSelectInput from '@renderer/components/shared/inputs/DataTypeSelectInput' +import { AddressInput } from './maskedInputs' + +export const AddressField = meme(() => { + const address = useAddRegisterZustand((z) => String(z.address)) + const addressInUse = useAddRegisterZustand((z) => z.addressInUse) + const addressFitError = useAddRegisterZustand((z) => z.addressFitError) + const valid = useAddRegisterZustand((z) => z.valid.address) + + const setAddress = useAddRegisterZustand.getState().setAddress + + return ( + + , + inputProps: maskInputProps({ set: setAddress }) + } + }} + /> + {addressInUse && In use} + {addressFitError && Data type does not fit at this address} + + ) +}) + +// +// +// +// +// Data Type + +export const DataTypeSelect = meme(() => { + const dataType = useAddRegisterZustand((z) => z.dataType) + + const setDataType = useAddRegisterZustand.getState().setDataType + + return +}) + +// +// +// +// +// Comment + +export const CommentField = meme(() => { + const comment = useAddRegisterZustand((z) => z.comment) + + const handleChange = useCallback((event: ChangeEvent): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setComment(event.target.value) + }, []) + + return ( + + ) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx new file mode 100644 index 0000000..7ac86b9 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/AddRegister/valueParameters.tsx @@ -0,0 +1,345 @@ +/** + * What value the register produces: a fixed one, or a generator. + * + * The fields swap with the data type, so the nine of them are one subject. + */ +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +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 { ChangeEvent, ElementType, useCallback, useEffect } from 'react' +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' +import { DateTime } from 'luxon' +import { ValueInput, MinInput, MaxInput, IntervalInput, RegisterLengthInput } from './maskedInputs' + +export const FixedOrGenerator = meme(() => { + const fixed = useAddRegisterZustand((z) => z.fixed) + const dataType = useAddRegisterZustand((z) => z.dataType) + + const handleChange = useCallback((_event: unknown, value: boolean | null): void => { + if (value === null) return + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setFixed(value) + }, []) + + // UTF-8 and BITMAP are always fixed — hide toggle + if (dataType === 'utf8' || dataType === 'bitmap') return null + + return ( + + + Fixed + + + Generator + + + ) +}) + +// +// +// +// +// Value Input + +const ValueInputComponent = meme(() => { + const value = useAddRegisterZustand((z) => z.value) + const valid = useAddRegisterZustand((z) => z.valid.value) + + const setValue = useAddRegisterZustand.getState().setValue + + return ( + , + inputProps: maskInputProps({ set: setValue }) + } + }} + /> + ) +}) + +// +// +// +// +// Min/Max Masks + +const MinTextField = meme(() => { + const min = useAddRegisterZustand((z) => String(z.min)) + const valid = useAddRegisterZustand((z) => z.valid.min) + + const setMin = useAddRegisterZustand.getState().setMin + + return ( + , + inputProps: maskInputProps({ set: setMin }) + } + }} + /> + ) +}) + +const MaxTextField = meme(() => { + const max = useAddRegisterZustand((z) => String(z.max)) + const valid = useAddRegisterZustand((z) => z.valid.max) + + const setMax = useAddRegisterZustand.getState().setMax + + return ( + , + inputProps: maskInputProps({ set: setMax }) + } + }} + /> + ) +}) + +// +// +// +// +// Interval + +const IntervalTextField = meme(() => { + const interval = useAddRegisterZustand((z) => String(z.interval)) + const valid = useAddRegisterZustand((z) => z.valid.interval) + + const setInterval = useAddRegisterZustand.getState().setInterval + + return ( + , + inputProps: maskInputProps({ set: setInterval }) + } + }} + /> + ) +}) + +// +// +// +// +// DateTimePicker for unix/datetime fixed mode + +const DateTimeField = meme(() => { + const value = useAddRegisterZustand((z) => z.value) + const showDatePickerUtc = useAddRegisterZustand((z) => z.showDatePickerUtc) + + const handleChange = useCallback((dt: DateTime | null): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + if (dt && dt.isValid) addRegisterZustand.setValue(String(dt.toMillis()), true) + }, []) + + const handleUtcChange = useCallback((): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setShowDatePickerUtc(!addRegisterZustand.showDatePickerUtc) + }, []) + + const dateValue = value && value !== '0' ? DateTime.fromMillis(Number(value)) : DateTime.now() + + return ( + + + + + UTC + + + + ) +}) + +// +// +// +// +// String value input for utf8 + +const StringValueField = meme(() => { + const stringValue = useAddRegisterZustand((z) => z.stringValue) + const maxBytes = useAddRegisterZustand((z) => (Number(z.registerLength) || 10) * 2) + const valid = useAddRegisterZustand((z) => z.valid.stringValue) + + const handleChange = useCallback((event: ChangeEvent): void => { + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setStringValue(event.target.value) + }, []) + + useEffect(() => { + // Reevaluate string length when changing register Length + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setStringValue(addRegisterZustand.stringValue) + }, [maxBytes]) + + const helperText = `${new TextEncoder().encode(stringValue).length} / ${maxBytes} bytes` + + return ( + + ) +}) + +// +// +// +// +// Register length input for utf8 + +const RegisterLengthField = meme(() => { + const registerLength = useAddRegisterZustand((z) => z.registerLength) + const valid = useAddRegisterZustand((z) => z.valid.registerLength) + + const setRegisterLength = useAddRegisterZustand.getState().setRegisterLength + + return ( + , + inputProps: maskInputProps({ set: setRegisterLength }) + } + }} + /> + ) +}) + +// +// +// +// +// ValueParameters + +export const ValueParameters = meme(() => { + const fixed = useAddRegisterZustand((z) => z.fixed) + const dataType = useAddRegisterZustand((z) => z.dataType) + + // UTF-8: string input + register length + if (dataType === 'utf8') { + return ( + <> + + + + ) + } + + // Unix/datetime fixed: date picker + if (['unix', 'datetime'].includes(dataType) && fixed) { + return + } + + // Unix/datetime generator: only interval + if (['unix', 'datetime'].includes(dataType) && !fixed) { + return + } + + // Numeric fixed: value input + if (fixed) { + return + } + + // Numeric generator: min/max/interval + return ( + <> + + + + + ) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx similarity index 88% rename from src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx rename to src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx index ef22d99..01e48cf 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/ServerBitMapDetail.tsx @@ -1,9 +1,9 @@ -import { Box } from '@mui/material' +import Box from '@mui/material/Box' import { ServerRegisterEntry, BitMapConfig, getBit } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback } from 'react' -import ServerBit from '../shared/ServerBit' +import ServerBit from '../../shared/ServerBit' interface ServerBitMapDetailProps { register: ServerRegisterEntry @@ -18,27 +18,28 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele const uuid = useServerZustand((z) => z.selectedUuid) const unitId = useServerZustand((z) => z.getUnitId(z.selectedUuid)) const littleEndian = useServerZustand((z) => z.littleEndian[z.selectedUuid] ?? false) - const addRegister = useServerZustand((z) => z.addRegister) const handleToggle = useCallback( (bitIndex: number) => { + const serverZustand = useServerZustand.getState() const currentValue = register.value const newValue = getBit(currentValue, bitIndex) ? currentValue & ~(1 << bitIndex) : currentValue | (1 << bitIndex) - addRegister({ + serverZustand.addRegister({ uuid, unitId, params: { ...params, value: newValue, min: undefined, max: undefined, interval: undefined }, littleEndian }) }, - [register.value, params, uuid, unitId, littleEndian, addRegister] + [register.value, params, uuid, unitId, littleEndian] ) const handleCommentChange = useCallback( (bitIndex: number, comment: string | undefined) => { + const serverZustand = useServerZustand.getState() const current = bitConfig ?? {} const entry = current[String(bitIndex)] ?? {} const updated: BitMapConfig = { @@ -53,7 +54,7 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele } const newBitMap = Object.keys(updated).length > 0 ? updated : undefined - addRegister({ + serverZustand.addRegister({ uuid, unitId, params: { @@ -63,7 +64,7 @@ const ServerBitMapDetail = meme(({ register }: ServerBitMapDetailProps): JSX.Ele littleEndian }) }, - [bitConfig, params, uuid, unitId, littleEndian, addRegister] + [bitConfig, params, uuid, unitId, littleEndian] ) return ( diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx new file mode 100644 index 0000000..efe1921 --- /dev/null +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerBitMapDetail/__tests__/ServerBitMapDetail.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom +/// +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ServerRegisterEntry } from '@shared' + +// ─── Store stub ────────────────────────────────────────────────────── +// The real server store persists through window.api on import, which is more +// machinery than sixteen circles need. + +const mockAddRegister = vi.fn() +const serverState = { + selectedUuid: 'main', + getUnitId: (): string => '0', + littleEndian: { main: false }, + addRegister: mockAddRegister +} + +vi.mock('@renderer/context/server.zustand', () => ({ + useServerZustand: Object.assign( + (selector: (state: typeof serverState) => unknown) => selector(serverState), + { getState: () => serverState } + ) +})) + +import ServerBitMapDetail from '../ServerBitMapDetail' + +const BIT_INDICES = Array.from({ length: 16 }, (_, i) => i) + +/** A fixed bitmap register, which is what the expander offers the panel. */ +const bitmapAt100 = ( + value: number, + bitMap?: Record +): ServerRegisterEntry => ({ + value, + params: { + address: 100, + registerType: 'holding_registers', + dataType: 'bitmap', + comment: 'server status', + value, + min: undefined, + max: undefined, + interval: undefined, + bitMap + } +}) + +/** The register params the panel wrote back, or a failure naming what is missing. */ +const writtenParams = (): ServerRegisterEntry['params'] => { + const call = mockAddRegister.mock.calls[0]?.[0] + if (!call) throw new Error('addRegister was never called') + return call.params +} + +beforeEach(() => { + mockAddRegister.mockClear() +}) + +describe('which bits the panel shows as on', () => { + it('reads them out of the register value', () => { + render() + + for (const bitIndex of BIT_INDICES) { + expect(screen.getByTestId(`server-bit-circle-${bitIndex}`)).toHaveAttribute( + 'data-active', + bitIndex === 0 || bitIndex === 2 ? 'true' : 'false' + ) + } + }) + + it('shows the top bit of the word', () => { + render() + + expect(screen.getByTestId('server-bit-circle-15')).toHaveAttribute('data-active', 'true') + expect(screen.getByTestId('server-bit-circle-0')).toHaveAttribute('data-active', 'false') + }) +}) + +describe('toggling a bit', () => { + it('sets one that was off', async () => { + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-1')) + + expect(writtenParams().value).toBe(7) + }) + + it('clears one that was on', async () => { + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-0')) + + expect(writtenParams().value).toBe(4) + }) + + // A toggled bit is a value the user set, and a generator would write over it + // on its next interval. + it('drops the generator fields', async () => { + const generator = bitmapAt100(5) + generator.params.min = 0 + generator.params.max = 65535 + generator.params.interval = 1000 + + render() + + await userEvent.click(screen.getByTestId('server-bit-circle-1')) + + const params = writtenParams() + expect(params.min).toBeUndefined() + expect(params.max).toBeUndefined() + expect(params.interval).toBeUndefined() + }) +}) + +describe('the bit comments', () => { + it('come from the register bitMap', () => { + render() + + expect(screen.getByTestId('server-bit-comment-2')).toHaveTextContent('warning lamp') + expect(screen.getByTestId('server-bit-comment-3')).toHaveTextContent('...') + }) + + it('writes an edited one back under its own index', async () => { + render() + + await userEvent.click(screen.getByTestId('server-bit-comment-7')) + await userEvent.type(screen.getByRole('textbox'), 'heartbeat{Enter}') + + expect(writtenParams().bitMap).toEqual({ + '7': { comment: 'heartbeat' } + }) + }) +}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx index 046ac63..eb9c5ce 100644 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx +++ b/src/renderer/src/components/server/ServerGrid/ServerRegisters/ServerRegisters.tsx @@ -1,13 +1,19 @@ -import { Edit, ExpandLess, ExpandMore } from '@mui/icons-material' -import { Paper, Box, IconButton, alpha } from '@mui/material' +import Edit from '@mui/icons-material/Edit' +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 { NumberRegisters, ServerRegister } from '@shared' import { useServerZustand } from '@renderer/context/server.zustand' import { meme } from '@renderer/components/shared/inputs/meme' +import { gridSurface } from '@renderer/theme' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useAddRegisterZustand } from './addRegister.zustand' +import { useAddRegisterZustand } from './AddRegister/addRegister.zustand' import ServerPartTitle from '../ServerPartTitle/ServerPartTitle' import useServerGridZustand from '../serverGrid.zustand' -import ServerBitMapDetail from './ServerBitMapDetail' +import ServerBitMapDetail from './ServerBitMapDetail/ServerBitMapDetail' import { DateTime } from 'luxon' interface RowProps { @@ -16,8 +22,8 @@ interface RowProps { const RowEdit = meme(({ register }: RowProps) => { const handleClick = useCallback(() => { - const state = useAddRegisterZustand.getState() - state.setEditRegister(register) + const addRegisterZustand = useAddRegisterZustand.getState() + addRegisterZustand.setEditRegister(register) }, [register]) return ( @@ -63,7 +69,7 @@ const getDisplayValue = (register: ServerRegister[number]): string | number => { return register.value } -const ServerRegisterValue = ({ register }: RowProps): JSX.Element => { +const ServerRegisterValue = meme(({ register }: RowProps): JSX.Element => { const [displayValue, setDisplayValue] = useState(() => getDisplayValue(register)) useEffect(() => { @@ -75,8 +81,15 @@ const ServerRegisterValue = ({ register }: RowProps): JSX.Element => { } }, [register.value, register.params.stringValue, register]) - return {displayValue} -} + return ( + + {displayValue} + + ) +}) const ServerRegisterRow = meme(({ register }: RowProps) => { const isBitmap = register.params.dataType === 'bitmap' @@ -183,7 +196,7 @@ const ServerRegisters = meme(({ name, type }: ServerRegistersProps) => { flex: 1, width: '100%', height: '100%', - backgroundColor: '#2A2A2A', + backgroundColor: gridSurface, fontSize: '0.95em', position: 'relative' }} diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/addRegister.zustand.helpers.test.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/addRegister.zustand.helpers.test.ts deleted file mode 100644 index ab47797..0000000 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/__tests__/addRegister.zustand.helpers.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { getRegisterSize, isAddressInUse } from '../addRegister.zustand.helpers' - -// ─── getRegisterSize ──────────────────────────────────────────────── - -describe('getRegisterSize', () => { - it('returns 1 for 16-bit types', () => { - expect(getRegisterSize('int16')).toBe(1) - expect(getRegisterSize('uint16')).toBe(1) - }) - - it('returns 2 for 32-bit types', () => { - expect(getRegisterSize('int32')).toBe(2) - expect(getRegisterSize('uint32')).toBe(2) - expect(getRegisterSize('float')).toBe(2) - expect(getRegisterSize('unix')).toBe(2) - }) - - it('returns 4 for 64-bit types', () => { - expect(getRegisterSize('int64')).toBe(4) - expect(getRegisterSize('uint64')).toBe(4) - expect(getRegisterSize('double')).toBe(4) - expect(getRegisterSize('datetime')).toBe(4) - }) - - it('returns provided length for utf8', () => { - expect(getRegisterSize('utf8', 5)).toBe(5) - expect(getRegisterSize('utf8', 124)).toBe(124) - }) - - it('defaults to 10 for utf8 without length', () => { - expect(getRegisterSize('utf8')).toBe(10) - }) -}) - -// ─── isAddressInUse ───────────────────────────────────────────────── - -describe('isAddressInUse', () => { - it('returns false when no addresses are used', () => { - expect(isAddressInUse([], 'int16', 0)).toBe(false) - }) - - it('returns true when exact address is used', () => { - expect(isAddressInUse([5], 'int16', 5)).toBe(true) - }) - - it('returns false when address is not used', () => { - expect(isAddressInUse([5], 'int16', 6)).toBe(false) - }) - - // Multi-register overlap - it('detects overlap for int32 (2 registers)', () => { - // INT32 at address 10 needs addresses 10, 11 - expect(isAddressInUse([11], 'int32', 10)).toBe(true) - expect(isAddressInUse([10], 'int32', 10)).toBe(true) - expect(isAddressInUse([12], 'int32', 10)).toBe(false) - }) - - it('detects overlap for int64 (4 registers)', () => { - // INT64 at address 100 needs 100, 101, 102, 103 - expect(isAddressInUse([103], 'int64', 100)).toBe(true) - expect(isAddressInUse([104], 'int64', 100)).toBe(false) - }) - - it('detects overlap for utf8 with custom length', () => { - // UTF-8 with length 3 at address 50 needs 50, 51, 52 - expect(isAddressInUse([52], 'utf8', 50, 3)).toBe(true) - expect(isAddressInUse([53], 'utf8', 50, 3)).toBe(false) - }) - - // Edit mode — exclude current register's addresses - it('excludes edit register addresses in edit mode', () => { - // Address 10 is used, but we're editing the register at 10 - const used = [10] - const edit = { dataType: 'int16' as const, address: 10 } - expect(isAddressInUse(used, 'int16', 10, undefined, edit)).toBe(false) - }) - - it('excludes multi-register edit addresses', () => { - // Addresses 10, 11 used by INT32, editing that same register - const used = [10, 11] - const edit = { dataType: 'int32' as const, address: 10 } - expect(isAddressInUse(used, 'int32', 10, undefined, edit)).toBe(false) - }) - - it('detects conflict even in edit mode when moving to occupied address', () => { - // Addresses 10, 11 (INT32 being edited) and 20 (another register) - const used = [10, 11, 20] - const edit = { dataType: 'int32' as const, address: 10 } - // Moving to address 20 should conflict - expect(isAddressInUse(used, 'int16', 20, undefined, edit)).toBe(true) - }) - - it('allows moving edit register to a free address', () => { - const used = [10, 11] - const edit = { dataType: 'int32' as const, address: 10 } - // Moving to address 50 is fine - expect(isAddressInUse(used, 'int32', 50, undefined, edit)).toBe(false) - }) - - it('handles edit register with utf8 length', () => { - // UTF-8 register at address 0 with length 5 occupies 0-4 - const used = [0, 1, 2, 3, 4, 10] - const edit = { dataType: 'utf8' as const, address: 0, length: 5 } - // Changing to address 0 with same size should be fine (it's the same register) - expect(isAddressInUse(used, 'utf8', 0, 5, edit)).toBe(false) - // But address 10 is still occupied by another register - expect(isAddressInUse(used, 'int16', 10, undefined, edit)).toBe(true) - }) - - // Edge cases - it('handles empty used addresses with edit register', () => { - const edit = { dataType: 'int16' as const, address: 5 } - expect(isAddressInUse([], 'int16', 0, undefined, edit)).toBe(false) - }) - - it('handles address at boundary (65535)', () => { - expect(isAddressInUse([], 'int16', 65535)).toBe(false) - expect(isAddressInUse([65535], 'int16', 65535)).toBe(true) - }) - - it('detects partial overlap when expanding data type in edit mode', () => { - // INT16 at address 10 being edited, but address 11 is used by another register - const used = [10, 11] - const edit = { dataType: 'int16' as const, address: 10 } - // Changing to INT32 at address 10 needs 10+11, but 11 belongs to another register - expect(isAddressInUse(used, 'int32', 10, undefined, edit)).toBe(true) - }) -}) diff --git a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.helpers.ts b/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.helpers.ts deleted file mode 100644 index 1b5add3..0000000 --- a/src/renderer/src/components/server/ServerGrid/ServerRegisters/addRegister.zustand.helpers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { DataType } from '@shared' - -/** - * Returns the number of Modbus registers a data type occupies. - */ -export const getRegisterSize = (dataType: DataType, length?: number): number => { - if (['double', 'uint64', 'int64', 'datetime'].includes(dataType)) return 4 - if (['uint32', 'int32', 'float', 'unix'].includes(dataType)) return 2 - if (dataType === 'utf8') return length ?? 10 - return 1 -} - -/** - * Pure function that checks whether an address (+ its data-type span) overlaps - * with already-used addresses, optionally excluding the addresses of the - * register currently being edited. - */ -export const isAddressInUse = ( - usedAddresses: number[], - dataType: DataType, - address: number, - length?: number, - editRegister?: { dataType: DataType; address: number; length?: number } -): boolean => { - const size = getRegisterSize(dataType, length) - const addressesNeeded = Array.from({ length: size }, (_, i) => address + i) - - if (editRegister) { - const editSize = getRegisterSize(editRegister.dataType, editRegister.length) - const editAddresses = Array.from({ length: editSize }, (_, i) => editRegister.address + i) - const filteredUsed = usedAddresses.filter((a) => !editAddresses.includes(a)) - return addressesNeeded.some((a) => filteredUsed.includes(Number(a))) - } - - return addressesNeeded.some((a) => usedAddresses.includes(Number(a))) -} diff --git a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx index 4617b56..5f37e1b 100644 --- a/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx +++ b/src/renderer/src/components/server/ServerGrid/shared/ServerBit.tsx @@ -1,4 +1,7 @@ -import { Box, TextField, Typography, alpha } from '@mui/material' +import Box from '@mui/material/Box' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { alpha } from '@mui/material/styles' import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useEffect, useState } from 'react' @@ -69,6 +72,9 @@ const ServerBit = meme( {/* Toggle circle */} ({ width: 12, @@ -103,6 +109,7 @@ const ServerBit = meme( {/* Comment — inline editable */} {!readOnly && editing ? ( ) : ( +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' + +import ServerBit, { ServerBitProps } from '../ServerBit' + +const renderBit = (props: Partial = {}): void => { + render( + + ) +} + +describe('the toggle circle', () => { + it('carries a bit that is on', () => { + renderBit({ active: true }) + expect(screen.getByTestId('server-bit-circle-3')).toHaveAttribute('data-active', 'true') + }) + + it('carries a bit that is off', () => { + renderBit({ active: false }) + expect(screen.getByTestId('server-bit-circle-3')).toHaveAttribute('data-active', 'false') + }) + + it('toggles on click', async () => { + const onToggle = vi.fn() + renderBit({ onToggle }) + + await userEvent.click(screen.getByTestId('server-bit-circle-3')) + + expect(onToggle).toHaveBeenCalledOnce() + }) + + it('does not toggle when the row is read only', async () => { + const onToggle = vi.fn() + renderBit({ onToggle, readOnly: true }) + + await userEvent.click(screen.getByTestId('server-bit-circle-3')) + + expect(onToggle).not.toHaveBeenCalled() + }) +}) + +describe('the comment', () => { + it('is editable by clicking what is shown', async () => { + const onCommentChange = vi.fn() + renderBit({ comment: 'run', onCommentChange }) + + await userEvent.click(screen.getByTestId('server-bit-comment-3')) + await userEvent.clear(screen.getByRole('textbox')) + await userEvent.type(screen.getByRole('textbox'), 'motor running{Enter}') + + expect(onCommentChange).toHaveBeenCalledWith('motor running') + }) + + it('is not editable when the row is read only', async () => { + const onCommentChange = vi.fn() + renderBit({ comment: 'run', onCommentChange, readOnly: true }) + + await userEvent.click(screen.getByTestId('server-bit-comment-3')) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/shared/CommandBlock.tsx b/src/renderer/src/components/shared/CommandBlock.tsx index 0de3a84..2ba5389 100644 --- a/src/renderer/src/components/shared/CommandBlock.tsx +++ b/src/renderer/src/components/shared/CommandBlock.tsx @@ -1,5 +1,10 @@ -import { Box, IconButton, Tooltip, Typography } from '@mui/material' -import { Check, ContentCopy } from '@mui/icons-material' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import Check from '@mui/icons-material/Check' +import ContentCopy from '@mui/icons-material/ContentCopy' +import { meme } from '@renderer/components/shared/inputs/meme' import { useCallback, useState } from 'react' /** @@ -9,53 +14,60 @@ import { useCallback, useState } from 'react' * screen rather than describing it. `copied` is local on purpose: two seconds * of a changed icon belongs to this element and nothing else reads it. */ -const CommandBlock = ({ command, testId }: { command: string; testId: string }): JSX.Element => { - const [copied, setCopied] = useState(false) +const CommandBlock = meme( + ({ command, testId }: { command: string; testId: string }): JSX.Element => { + const [copied, setCopied] = useState(false) - const handleCopy = useCallback(async (): Promise => { - try { - await navigator.clipboard.writeText(command) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch { - // Clipboard can be unavailable; the command stays selectable on screen. - } - }, [command]) + const handleCopy = useCallback(async (): Promise => { + try { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard can be unavailable; the command stays selectable on screen. + } + }, [command]) - return ( - ({ - display: 'flex', - alignItems: 'center', - gap: 1, - p: 1, - pl: 1.5, - borderRadius: 1, - border: `1px solid ${theme.palette.divider}`, - // A shade up from the dialog surface, as the scan modals nest theirs. - background: theme.palette.background.paper - })} - > - ({ + display: 'flex', + alignItems: 'center', + gap: 1, + p: 1, + pl: 1.5, + borderRadius: 1, + border: `1px solid ${theme.palette.divider}`, + // A shade up from the dialog surface, as the scan modals nest theirs. + background: theme.palette.background.paper + })} > - {command} - - - - {copied ? : } - - - - ) -} + + {command} + + + + {copied ? : } + + + + ) + } +) export default CommandBlock diff --git a/src/renderer/src/components/shared/HomeButton.tsx b/src/renderer/src/components/shared/HomeButton.tsx index fe39495..54742ab 100644 --- a/src/renderer/src/components/shared/HomeButton.tsx +++ b/src/renderer/src/components/shared/HomeButton.tsx @@ -1,11 +1,17 @@ import Button from '@mui/material/Button' -import { Home } from '@mui/icons-material' +import Home from '@mui/icons-material/Home' +import { meme } from '@renderer/components/shared/inputs/meme' import { useLayoutZustand } from '@renderer/context/layout.zustand' +import { useCallback } from 'react' -const HomeButton = (): JSX.Element | null => { - const setAppType = useLayoutZustand((z) => z.setAppType) +const HomeButton = meme((): JSX.Element | null => { const hideHomeButton = useLayoutZustand((z) => z.hideHomeButton) + const handleClick = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.setAppType(undefined) + }, []) + return hideHomeButton ? null : ( ) -} +}) export default HomeButton diff --git a/src/renderer/src/components/shared/MessageReceiver.tsx b/src/renderer/src/components/shared/MessageReceiver.tsx index 6eed740..8de11bc 100644 --- a/src/renderer/src/components/shared/MessageReceiver.tsx +++ b/src/renderer/src/components/shared/MessageReceiver.tsx @@ -1,11 +1,16 @@ +import { meme } from '@renderer/components/shared/inputs/meme' +import { useClientZustand } from '@renderer/context/client.zustand' +import { useServerZustand } from '@renderer/context/server.zustand' import { onEvent } from '@renderer/events' -import { BackendMessage } from '@shared' +import { BackendMessage, resetMessage } from '@shared' import { useSnackbar } from 'notistack' import { useCallback, useEffect } from 'react' // Receives message and shows them in a snackbar -const MessageReceiver = (): null => { +const MessageReceiver = meme((): null => { const { enqueueSnackbar } = useSnackbar() + const clientConfigReset = useClientZustand((z) => z.configReset) + const serverConfigReset = useServerZustand((z) => z.configReset) const handleMessage = useCallback( (message: BackendMessage) => { @@ -22,6 +27,27 @@ const MessageReceiver = (): null => { return (): void => unlisten() }, [handleMessage]) + // 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. + // + // Acknowledged after telling, because this component mounts inside Client and + // Server rather than at the root: without that, walking Home and back reports + // the same reset again. + useEffect(() => { + const clientZustand = useClientZustand.getState() + const serverZustand = useServerZustand.getState() + if (clientConfigReset !== undefined) { + enqueueSnackbar({ variant: 'error', message: resetMessage('Client', clientConfigReset) }) + clientZustand.acknowledgeConfigReset() + } + if (serverConfigReset !== undefined) { + enqueueSnackbar({ variant: 'error', message: resetMessage('Server', serverConfigReset) }) + serverZustand.acknowledgeConfigReset() + } + }, [clientConfigReset, serverConfigReset, enqueueSnackbar]) + return null -} +}) export default MessageReceiver diff --git a/src/renderer/src/components/shared/SliderComponent.tsx b/src/renderer/src/components/shared/SliderComponent.tsx index 03e0d26..9856d52 100644 --- a/src/renderer/src/components/shared/SliderComponent.tsx +++ b/src/renderer/src/components/shared/SliderComponent.tsx @@ -1,51 +1,60 @@ import Box from '@mui/material/Box' import Slider from '@mui/material/Slider' import Typography from '@mui/material/Typography' +import { meme } from '@renderer/components/shared/inputs/meme' -interface Props { +interface SliderComponentProps { label: string value: number setValue: (value: number) => void + testId: string } -const SliderComponent = ({ label, value, setValue }: Props): JSX.Element => { - const labelWidth = 70 - const valueWidth = 25 +const SliderComponent = meme( + ({ label, value, setValue, testId }: SliderComponentProps): JSX.Element => { + const labelWidth = 70 + const valueWidth = 25 - return ( - - - {label} - - - { - const value = Array.isArray(v) ? v.at(0) : v - if (value === undefined) return - setValue(value) - }} - /> + return ( + + + {label} + + + { + const value = Array.isArray(v) ? v.at(0) : v + if (value === undefined) return + setValue(value) + }} + /> + + + {value} s + - - {value} s - - - ) -} + ) + } +) export default SliderComponent diff --git a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx index 047a632..0eefb14 100644 --- a/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx +++ b/src/renderer/src/components/shared/inputs/AddressBaseInput.tsx @@ -1,9 +1,13 @@ -import { InputBaseComponentProps, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material' -import { useRootZustand } from '@renderer/context/root.zustand' -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { InputBaseComponentProps } from '@mui/material/InputBase' +import TextField from '@mui/material/TextField' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { useClientZustand } from '@renderer/context/client.zustand' +import { MaskSetFn } from '@renderer/context/client.zustand.types' import { ElementType, useCallback } from 'react' import { maskInputProps } from './types' import UIntInput from './UintInput' +import { meme } from './meme' interface AddressBaseInputProps { disabled?: boolean @@ -13,66 +17,67 @@ interface AddressBaseInputProps { baseTestId: string } -const AddressBaseInput = ({ - disabled, - address, - setAddress, - testId, - baseTestId -}: AddressBaseInputProps): JSX.Element => { - const addressBase = useRootZustand((z) => z.registerConfig.addressBase) - const setAddressBase = useRootZustand((z) => z.setAddressBase) +const AddressBaseInput = meme( + ({ disabled, address, setAddress, testId, baseTestId }: AddressBaseInputProps): JSX.Element => { + const addressBase = useClientZustand((z) => z.registerConfig.addressBase) - const base = Number(addressBase) - const displayValue = String(address + base) + const handleBaseChange = useCallback((_event: unknown, value: '0' | '1' | null): void => { + if (value === null) return + const clientZustand = useClientZustand.getState() + clientZustand.setAddressBase(value) + }, []) - const handleSetAddress = useCallback( - (v: string) => setAddress(String(Math.max(0, Number(v) - base))), - [setAddress, base] - ) + const base = Number(addressBase) + const displayValue = String(address + base) - return ( - , - inputProps: maskInputProps({ set: handleSetAddress, max: 65535 + base }), - endAdornment: ( - v !== null && setAddressBase(v)} - > - - 0 - - setAddress(String(Math.max(0, Number(v) - base))), + [setAddress, base] + ) + + return ( + , + inputProps: maskInputProps({ set: handleSetAddress, max: 65535 + base }), + endAdornment: ( + - 1 - - - ) - } - }} - /> - ) -} + + 0 + + + 1 + + + ) + } + }} + /> + ) + } +) export default AddressBaseInput diff --git a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx index 62ed59a..0fb99f1 100644 --- a/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx +++ b/src/renderer/src/components/shared/inputs/DataTypeSelectInput.tsx @@ -1,43 +1,48 @@ -import { FormControl, InputLabel, Select, MenuItem } from '@mui/material' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' import { BaseDataType } from '@shared' import { meme } from './meme' -interface Props { +interface DataTypeSelectInputProps { disabled?: boolean dataType: BaseDataType setDataType: (dataType: BaseDataType) => void } -const DataTypeSelectInput = meme(({ disabled, dataType, setDataType }: Props) => { - const labelId = 'data-type-select' - return ( - - Type - setDataType(e.target.value as BaseDataType)} + > + INT16 + UINT16 + INT32 + UINT32 + FLOAT - INT64 - UINT64 - DOUBLE + INT64 + UINT64 + DOUBLE - UNIX - DATETIME - UTF-8 - BITMAP - - - ) -}) + UNIX + DATETIME + UTF-8 + BITMAP + + + ) + } +) export default DataTypeSelectInput diff --git a/src/renderer/src/components/shared/inputs/EndianTable.tsx b/src/renderer/src/components/shared/inputs/EndianTable.tsx index 15738ad..fb40f1b 100644 --- a/src/renderer/src/components/shared/inputs/EndianTable.tsx +++ b/src/renderer/src/components/shared/inputs/EndianTable.tsx @@ -1,5 +1,12 @@ -import { Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' +import Paper from '@mui/material/Paper' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Typography from '@mui/material/Typography' import { tableCellClasses } from '@mui/material/TableCell' +import { meme } from './meme' /** * What BE and LE do to one value, shown rather than described. @@ -13,82 +20,86 @@ import { tableCellClasses } from '@mui/material/TableCell' * register grid, so a value looks the same wherever it appears. */ -const Hex = ({ children }: { children: string }): JSX.Element => ( - ({ - fontFamily: 'monospace', - color: theme.palette.primary.light, - fontSize: '0.9em' - })} - > - {children} - +const Hex = meme( + ({ children }: { children: string }): JSX.Element => ( + ({ + fontFamily: 'monospace', + color: theme.palette.primary.light, + fontSize: '0.9em' + })} + > + {children} + + ) ) -const EndianTable = (): JSX.Element => ( - - - 32 bit value: 0x12345678 - +const EndianTable = meme( + (): JSX.Element => ( + + + 32 bit value: 0x12345678 + - {/* - Set once here rather than per cell. The size comes down from the table, - and Hex sizes itself against it in em rather than in pixels, so one - number governs the lot. Padding does not come down: a cell brings its - own, so the outer two are cleared with pseudo classes, which is the only - way to reach first and last. - */} - - - - - Register 0 - Register 1 - ST - - - - - Big-Endian - - 0x1234 high - - - 0x5678 low - - - reg[0] := dWord.W1; reg[1] := dWord.W0; - - - - Little-Endian - - 0x5678 low - - - 0x1234 high - - - reg[0] := dWord.W0; reg[1] := dWord.W1; - - - -
+ {/* + Set once here rather than per cell. The size comes down from the table, + and Hex sizes itself against it in em rather than in pixels, so one + number governs the lot. Padding does not come down: a cell brings its + own, so the outer two are cleared with pseudo classes, which is the only + way to reach first and last. + */} + + + + + Register 0 + Register 1 + ST + + + + + Big-Endian + + 0x1234 high + + + 0x5678 low + + + reg[0] := dWord.W1; reg[1] := dWord.W0; + + + + Little-Endian + + 0x5678 low + + + 0x1234 high + + + reg[0] := dWord.W0; reg[1] := dWord.W1; + + + +
- - Big-Endian puts the high word first and is what most devices use. Pick the one your device - uses, or every 32-bit value reads as nonsense. - -
+ + Big-Endian puts the high word first and is what most devices use. Pick the one your device + uses, or every 32-bit value reads as nonsense. + +
+ ) ) export default EndianTable diff --git a/src/renderer/src/components/shared/inputs/HostInput.tsx b/src/renderer/src/components/shared/inputs/HostInput.tsx index 841ebac..8d981c7 100644 --- a/src/renderer/src/components/shared/inputs/HostInput.tsx +++ b/src/renderer/src/components/shared/inputs/HostInput.tsx @@ -1,7 +1,8 @@ import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const HostInput = forwardRef((props, ref) => { +const HostInputForward = forwardRef((props, ref) => { const { set, ...other } = props return ( ((props, ref) => { ) }) -HostInput.displayName = 'HostInput' +HostInputForward.displayName = 'HostInput' + +const HostInput = meme(HostInputForward) export default HostInput diff --git a/src/renderer/src/components/shared/inputs/LengthInput.tsx b/src/renderer/src/components/shared/inputs/LengthInput.tsx index f68682a..231b6e7 100644 --- a/src/renderer/src/components/shared/inputs/LengthInput.tsx +++ b/src/renderer/src/components/shared/inputs/LengthInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const LengthInput = forwardRef((props, ref) => { +const LengthInputForward = forwardRef((props, ref) => { const { set, max = 125, ...other } = props return ( ((props, ref) => ) }) -LengthInput.displayName = 'LengthInput' +LengthInputForward.displayName = 'LengthInput' + +const LengthInput = meme(LengthInputForward) export default LengthInput diff --git a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx index 780a5b0..eb197df 100644 --- a/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx +++ b/src/renderer/src/components/shared/inputs/SerialPortInputs.tsx @@ -1,15 +1,13 @@ -import { - AutocompleteRenderInputParams, - Box, - CircularProgress, - FormControl, - InputLabel, - MenuItem, - Select, - TextField -} from '@mui/material' +import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import TextField from '@mui/material/TextField' import { meme } from './meme' -import { ModbusBaudRate, ModbusBaudRateSchema } from '@shared' +import { ModbusBaudRate, ModbusBaudRateSchema, Parity, ParitySchema } from '@shared' import React, { useMemo } from 'react' export const measureTextWidth = ( @@ -156,8 +154,6 @@ export const BaudRateSelect = meme( } ) -const parityOptions = ['none', 'even', 'odd', 'mark', 'space'] as const - export const ParitySelect = meme( ({ value, @@ -165,8 +161,8 @@ export const ParitySelect = meme( disabled, testId = 'rtu-parity-select' }: { - value: string - onChange: (value: string) => void + value: Parity + onChange: (value: Parity) => void disabled?: boolean testId?: string }) => { @@ -181,11 +177,11 @@ export const ParitySelect = meme( labelId={labelId} value={value} label="Parity" - onChange={(e) => onChange(e.target.value)} + onChange={(e) => onChange(e.target.value as Parity)} sx={{ width: 85 }} data-testid={testId} > - {parityOptions.map((option) => ( + {ParitySchema.options.map((option) => ( {option} diff --git a/src/renderer/src/components/shared/inputs/UintInput.tsx b/src/renderer/src/components/shared/inputs/UintInput.tsx index 1118e26..a508975 100644 --- a/src/renderer/src/components/shared/inputs/UintInput.tsx +++ b/src/renderer/src/components/shared/inputs/UintInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const UIntInput = forwardRef((props, ref) => { +const UIntInputForward = forwardRef((props, ref) => { const { set, max = 65535, ...other } = props return ( ((props, ref) => { ) }) -UIntInput.displayName = 'UIntInput' +UIntInputForward.displayName = 'UIntInput' + +const UIntInput = meme(UIntInputForward) export default UIntInput diff --git a/src/renderer/src/components/shared/inputs/UnitIdInput.tsx b/src/renderer/src/components/shared/inputs/UnitIdInput.tsx index 65bdbbd..a40a1a3 100644 --- a/src/renderer/src/components/shared/inputs/UnitIdInput.tsx +++ b/src/renderer/src/components/shared/inputs/UnitIdInput.tsx @@ -1,8 +1,9 @@ import { IMaskInput, IMask } from 'react-imask' import { forwardRef } from 'react' +import { meme } from './meme' import { MaskInputProps } from './types' -const UnitIdInput = forwardRef((props, ref) => { +const UnitIdInputForward = forwardRef((props, ref) => { const { set, ...other } = props return ( ((props, ref) => ) }) -UnitIdInput.displayName = 'UnitIdInput' +UnitIdInputForward.displayName = 'UnitIdInput' + +const UnitIdInput = meme(UnitIdInputForward) export default UnitIdInput diff --git a/src/renderer/src/components/shared/inputs/types.ts b/src/renderer/src/components/shared/inputs/types.ts index 5b7ba36..73efee6 100644 --- a/src/renderer/src/components/shared/inputs/types.ts +++ b/src/renderer/src/components/shared/inputs/types.ts @@ -1,7 +1,11 @@ -import { MaskSetFn } from '@renderer/context/root.zustand.types' +import { AsyncMaskSetFn, MaskSetFn } from '@renderer/context/client.zustand.types' export interface MaskInputProps { - set: MaskSetFn + /** + * The mask inputs call this and discard what comes back, so a setter that + * waits on the backend fits here too. Server `setPort` is the one that does. + */ + set: MaskSetFn | AsyncMaskSetFn max?: number } export const maskInputProps = (props: MaskInputProps): MaskInputProps => props diff --git a/src/renderer/src/containers/Client.tsx b/src/renderer/src/containers/Client.tsx index 4a37ae5..b4aa987 100644 --- a/src/renderer/src/containers/Client.tsx +++ b/src/renderer/src/containers/Client.tsx @@ -8,10 +8,10 @@ import ClientGrids from '@renderer/components/client/ClientGrids/ClientGrids' import ConnectionConfig from '@renderer/components/client/ConnectionConfig/ConnectionConfig' import ScanRegisters from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanRegistersButton/ScanRegisters/ScanRegisters' import ScanUnitIds from '@renderer/components/client/ClientGrids/RegisterGrid/RegisterGridToolbar/MenuButton/ScanUnitIds/ScanUnitIds' -import { useRootZustand } from '@renderer/context/root.zustand' +import { useClientZustand } from '@renderer/context/client.zustand' const Client = meme(() => { - const ready = useRootZustand((z) => z.ready) + const ready = useClientZustand((z) => z.ready) return ( { - const setAppType = useLayoutZustand((z) => z.setAppType) - const connected = useRootZustand((z) => z.clientState.connectState === 'connected') + const connected = useClientZustand((z) => z.clientState.connectState === 'connected') + + const handleClick = useCallback((): void => { + const layoutZustand = useLayoutZustand.getState() + layoutZustand.setAppType('client') + }, []) + return (