diff --git a/test/js/improv-frame.test.mjs b/test/js/improv-frame.test.mjs index 5444094..6ddc8a0 100644 --- a/test/js/improv-frame.test.mjs +++ b/test/js/improv-frame.test.mjs @@ -80,3 +80,51 @@ test("APPLY_OP always emits at least one frame (so `last` always sends)", () => assert.equal(frames.length, 1); assert.equal(frames[0][9 + 2], 1, "last=1 on the lone frame"); }); + +// --------------------------------------------------------------------------- +// PR: removal of IMPROV_CMD_SET_DEVICE_MODEL (0xFE) — export surface tests +// --------------------------------------------------------------------------- + +import * as improvModule from "../../docs/install/improv-frame.js"; + +test("IMPROV_CMD_SET_DEVICE_MODEL (0xFE) is NOT exported — the vendor RPC was removed", () => { + // SET_DEVICE_MODEL was removed from the wire protocol in favour of pushing the + // deviceModel identity as a plain APPLY_OP set op (System.deviceModel). + // If this fails, something re-exported the old constant. + assert.equal(improvModule.IMPROV_CMD_SET_DEVICE_MODEL, undefined, + "0xFE SET_DEVICE_MODEL must not be present in the export surface"); +}); + +test("IMPROV_CMD_SET_TX_POWER (0xFD) is still exported with the correct command byte", () => { + // SET_TX_POWER is the pre-association TX-power cap vendor RPC — still needed. + assert.equal(improvModule.IMPROV_CMD_SET_TX_POWER, 0xFD, + "SET_TX_POWER must remain 0xFD"); +}); + +test("IMPROV_CMD_APPLY_OP (0xFC) is still exported with the correct command byte", () => { + assert.equal(improvModule.IMPROV_CMD_APPLY_OP, 0xFC, + "APPLY_OP must remain 0xFC"); +}); + +test("golden vector G4: SET_TX_POWER frame (8 dBm) matches expected bytes", () => { + // SET_TX_POWER payload: [0xFD][0x01][dBm] — command, length=1, value + // Frame bytes hand-verified: IMPROV magic + version=1 + type=0x03 + length=3 + // + [0xFD, 0x01, 0x08] + checksum. + // Checksum: sum(0x49+0x4d+0x50+0x52+0x4f+0x56+0x01+0x03+0x03+0xfd+0x01+0x08) mod 256 + // = 746 mod 256 = 234 = 0xEA. + const { buildImprovFrame, IMPROV_FRAME_TYPE_RPC, IMPROV_CMD_SET_TX_POWER } = improvModule; + const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC, + new Uint8Array([IMPROV_CMD_SET_TX_POWER, 0x01, 0x08])); + assert.equal(hex(frame), "49 4d 50 52 4f 56 01 03 03 fd 01 08 ea"); +}); + +test("regression: no exported constant has value 0xFE (the old SET_DEVICE_MODEL byte)", () => { + // Guard against a future re-add of the 0xFE command under any name. + // Numeric exports only (skip functions, arrays, non-numeric values). + const numericExports = Object.entries(improvModule) + .filter(([, v]) => typeof v === "number"); + for (const [name, val] of numericExports) { + assert.notEqual(val, 0xFE, + `exported constant '${name}' must not be 0xFE (SET_DEVICE_MODEL was removed)`); + } +}); diff --git a/test/js/moondeck-push-board.test.mjs b/test/js/moondeck-push-board.test.mjs new file mode 100644 index 0000000..52501f2 --- /dev/null +++ b/test/js/moondeck-push-board.test.mjs @@ -0,0 +1,220 @@ +// Tests for the pushBoard helper added in scripts/moondeck_ui/app.js. +// +// The pushBoard function is a browser-environment closure (defined inside +// renderDevices, not exported), so we test the BEHAVIOURAL CONTRACT it enforces: +// +// 1. Success is determined by the JSON body field `j.ok`, NOT by the HTTP +// status `r.ok` — because a device timeout mid-fan-out can return HTTP 200 +// while the body carries `{"ok": false}`. +// 2. A fetch() rejection (network error, AbortSignal timeout) calls onDone(false). +// 3. A body with `{"ok": true}` calls onDone(true). +// 4. A body with `{"ok": false}` calls onDone(false) even when HTTP 200. +// 5. onDone is optional; omitting it doesn't throw on any path. +// +// These are extracted as a standalone equivalent of the closure so the +// behavioural contract is verifiable without a DOM or browser globals. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// --------------------------------------------------------------------------- +// Equivalent implementation of the pushBoard closure from app.js (verbatim +// logic, extracted for testability). If the app.js implementation changes, +// update this mirror to stay in sync. +// --------------------------------------------------------------------------- +function makePushBoard(fetchImpl, ip) { + return function pushBoard(board, onDone) { + fetchImpl("/api/push-board", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ip, board }), + signal: AbortSignal.timeout(10000), + }).then(r => r.json()).then(j => onDone && onDone(!!j.ok)) + .catch(() => onDone && onDone(false)); + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Build a fake fetch that resolves with a JSON body. +function fakeFetch(body, status = 200) { + return (_url, _opts) => Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); +} + +// Build a fake fetch that rejects (network error / timeout). +function failingFetch(err = new Error("fetch failed")) { + return (_url, _opts) => Promise.reject(err); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("pushBoard calls onDone(true) when JSON body has ok:true (HTTP 200)", () => { + return new Promise((resolve, reject) => { + const fetch = fakeFetch({ ok: true }); + const pushBoard = makePushBoard(fetch, "192.168.1.42"); + pushBoard("MyBoard", (result) => { + try { + assert.equal(result, true, "onDone should receive true"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard calls onDone(false) when JSON body has ok:false even though HTTP 200", () => { + // HTTP 200 alone cannot be used to declare success — a device timeout + // mid-fan-out returns HTTP 200 with {"ok": false} in the body. + return new Promise((resolve, reject) => { + const fetch = fakeFetch({ ok: false }, 200); + const pushBoard = makePushBoard(fetch, "192.168.1.42"); + pushBoard("MyBoard", (result) => { + try { + assert.equal(result, false, "onDone should receive false on body ok:false"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard calls onDone(false) on a fetch rejection (network error)", () => { + return new Promise((resolve, reject) => { + const fetch = failingFetch(new Error("Network unreachable")); + const pushBoard = makePushBoard(fetch, "192.168.1.1"); + pushBoard("MyBoard", (result) => { + try { + assert.equal(result, false, "onDone should receive false on network error"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard calls onDone(false) when fetch times out (AbortError)", () => { + return new Promise((resolve, reject) => { + const abortErr = new DOMException("The operation was aborted.", "AbortError"); + const fetch = failingFetch(abortErr); + const pushBoard = makePushBoard(fetch, "10.0.0.5"); + pushBoard("SlowBoard", (result) => { + try { + assert.equal(result, false, "onDone should receive false on AbortError"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard with no onDone does not throw on success", () => { + // Fire-and-forget path: boardPicker change handler passes no callback. + return new Promise((resolve) => { + const fetch = fakeFetch({ ok: true }); + const pushBoard = makePushBoard(fetch, "192.168.1.42"); + pushBoard("MyBoard"); // onDone omitted — must not throw + // Give the micro-task queue a tick to let the promise chain settle. + setTimeout(resolve, 50); + }); +}); + +test("pushBoard with no onDone does not throw on fetch rejection", () => { + return new Promise((resolve) => { + const fetch = failingFetch(); + const pushBoard = makePushBoard(fetch, "192.168.1.42"); + pushBoard("MyBoard"); // onDone omitted — must not throw + setTimeout(resolve, 50); + }); +}); + +test("pushBoard sends the board name and device IP in the POST body", () => { + return new Promise((resolve, reject) => { + let capturedBody; + const fetch = (url, opts) => { + capturedBody = JSON.parse(opts.body); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + }; + const pushBoard = makePushBoard(fetch, "172.16.0.10"); + pushBoard("projectMM testbench S3", (result) => { + try { + assert.equal(result, true); + assert.equal(capturedBody.ip, "172.16.0.10", "IP forwarded to server"); + assert.equal(capturedBody.board, "projectMM testbench S3", "board name forwarded"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard targets POST /api/push-board", () => { + return new Promise((resolve, reject) => { + let capturedUrl; + let capturedMethod; + const fetch = (url, opts) => { + capturedUrl = url; + capturedMethod = opts.method; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + }; + const pushBoard = makePushBoard(fetch, "192.168.1.1"); + pushBoard("SomeBoard", () => { + try { + assert.equal(capturedUrl, "/api/push-board"); + assert.equal(capturedMethod, "POST"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard treats a truthy j.ok (non-boolean) as success", () => { + // JSON parsing can yield numbers or strings; guard against !!j.ok coercion. + return new Promise((resolve, reject) => { + const fetch = fakeFetch({ ok: 1 }); // number 1, not boolean true + const pushBoard = makePushBoard(fetch, "192.168.1.1"); + pushBoard("Board", (result) => { + try { + assert.equal(result, true, "!!1 === true"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); + +test("pushBoard treats a falsy j.ok (0) as failure", () => { + return new Promise((resolve, reject) => { + const fetch = fakeFetch({ ok: 0 }); + const pushBoard = makePushBoard(fetch, "192.168.1.1"); + pushBoard("Board", (result) => { + try { + assert.equal(result, false, "!!0 === false"); + resolve(); + } catch (e) { + reject(e); + } + }); + }); +}); \ No newline at end of file