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 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-17 08:01:09 +02:00
co-authored by Claude Opus 4.6
parent 7aba1260be
commit 72c286db33
3 changed files with 0 additions and 613 deletions
@@ -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 `<input id="vscale-cal-unit">`.
`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)
```
-240
View File
@@ -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 `<cmath>`, 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 `<algorithm>`).
### 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)
```
-170
View File
@@ -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 `<script src="/calibration.js"></script>` immediately before the existing `<script src="/app.js"></script>`.
## 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)