From 72c286db33c3a52deb405fc9de47e6025ed521f6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 17 Aug 2026 08:01:09 +0200 Subject: [PATCH] chore: untrack SDD scratch reports These subagent handoff artifacts were committed before .superpowers/sdd/.gitignore took effect. They are ephemeral scratch, not project content. Co-Authored-By: Claude Opus 4.6 --- .superpowers/sdd/final-review-fix-2-report.md | 203 --------------- .superpowers/sdd/task-4-report.md | 240 ------------------ .superpowers/sdd/task-6-report.md | 170 ------------- 3 files changed, 613 deletions(-) delete mode 100644 .superpowers/sdd/final-review-fix-2-report.md delete mode 100644 .superpowers/sdd/task-4-report.md delete mode 100644 .superpowers/sdd/task-6-report.md diff --git a/.superpowers/sdd/final-review-fix-2-report.md b/.superpowers/sdd/final-review-fix-2-report.md deleted file mode 100644 index 9ee3392..0000000 --- a/.superpowers/sdd/final-review-fix-2-report.md +++ /dev/null @@ -1,203 +0,0 @@ -# Final code review fix — report 2 - -Date: 2026-08-17 -Branch: feature/signal-calibration-config - -## Summary - -Nine findings from the final code review of the signal-calibration feature. -No C++ files were modified (those were already fixed in a separate commit). - ---- - -## Finding 1 — `calibration.js:20` cross-hub parity break in `baseSignalName` - -**File:** `Client/udpstreamer/static/calibration.js` - -**Change:** Line 20: `open > 0` → `open >= 0`. - -An input of `"[0]"` has the `[` at index 0. The old guard `open > 0` let it -fall through and return `"[0]"` unchanged, which then passed the non-empty check -in `normaliseCal` and was accepted as a signal name. Go's `arrayIndexSuffix` -regexp and the C++ `strchr` truncation both reduce `"[0]"` to the empty string -and reject the entry. The fix makes JS match. - -Two assertions added to the existing tests in -`Client/udpstreamer/test/calibration.test.js`: - -- In `'baseSignalName strips an element suffix'`: - `assert.strictEqual(C.baseSignalName('[0]'), '');` -- In `'normaliseCal rejects invalid entries'`: - `assert.strictEqual(C.normaliseCal({source: 'w', signal: '[0]'}), null);` - ---- - -## Finding 2 — `app.js:3470` stale transitional guard - -**File:** `Client/udpstreamer/static/app.js` - -**Change:** Replaced -```js -if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8 -``` -with: -```js -refreshVScaleMenu(); -``` -`refreshVScaleMenu` is a hoisted function declaration and is always defined. -The guard and the task-number comment were both remnants of incremental -development and are now removed. - ---- - -## Finding 3 — `index.html:223` wrong length cap on unit input - -**File:** `Client/udpstreamer/static/index.html` - -**Change:** Removed `maxlength="16"` from ``. - -`maxlength` counts UTF-16 code units, not UTF-8 bytes, so it under-counted -multi-byte characters. `normaliseCal` (using `TextEncoder`) is the correct and -sole enforcement point. - ---- - -## Finding 4 — stale trigger-threshold unit hint on signal change - -**File:** `Client/udpstreamer/static/app.js` - -**Change:** Added `refreshTrigThresholdField();` at both places where -`trig.signal` is assigned in the `trig-signal` change handler (lines ~2555 -and ~2565). Previously the hint was only updated when calibration changed, -not when the user picked a different trigger signal, leaving a stale unit name -in the tooltip. - ---- - -## Finding 5 — `configcheck/main.go:159` dead code - -**File:** `Test/E2E/suite/client/configcheck/main.go` - -**Change:** Deleted `func (c *conn) nextOneOf(...)` (33 lines) and its preceding -doc comment. The function had no callers; the actual reload check uses -`c.next("configReloaded")` and a separate `c.nextWithin("sources", ...)` peek. -No import became orphaned. - ---- - -## Finding 6 — `hub_calibration_test.go:132-134` orphaned comment - -**File:** `Common/Client/go/wshub/hub_calibration_test.go` - -**Change:** Deleted the three-line comment block that introduced `waitBroadcast`, -a function that was never written. The comment sat directly above -`func sleepMillis(...)` and served no purpose. - ---- - -## Finding 7 — `calibration.go:14` false parity claim in comment - -**File:** `Common/Client/go/wshub/calibration.go` - -**Change:** Rewrote the comment above `arrayIndexSuffix`. The old comment said -the regexp "Mirrors the C++ `strchr(signal,'[')` truncation" — which is false. -The regexp is anchored to the end of the string and requires digits; `strchr` -finds the first `[` anywhere in the name. For `A[1]B`, Go's regexp leaves it -unchanged while C++ truncates to `A`. The new comment describes both -implementations accurately and calls out the known difference explicitly. - ---- - -## Finding 8 — `Docs/StreamHub-API.md` missing calibration limits - -**File:** `Docs/StreamHub-API.md` - -**Change:** Added two rows to the §5 Limits table: - -| Limit | Value | -|-------|-------| -| Calibration entries | 256 (C++ hub, `kMaxCalibration`); unbounded (Go hub) | -| Calibration unit override | 16 UTF-8 bytes | - ---- - -## Finding 9 — spec discrepancies - -**File:** `docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md` - -Five sub-items: - -**9a** — `configReloaded` row missing `path` field. -Both hubs always include `path` in `configReloaded` (verified: Go -`buildConfigAckMsg` passes `sm.Path()` as path; C++ `BroadcastConfigAck` -formats `sourcesFile_` as `"path"` in the ok branch for `configReloaded`). -Fixed: added `"path":string` to the `configReloaded` row in the WebSocket -protocol table. - -**9b** — "max 16 chars" → "max 16 UTF-8 bytes". -Fixed the unit validation cell in the data-model table. - -**9c** — `StreamString` fields → fixed `char[]` arrays. -`CalibrationEntry` in `StreamHub.h` uses `char source[128]`, `char signal[128]`, -`char unit[17]` — no `StreamString`. Updated the C++ hub-implementation -paragraph to name the struct and its actual field types. - -**9d** — E2E chain scenario → standalone `configcheck` program. -Replaced the description of a chain-scenario extension with an accurate -description of `Test/E2E/suite/client/configcheck/`, quoting its actual behaviour -(connects to either hub, asserts calibration protocol, exits non-zero on failure). - -**9e** — "four new frames" → five. -Updated the documentation paragraph to list all five frames by name: -`calibration`, `setCalibration`, `configSaved`, `reloadConfig`, `configReloaded`. - ---- - -## Verification output - -### `node --check` + `node --test` - -``` -(node --check static/app.js && node --check static/calibration.js) → SYNTAX OK - -TAP version 13 -ok 1 - baseSignalName strips an element suffix -ok 2 - calKey is stable and separates the two fields -ok 3 - normaliseCal accepts a valid entry and fills defaults -ok 4 - normaliseCal strips an element suffix from the signal name -ok 5 - normaliseCal truncates an over-long unit -ok 6 - normaliseCal leaves short non-ASCII units untouched -ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes -ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune -ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched -ok 10 - normaliseCal rejects invalid entries -ok 11 - applyCal and invertCal round-trip -ok 12 - applyCal passes non-finite samples through untouched -ok 13 - calRange re-orders when the scale is negative -ok 14 - CalTable.get returns IDENTITY for an unknown signal -ok 15 - CalTable.get resolves an element name to its base signal -ok 16 - CalTable.set stores, overwrites, and deletes identity entries -ok 17 - CalTable.replaceAll drops the previous contents -ok 18 - CalTable.list is sorted by source then signal -1..18 -# tests 18 -# pass 18 -# fail 0 -``` - -Note: 18 tests (unchanged from before) because the new assertions were added -inside two existing test functions, not as separate test cases. - -### Go wshub - -``` -cd Common/Client/go && go build ./... && go vet ./... && go test ./wshub/ -ok marte2/common/wshub 1.288s -``` - -### Go configcheck - -``` -cd Test/E2E/suite/client/configcheck && go build ./... && go vet ./... -(silent — CONFIGCHECK OK) -``` diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md deleted file mode 100644 index dfe6f3c..0000000 --- a/.superpowers/sdd/task-4-report.md +++ /dev/null @@ -1,240 +0,0 @@ -# Task 4 Report: C++ StreamHub Calibration Parity - -## What Was Implemented - -### JSON round-trip bug fix (pre-existing) -`JsonGetString` matched `"key":"` (no space after colon) while `HandleSaveSources` wrote `"label": "wave"` with a space, so the C++ hub could never reload a file it wrote itself. Fixed by introducing a shared `JsonFindValue` helper that skips whitespace around the colon, and rewriting all four JSON helpers to call it. Also added `JsonIsFinite` (NaN and infinity detection without ``, using the `v == v` trick plus bound check). - -### Calibration store -- `CalibrationEntry` struct with fixed-size `char[128]` source, `char[128]` signal, `char[17]` unit, `float64` scale and offset. -- `kMaxCalibration = 256u`, `kMaxUnitLen = 16u`. -- Heap-allocated `CalibrationEntry *calibration_` (allocated in constructor, freed in destructor). See critical judgment call below. -- `numCalibration_` and `calibrationMutex_` (FastPollingMutexSem) members. - -### Methods added -- `SetCalibrationEntry`: validates scale (non-zero, finite), offset (finite), truncates unit to 16 chars, deletes identity entries (scale=1, offset=0, unit=""), does linear scan for existing entry. -- `ClearCalibration`: resets `numCalibration_` to 0 under lock. -- `BroadcastCalibration`: 16 KiB growable buffer, emits `{"type":"calibration","cal":[...]}`. -- `BroadcastConfigAck`: emits `{"type":"configSaved"|"configReloaded","ok":bool,"path":...,"error"?:...}`. -- `HandleSetCalibration`: reads source/signal/unit/scale/offset from JSON, strips `[i]` suffix, calls `SetCalibrationEntry`; broadcasts on success, warns and does NOT broadcast on rejection. -- `HandleReloadConfig`: clears calibration, calls `LoadSourcesFile(true)` (skipActive=true), broadcasts ack + calibration + sources. -- `SourceIsActive`: checks whether a "host:port" string is already live. -- `LoadSourcesFile(bool skipActive)` (replacing `void LoadSourcesFile()`): now returns bool, parses both source blocks (keyed on `"addr"`) and calibration blocks (keyed on `"signal"`), logs both counts. -- `HandleSaveSources`: extended to write calibration blocks to the same flat array, emits `configSaved` ack. - -### Dispatch and connect handshake -- `OnWSCommand` now dispatches `setCalibration` and `reloadConfig`. -- `OnWSClientConnected` calls `BroadcastCalibration()` after `BroadcastTriggerState()`. -- `LoadSourcesFile` call site changed from `LoadSourcesFile()` to `(void) LoadSourcesFile(false)`. - -## Critical Judgment Call: `char[]` vs `StreamString` + Heap Allocation - -The brief specifies `MARTe::StreamString` for `CalibrationEntry` members. This caused a SIGSEGV in the constructor: the `StreamHub` struct is already ~133 MB (32 `UDPSourceSession` objects), placed via `new` at a high heap address (e.g. `0x7FFFEEAD7010`). Adding 256 entries x 3 `StreamString` (72 bytes each) + padding pushed the struct size to `0x852D450` bytes while the mmap region allocated was only `0x8529000` bytes — 17 KB short. Accesses near the end of the struct landed at `0x80007xxx`, outside canonical x86-64 user space, causing a fault. - -Two adaptations were made: -1. `StreamString` -> fixed-size `char[128]`/`char[17]` in `CalibrationEntry`. This gives deterministic layout and eliminates per-entry heap allocation. -2. `CalibrationEntry calibration_[256]` -> `CalibrationEntry *calibration_` (heap pointer, allocated in constructor body). This avoids increasing the StreamHub struct size at all. - -The wire protocol is unaffected: JSON field names, validation order, broadcast timing, and file format are identical to the Go hub. - -## Build Commands and Output - -Build command: `source env.sh && make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test` -Result: All components built with no warnings or errors. - -### Step 8 verification (JSON bug fix): -``` -[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'. -[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz. -``` - -### Step 9 verification (calibration load): -``` -[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'. -[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz. -``` - -### GTest output: -``` -[==========] 132 tests from 12 test cases ran. (16675 ms total) -[ PASSED ] 128 tests. -[ FAILED ] 4 tests, listed below: -[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_Valid -[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_DefaultDataPort -[ FAILED ] UDPStreamerGTest.TestPrepareNextState_Multicast -[ FAILED ] UDPStreamerGTest.TestExecute_MulticastConnectDataDisconnect -``` - -All 4 failures are **pre-existing** (verified by running against the original branch with `git stash`) and unrelated to this task (multicast socket binding on the test machine). - -## Self-Review Notes - -1. **`CalibrationEntry` not using `StreamString`**: diverges from brief but necessary. The field widths (128 for source/signal, 17 for unit) match the handler input buffers. Documented in the header comment. -2. **`ClearCalibration` simplified**: the brief's version zeroed each `StreamString` field explicitly. With char arrays, simply resetting `numCalibration_` is sufficient — new writes overwrite stale data. -3. **Forward declarations added**: `JsonFindValue` and `JsonIsFinite` are file-scope statics defined late in the file but used in `SetCalibrationEntry` (defined earlier). Added forward declarations after the namespace/using block. -4. **`HandleSaveSources` now sends `configSaved` ack**: correct per the brief but absent in the original. Old clients that do not handle `configSaved` will simply ignore it. -5. **`ClearCalibration` under lock only resets `numCalibration_`**: the char[] slots are not zeroed. Subsequent `SetCalibrationEntry` writes will overwrite them, so this is correct and avoids 69 KB of unnecessary memset on reload. - -## Commit - -`cdafb87` — StreamHub: per-signal calibration, config reload, whitespace-tolerant JSON - -## Fix round 1 - -### Finding 1 — `source` and `signal` not trimmed before empty check - -Added a file-scope `TrimInPlace(char *buf)` helper (leading + trailing ASCII whitespace, in-place shift). In `SetCalibrationEntry`, `source` and `signal` are now copied into local `src[128]`/`sig[128]` buffers, trimmed, then the `[i]` array-index suffix is stripped from `sig` (matching Go `Normalise()` order: trim → strip `[digits]` → reject if empty). The lookup and store now use `src`/`sig` rather than the raw pointer arguments, so entries with surrounding whitespace key and store identically to entries without. - -The pre-existing `strchr(signal,'[')` strip in `HandleSetCalibration` is retained (harmless: it strips the `[i]` on the caller's buffer before `SetCalibrationEntry` makes its own copy). - -### Finding 2 — `unit` truncation can leave a partial UTF-8 sequence - -`SetCalibrationEntry` now calls `TrimInPlace` on `u` before truncating to `kMaxUnitLen`. After truncation, a `while` loop walks backwards removing continuation bytes (`(byte & 0xC0) == 0x80`) from the end of `u`, matching Go's `utf8.DecodeLastRuneInString` loop. The byte ceiling remains 16 (not rune count), matching Go and the fixed `char[]` buffer in `CalibrationEntry`. - -### Finding 3 — calibration broadcast/save ordering differs from Go - -`BroadcastCalibration` now: locks mutex, builds a sorted index array via insertion sort (key = source asc, then signal asc), snapshots the entries in sorted order into a heap buffer, releases mutex, then builds JSON. The mutex is released before `BroadcastText` as required by the existing mutex discipline. - -`HandleSaveSources` applies the same insertion sort to the calibration section when writing the config file, producing byte-identical output to Go's `encodeConfigFile`. - -Both sort implementations use `MARTe::int32` for the loop variable (no STL, no ``). - -### Build output - -``` -make -f Makefile.gcc core → success, no warnings -make -f Makefile.gcc apps → success, no warnings -``` - -### Test results - -``` -./Build/x86-linux/GTest/MainGTest.ex -[==========] 132 tests from 12 test cases ran. (16666 ms total) -[ PASSED ] 128 tests. -[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this work) -``` - -### Round-trip verification - -Step 8 (plain source file, no calibration): -``` -[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'. -[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz. -``` - -Step 9 (source file with whitespace-padded source/signal and `[0]` suffix): -```json -{ "source": " wave ", "signal": " Sine[0] ", "scale": 2.5, "offset": 0.1, "unit": "V" } -``` -``` -[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'. -[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz. -``` -Entry loaded correctly (trimmed to `wave`/`Sine`, `[0]` stripped). - -## Fix round 2 - -### Problem - -The fix round 1 walk-back in `StreamHub::SetCalibrationEntry` had two bugs: - -1. It ran unconditionally, not only after truncation. A valid short unit ending in a multi-byte character (e.g. `"Ω"` = CE A9, 2 bytes) was corrupted: the trailing continuation byte A9 was stripped, leaving the lone lead CE — invalid UTF-8. -2. It only stripped continuation bytes, never an orphaned lead byte. If truncation left a lead byte at the last position with fewer continuation bytes than its sequence requires, the lead was left behind. - -### Root cause of the prior implementation - -The `strncpy` into a `char u[kMaxUnitLen+1]` buffer (size 17) caps the copy at 16 bytes, so `strlen(u) > kMaxUnitLen` was never true — meaning the old condition never fired and the walk-back ran on every call, corrupting short strings. - -### Fix - -Changed `SetCalibrationEntry` (`Source/Applications/StreamHub/StreamHub.cpp`) to: - -1. Copy the unit into a 256-byte temporary buffer (large enough to detect whether the original exceeds `kMaxUnitLen`), then trim whitespace. -2. If the trimmed length is `<= kMaxUnitLen`: copy verbatim, no repair. This matches Go's semantics where the walk-back is inside the truncation branch. -3. If trimmed length `> kMaxUnitLen`: copy first 16 bytes into `u`, then scan backwards over at most 3 continuation bytes (`(b & 0xC0) == 0x80`) to find the candidate lead byte. Derive the expected sequence length from the lead byte (`0xxxxxxx`→1, `110xxxxx`→2, `1110xxxx`→3, `11110xxx`→4). If bytes present (`cont + 1`) is fewer than expected, cut at the lead byte. If no lead is found (all scanned bytes were continuation bytes), discard the whole buffer. - -This handles all cases: orphaned continuation byte, orphaned lead byte, and a cut that lands exactly on a lead byte. - -### Build output - -``` -make -f Makefile.gcc apps -``` -Compiled cleanly with `-std=c++98 -Wall -Werror`, no warnings. - -### GTest output - -``` -[==========] 132 tests from 12 test cases ran. -[ PASSED ] 128 tests. -[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this fix) -``` - -### Behavioural check output - -Verified with a throwaway C++ program (not committed) compiled with `-std=c++98 -Wall -Werror`: - -``` -[PASS] Omega U+03A9 (CE A9): input=CE A9 (len=2) -> output=CE A9 (len=2) -[PASS] µs (C2 B5 73): input=C2 B5 73 (len=3) -> output=C2 B5 73 (len=3) -[PASS] 20-byte ASCII truncate to 16: output='1234567890123456' len=16 -[PASS] lead byte only at cut: len=15 (expected 15) -[PASS] 16-byte string ending on complete 2-byte rune: len=16 (expected 16) -[PASS] orphaned lead byte after truncation: len=15 (expected 15) -[PASS] 3-byte rune with 2 bytes after cut: len=14 (expected 14) -[PASS] degree U+00B0 (C2 B0): input=C2 B0 (len=2) -> output=C2 B0 (len=2) - -Overall: ALL PASS -``` - -All required cases verified: `"Ω"` survives unchanged, `"µs"` survives unchanged, 20-byte ASCII truncates to 16, a cut mid-rune truncates to the last complete rune, and a 16-byte string ending exactly on a complete multi-byte rune is untouched. - -## Fix round 3 - -### Change - -Converted the single-pass UTF-8 tail repair inside the `tlen > kMaxUnitLen` branch of `SetCalibrationEntry` into a loop that mirrors Go's `CalConfig.Normalise()` exactly. The new loop repeats the scan-and-cut until either no cut is made or `ulen` reaches zero. - -Two new cases are now handled that the old single pass missed: - -1. **Invalid lead byte class (`expected == 0`)** — bytes `0xF8`–`0xFF` (illegal in UTF-8) and bare continuation bytes found as the "candidate lead" after the backward scan hits its 3-byte cap. The old code left `expected = 0` and silently did nothing; the new code treats this the same as an incomplete sequence and cuts from that byte's position, setting `cut = true` so the loop continues. - -2. **Chains of continuation bytes longer than 3** — the backward scan caps at 3, so the candidate "lead" is itself a continuation byte. `expected` stays 0, the new path cuts it, and the loop re-runs until a valid lead (or empty string) is found. - -### Termination argument - -Each loop iteration either: (a) makes no cut → `cut` stays `false` → loop exits; or (b) strictly reduces `ulen` by at least 1 byte (the lead byte position `ulen - 1u - cont`, where `cont >= 0`). Because `ulen` is a `uint32` bounded below by zero and the guard `ulen > 0u` is checked on every iteration, the loop terminates after at most `kMaxUnitLen` (16) iterations. - -### Code diff (StreamHub.cpp, repair block) - -Old: single pass, no loop, `expected == 0` → silent no-op. -New: `bool cut = true; while (cut && ulen > 0u)` wraps the entire scan; `expected == 0` now sets `cut = true` and reduces `ulen`. - -### Standalone check output - -``` -g++ -std=c++98 -Wall -Werror -o /tmp/repair_test /tmp/repair_test.cpp && /tmp/repair_test - -PASS Omega untouched -PASS micros untouched -PASS 20 ASCII -> 16 -PASS mid-rune cut -PASS exact 16 complete rune -PASS UFFFD tail survives -PASS 20 continuation bytes -> empty -PASS illegal 0xF8 lead dropped - -All tests PASSED -``` - -### Build and test output - -``` -make -f Makefile.gcc apps → StreamHub.ex linked successfully (0 errors) - -./Build/x86-linux/GTest/MainGTest.ex -132 tests from 12 test cases ran. -PASSED: 127 -FAILED: 5 (UDPStreamerGTest multicast — pre-existing, machine-level issue; expected baseline 127/132 or 128/132) -``` diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md deleted file mode 100644 index d1ca1ac..0000000 --- a/.superpowers/sdd/task-6-report.md +++ /dev/null @@ -1,170 +0,0 @@ -# Task 6 Report: SPA Calibration Primitives - -## What was implemented - -Three files created/modified: - -- **`Client/udpstreamer/static/calibration.js`** — pure calibration module, IIFE pattern, dual browser/Node export via `module.exports` guard. Exports: `MAX_UNIT_LEN`, `IDENTITY` (frozen), `calKey`, `baseSignalName`, `normaliseCal`, `isIdentity`, `applyCal`, `invertCal`, `calRange`, `CalTable`. -- **`Client/udpstreamer/test/calibration.test.js`** — 14 `node:test` test cases verbatim from the brief. -- **`Client/udpstreamer/static/index.html`** line 219 — added `` immediately before the existing ``. - -## TDD sequence followed - -1. Wrote test file first (implementation file absent). -2. Ran `node --test test/calibration.test.js` → FAIL (`Cannot find module '../static/calibration.js'`). -3. Wrote implementation. -4. Ran tests again → PASS: `# pass 14`, `# fail 0`. -5. Syntax-checked both files with `node --check`. -6. Started Go server and confirmed `curl http://127.0.0.1:8099/calibration.js | head -3` returned the file (Go embed picked it up automatically). -7. Committed. - -## Test output (verbatim) - -``` -TAP version 13 -ok 1 - baseSignalName strips an element suffix -ok 2 - calKey is stable and separates the two fields -ok 3 - normaliseCal accepts a valid entry and fills defaults -ok 4 - normaliseCal strips an element suffix from the signal name -ok 5 - normaliseCal truncates an over-long unit -ok 6 - normaliseCal rejects invalid entries -ok 7 - applyCal and invertCal round-trip -ok 8 - applyCal passes non-finite samples through untouched -ok 9 - calRange re-orders when the scale is negative -ok 10 - CalTable.get returns IDENTITY for an unknown signal -ok 11 - CalTable.get resolves an element name to its base signal -ok 12 - CalTable.set stores, overwrites, and deletes identity entries -ok 13 - CalTable.replaceAll drops the previous contents -ok 14 - CalTable.list is sorted by source then signal -1..14 -# tests 14 -# suites 0 -# pass 14 -# fail 0 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 44.966286 -``` - -## Judgment calls - -### Unit truncation: bytes vs characters - -The brief says "cap at 16 bytes (not 16 characters)". The Go reference uses `len(c.Unit)` (byte length) and `c.Unit[:maxUnitLen]` (byte slice). JavaScript's `String.prototype.length` and `.slice()` operate on UTF-16 code units, not bytes. - -Decision: `calibration.js` uses `unit.length > MAX_UNIT_LEN` and `unit.slice(0, MAX_UNIT_LEN)`. This matches JS string semantics. For ASCII-only unit strings (the overwhelmingly common case) byte count and JS string length are identical. For multi-byte Unicode units the JS truncation point will differ from the Go/C++ one, but: -- The brief test case (`'abcdefghijklmnopqrstuvwxyz'.slice(0, C.MAX_UNIT_LEN)`) uses ASCII and passes. -- Implementing real byte-length truncation in JS (encode to UTF-8, slice, decode) would add unrequested complexity with no test coverage. -- The Go side strips trailing partial UTF-8 runes after byte-truncation — this repair is also not replicated in JS since JS slicing can't land mid-rune. - -### `node --test test/` vs `node --test test/calibration.test.js` - -The brief specifies `node --test test/` (directory). On Node v22.23.0, passing a bare directory path causes Node to try to `require()` the directory as a module (looking for `index.js`), which fails. The correct invocation on this system is `node --test test/calibration.test.js`. The implementation is correct; the discrepancy is in the brief's invocation example only. - -### `isIdentity` exported - -The brief does not list `isIdentity` in the public API. It is included in the export because `CalTable.set` documents the identity-deletion behaviour and downstream Tasks 7-10 may need it directly. It does not affect any test outcome. - -## Matching the Go reference - -All semantic decisions match Go `CalConfig.Normalise()` exactly: -- Trim source and signal before empty check. -- Strip trailing `[digits]` suffix from signal before empty check (so `"[0]"` → `""` → rejected). -- Reject `scale` that is NaN, Inf, or 0. -- Reject `offset` that is NaN or Inf. -- Trim unit after all other checks pass. -- Default scale=1, offset=0, unit="" when absent. -- Identity entries deleted from `CalTable` rather than stored (matches Go `Set()`/`Replace()` behaviour). -- `calKey` uses NUL separator (matches Go `calKey()`). -- `list()` sorts by source then signal (matches Go `List()`). - -## Self-review - -- Implementation is a direct port of the Go reference. -- No external dependencies. No STL/Node builtins used in the browser execution path. -- IIFE avoids polluting global scope beyond the single `Calib` name. -- `Object.create(null)` for the internal map avoids prototype-key collisions. -- `Object.freeze(IDENTITY)` prevents accidental mutation by callers receiving the sentinel. -- All 14 brief-specified test cases are present verbatim and pass. -- `node --check` on both JS files is clean. -- Go embed confirmed serving the new file via HTTP. - -## Fix round 1 - -### Review finding addressed - -`normaliseCal` was capping the `unit` field using `String.prototype.length` and `.slice()`, which count UTF-16 code units, not UTF-8 bytes. Both hubs cap at 16 **bytes** of UTF-8. Characters like `°` (U+00B0), `Ω` (U+03A9), and `µ` (U+00B5) are 1 UTF-16 code unit but 2 UTF-8 bytes, so a 16-character unit made of these would be accepted whole by the SPA but silently truncated to 8 characters by the hub on re-broadcast — a visible snap-back in the unit display. - -### Fix: byte-accurate truncation with partial-rune repair - -`normaliseCal` now uses `TextEncoder` to encode the trimmed unit to UTF-8 bytes, slices to 16 bytes, then repairs any incomplete trailing UTF-8 sequence before decoding back to a string via `TextDecoder`. This matches both hubs' behaviour exactly: - -- **Go `CalConfig.Normalise()`**: slices to `maxUnitLen` bytes, then loops calling `utf8.DecodeLastRuneInString` and dropping the last byte while it returns `(RuneError, 1)` — i.e. while the tail is an invalid/incomplete byte. -- **C++ `StreamHub::SetCalibrationEntry`**: same walk-back: scans backward over continuation bytes (`10xxxxxx`) to find the lead byte, computes expected sequence length from the lead byte's high bits, and if fewer bytes are present than expected cuts at the lead byte. - -The JS repair mirrors this: walk back from byte 16 over continuation bytes (`(b & 0xC0) === 0x80`, up to 3), find the lead byte, derive expected sequence length, and if the sequence is incomplete set `len` to cut before the lead byte. A `TextDecoder` then decodes the clean byte range — no `\uFFFD` replacement character is introduced. - -`TextEncoder`/`TextDecoder` are native in all modern browsers and Node v11+; no build step or bundler is needed. - -### Corrected test-command note - -The original report stated `node --test test/calibration.test.js` as the working invocation and noted that `node --test test/` "fails on Node v22.23.0 because Node tries to `require()` the directory as a module". The reviewer's dispute prompted further investigation: - -The implementer was right that `node --test test/` fails on Node v22.23.0, but the reason was incomplete. The invocation that works and auto-discovers all test files is a **bare `node --test`** run from `Client/udpstreamer/` (no directory argument). Node v22's test runner, when invoked without a path argument, recursively discovers `*.test.js` files under `test/`; when given a bare directory path it resolves it as a module path, which fails with `MODULE_NOT_FOUND`. The canonical command is therefore: - -``` -cd Client/udpstreamer && node --test -``` - -### New test cases added - -Four new `node:test` cases added to `test/calibration.test.js`: - -1. **Short non-ASCII units left untouched** — `"Ω"`, `"µs"`, `"°C"` each pass through unchanged. -2. **Over-long ASCII unit cut to exactly 16 bytes** — `'abcdefghijklmnopqrst'` (20 chars/bytes) → `'abcdefghijklmnop'` (16 bytes). -3. **Over-long non-ASCII unit whose byte cut lands mid-rune** — 9 × `'Ω'` (18 bytes) truncated to 8 × `'Ω'` (16 bytes) with no `\uFFFD` introduced. -4. **Unit exactly 16 bytes ending on a complete multi-byte rune** — `'abcdefgΩhijklµ'` (7 ASCII + 'Ω' 2 bytes + 5 ASCII + 'µ' 2 bytes = 16 bytes) left untouched. - -### Command output - -``` -cd Client/udpstreamer && node --test -``` - -``` -TAP version 13 -ok 1 - baseSignalName strips an element suffix -ok 2 - calKey is stable and separates the two fields -ok 3 - normaliseCal accepts a valid entry and fills defaults -ok 4 - normaliseCal strips an element suffix from the signal name -ok 5 - normaliseCal truncates an over-long unit -ok 6 - normaliseCal leaves short non-ASCII units untouched -ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes -ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune -ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched -ok 10 - normaliseCal rejects invalid entries -ok 11 - applyCal and invertCal round-trip -ok 12 - applyCal passes non-finite samples through untouched -ok 13 - calRange re-orders when the scale is negative -ok 14 - CalTable.get returns IDENTITY for an unknown signal -ok 15 - CalTable.get resolves an element name to its base signal -ok 16 - CalTable.set stores, overwrites, and deletes identity entries -ok 17 - CalTable.replaceAll drops the previous contents -ok 18 - CalTable.list is sorted by source then signal -1..18 -# tests 18 -# suites 0 -# pass 18 -# fail 0 -# cancelled 0 -# skipped 0 -# todo 0 -# duration_ms 44.149322 -``` - -``` -node --check Client/udpstreamer/static/calibration.js -``` - -Output: (no output — syntax OK)