fix(calibration.js): cap unit at 16 UTF-8 bytes, matching both hubs

normaliseCal was using String.length/.slice() (UTF-16 code units), so
multi-byte characters like °, Ω, µ could slip through oversized. Now uses
TextEncoder to slice at 16 bytes, then repairs any incomplete trailing
UTF-8 sequence by walking back over continuation bytes to find the lead
byte and dropping the incomplete rune — exactly mirroring Go's
utf8.DecodeLastRuneInString loop and the C++ walk-back in
StreamHub::SetCalibrationEntry. Adds 4 new test cases covering the
non-ASCII/boundary scenarios, and corrects the canonical test command in
the task-6 report to 'cd Client/udpstreamer && node --test'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-17 00:36:01 +02:00
co-authored by Claude Opus 4.6
parent 21d084d2ea
commit 48d62c1f80
3 changed files with 252 additions and 1 deletions
+36 -1
View File
@@ -41,7 +41,42 @@
if (!isFiniteNum(scale) || scale === 0) return null;
if (!isFiniteNum(offset)) return null;
var unit = String(obj.unit == null ? '' : obj.unit).trim();
if (unit.length > MAX_UNIT_LEN) unit = unit.slice(0, MAX_UNIT_LEN);
// Cap at MAX_UNIT_LEN UTF-8 bytes, matching both hubs' Normalise() exactly.
// TextEncoder/TextDecoder are available natively in all modern browsers and
// Node v11+; no build step or bundler is needed.
var enc = new TextEncoder();
var bytes = enc.encode(unit);
if (bytes.length > MAX_UNIT_LEN) {
// Truncate to MAX_UNIT_LEN bytes, then repair any split UTF-8 rune at the
// tail. Mirrors Go's utf8.DecodeLastRuneInString walk-back loop and the
// C++ repair loop in StreamHub::SetCalibrationEntry:
// Drop continuation bytes (10xxxxxx) from the tail until the last byte
// is either an ASCII byte (< 0x80) or a lead byte whose sequence is
// complete (i.e. all expected continuation bytes are present).
var b = bytes.slice(0, MAX_UNIT_LEN);
var len = b.length;
// Walk back over continuation bytes (up to 3) to find the lead byte of
// the last sequence.
var cont = 0;
while (cont < 3 && cont < len && (b[len - 1 - cont] & 0xC0) === 0x80) {
cont++;
}
if (cont < len) {
var lead = b[len - 1 - cont];
// Determine expected sequence length from the lead byte.
var seqLen = lead < 0x80 ? 1 : // 0xxxxxxx ASCII
(lead & 0xE0) === 0xC0 ? 2 : // 110xxxxx
(lead & 0xF0) === 0xE0 ? 3 : // 1110xxxx
(lead & 0xF8) === 0xF0 ? 4 : // 11110xxx
1; // orphaned continuation byte — treat as 1
var haveBytes = cont + 1; // lead + continuation bytes present
if (haveBytes < seqLen) {
// Incomplete sequence: drop the lead byte and all its continuations.
len = len - haveBytes;
}
}
unit = new TextDecoder().decode(b.slice(0, len));
}
return {source: source, signal: signal, scale: scale, offset: offset, unit: unit};
}
@@ -34,6 +34,52 @@ test('normaliseCal truncates an over-long unit', () => {
long.slice(0, C.MAX_UNIT_LEN));
});
test('normaliseCal leaves short non-ASCII units untouched', () => {
// 'Ω' is U+03A9, 2 UTF-8 bytes — well within 16 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'Ω'}).unit, 'Ω');
// 'µs' is U+00B5 + U+0073, 3 UTF-8 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'µs'}).unit, 'µs');
// '°C' is U+00B0 + U+0043, 3 UTF-8 bytes.
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: '°C'}).unit, '°C');
});
test('normaliseCal truncates an over-long ASCII unit to exactly 16 bytes', () => {
// 20 ASCII characters — each 1 byte, so cut at character 16.
const long = 'abcdefghijklmnopqrst'; // 20 chars
const result = C.normaliseCal({source: 'w', signal: 's', unit: long}).unit;
assert.strictEqual(result, 'abcdefghijklmnop'); // first 16 bytes/chars
assert.strictEqual(new TextEncoder().encode(result).length, 16);
});
test('normaliseCal cuts a mid-rune byte boundary back to the last complete rune', () => {
// Each 'Ω' (U+03A9) is 2 UTF-8 bytes (CE A9).
// 8 × 'Ω' = 16 bytes exactly — fits without truncation.
const fits = 'ΩΩΩΩΩΩΩΩ';
assert.strictEqual(new TextEncoder().encode(fits).length, 16);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: fits}).unit, fits);
// 9 × 'Ω' = 18 bytes. Slicing at 16 bytes lands in the middle of the 9th
// 'Ω' (only 1 of its 2 bytes is in the window) so only 8 'Ω' should survive.
// No U+FFFD replacement character must appear.
const toolong = 'ΩΩΩΩΩΩΩΩΩ';
const result = C.normaliseCal({source: 'w', signal: 's', unit: toolong}).unit;
assert.strictEqual(result, 'ΩΩΩΩΩΩΩΩ');
assert.ok(!result.includes('\uFFFD'), 'must not contain U+FFFD replacement character');
assert.strictEqual(new TextEncoder().encode(result).length, 16);
});
test('normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched', () => {
// 7 ASCII chars + 'Ω' (2 bytes) + 6 ASCII chars + 'µ' (2 bytes) - 1 = let's
// build exactly 16 bytes ending on a complete 2-byte rune.
// 7 × 'a' (7 bytes) + 'Ω' (2 bytes) + 5 × 'b' (5 bytes) + '°' (2 bytes) =
// 7 + 2 + 5 + 2 = 16 bytes.
const exact = 'aaaaaaаbbbbb°'; // avoid confusion: use simple construction below
// Simple: 'abcdefgΩhijklµ' → 7 + 2 + 5 + 2 = 16 bytes
const u = 'abcdefgΩhijklµ';
assert.strictEqual(new TextEncoder().encode(u).length, 16);
assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: u}).unit, u);
});
test('normaliseCal rejects invalid entries', () => {
assert.strictEqual(C.normaliseCal(null), null);
assert.strictEqual(C.normaliseCal({signal: 's'}), null);