Files
HaloKeymind/test/test_notification_builder.js
T

162 lines
9.6 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const builder = require("../docs/_javascript/notification_builder.js");
assert.deepEqual(builder.pulse("50,300,40,20,500"), [50,300,40,20,500]);
for (const [time, on] of [[0,true],[49,true],[50,false],[349,false],[350,true],[390,false],[410,true],[910,false]])
assert.equal(builder.level(builder.pulse("50,300,40,20,500"), time), on);
for (const bad of ["0", "50,", "50,,20", "60001", "-5", "50 20", "1,1,1,1,1,1,1,1,1,1,1,1,1"])
assert.throws(() => builder.pulse(bad));
assert.equal(builder.melody("vip:d=8,o=5,b=120:c,c#,16p,4g.6").length, 4);
for (const bad of ["abc", "x:d=8,o=3,b=120:c", "x:d=8,o=5,b=0:c", "x:d=3,o=5,b=120:c",
"x:d=8,o=5,b=120:b#7", "x:d=8,o=5,b=120:c,", "x:d=8,o=5,b=120:c..", "x:d=8,o=5,b=120:p#",
"x:d=8,o=04,b=120:c", "x:d=8,o=5,b=120:c04", "x:d=8,o=5,b=120:c007", "x:d=08,o=5,b=120:c", "x:d=8,o=5,b=120:04c"])
assert.throws(() => builder.melody(bad));
for (const config of Object.values(builder.EXAMPLES)) {
const complete = {...config, id: config.kind === "contact" ? "01".repeat(32) : config.id};
const commands = builder.commands(complete);
assert.ok(commands.every(cmd => cmd.length + 4 <= 175));
if (config.quietOthers === "on") {
assert.throws(() => builder.notificationText(complete), /CLI|USB/);
continue;
}
const dm = builder.notificationText(complete);
assert.ok(dm.startsWith("!notify "));assert.ok(!dm.includes("gpio="));assert.ok(dm.length <= 159);
}
const vip = {...builder.EXAMPLES.vip, id: "01".repeat(32), remote: "on"};
assert.ok(builder.commands(vip).includes("set notify.remote contact:" + vip.id + " on"));
const room = {...vip, kind: "room"};
assert.ok(builder.commands(room).includes("set notify.remote room:" + room.id + " on"));
const longPost = {...vip, led: Array(9).fill("60000").join(","), repeat: "65535"};
assert.equal(builder.notificationText(longPost).length, 151);
assert.throws(() => builder.notificationText({...longPost, kind: "room"}), /150 characters for a room post/);
assert.throws(() => builder.commands({...builder.EXAMPLES.food, remote: "on"}));
assert.throws(() => builder.commands({...vip, id: "1234"}));
// The channel-only example installs a quiet default before its exception.
const quietPrefix = [
"set notify.enabled on",
"set notify.vibration all off", "set notify.sound all off",
"set notify.led all off", "set notify.screen all off", "set notify.gpio all off",
"set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button"
];
const channel9 = builder.EXAMPLES.channel9;
assert.equal(channel9.kind, "channel");assert.equal(channel9.id, "9");
assert.equal(channel9.when, "any");assert.equal(channel9.quietOthers, "on");
assert.equal(channel9.sound, "ch9:d=8,o=5,b=180:c,e,g");
assert.equal(channel9.repeat, "1");assert.equal(channel9.gap, "500");assert.equal(channel9.stop, "button");
for (const name of ["vibration", "led", "screen", "gpio"]) assert.equal(channel9[name], "off");
const exceptionOnly = builder.commands({...channel9, quietOthers: "off"});
const onlyChannel9 = builder.commands(channel9);
assert.deepEqual(onlyChannel9, [...quietPrefix, ...exceptionOnly]);
assert.deepEqual(exceptionOnly, builder.commands({...channel9, quietOthers: undefined}));
assert.deepEqual(exceptionOnly, [
"set notify.vibration channel:9 off", "set notify.sound on",
"set notify.sound channel:9 ch9:d=8,o=5,b=180:c,e,g",
"set notify.led channel:9 off", "set notify.screen channel:9 off",
"set notify.gpio channel:9 off", "set notify.repeat channel:9 1",
"set notify.gap channel:9 500", "set notify.stop channel:9 button"
]);
assert.deepEqual(builder.commands({...channel9, id: "8"}), [
...quietPrefix, ...exceptionOnly.map(command => command.replace("channel:9", "channel:8"))
]);
for (const kind of ["all", "contact", "room"]) {
assert.throws(() => builder.commands({...channel9, kind, id: "01".repeat(32)}), /channel/i);
}
assert.throws(() => builder.notificationText(channel9), /CLI|USB/);
assert.ok(builder.notificationText({...channel9, quietOthers: "off"}).startsWith("!notify "));
// Local examples must play before choosing a recipient. Saved rules still need a key.
assert.equal(builder.parseOutputs(builder.EXAMPLES.vip).notes.length, 4);
assert.throws(() => builder.commands(builder.EXAMPLES.vip), /full 64-digit/);
assert.throws(() => builder.parseOutputs({...vip, gpio: "64:50,50"}), /GPIO needs/);
assert.throws(() => builder.parseOutputs({...vip, screen: "invalid"}), /Screen must/);
// Verify the actual PCM file sent to the browser: note pitch, rests, and one cycle.
const wav = builder.melodyWav("test:d=4,o=5,b=120:a,p,a6");
const waveView = new DataView(wav.buffer), rate = waveView.getUint32(24, true);
assert.equal(new TextDecoder().decode(wav.slice(0, 4)), "RIFF");
assert.equal(new TextDecoder().decode(wav.slice(8, 12)), "WAVE");
assert.equal(new TextDecoder().decode(wav.slice(36, 40)), "data");
assert.equal(waveView.getUint32(4, true), wav.length - 8);
assert.equal(waveView.getUint16(20, true), 1); // PCM
assert.equal(waveView.getUint16(22, true), 1); // Mono
assert.equal(waveView.getUint16(34, true), 16);
assert.equal(waveView.getUint32(40, true), rate * 1.5 * 2);
assert.equal(wav.length, 44 + rate * 1.5 * 2);
function samples(from, to) {
return Array.from({length: Math.round((to - from) * rate)}, (_, i) => waveView.getInt16(44 + (Math.round(from * rate) + i) * 2, true));
}
function frequency(values) {
const crossings = values.slice(1).filter((sample, i) => sample > 0 && values[i] <= 0).length;
return crossings * rate / values.length;
}
const firstNote = samples(0.01, 0.49), lastNote = samples(1.01, 1.49);
assert.ok(Math.abs(frequency(firstNote) - 880) < 3);
assert.ok(Math.abs(frequency(lastNote) - 1760) < 3);
assert.ok(Math.sqrt(firstNote.reduce((sum, sample) => sum + sample * sample, 0) / firstNote.length) > 6000);
assert.ok(samples(0.5, 1).every(sample => sample === 0));
assert.equal(waveView.getInt16(44, true), 0);
assert.equal(waveView.getInt16(wav.length - 2, true), 0);
assert.equal(builder.melodyWav("off").length, 44);
assert.equal(builder.melodyWav("inherit").length, 44);
assert.throws(() => builder.melodyWav("invalid"));
// The browser warmup must preserve every sample, including the opening notes.
const warmedWav = builder.melodyWav("test:d=4,o=5,b=120:a,p,a6", 1000);
const leadBytes = rate * 2;
assert.equal(warmedWav.length, wav.length + leadBytes);
assert.ok(warmedWav.slice(44, 44 + leadBytes).every(byte => byte === 0));
assert.deepEqual(warmedWav.slice(44 + leadBytes), wav.slice(44));
assert.equal(new DataView(warmedWav.buffer).getUint32(40, true), warmedWav.length - 44);
assert.equal(builder.melodyWav("off", 1000).length, 44);
for (const lead of [-1, 2001, 0.5, NaN]) assert.throws(() => builder.melodyWav("off", lead));
console.log("Sound preview: audible PCM, note pitch, silence, duration, and recipient-free examples passed");
const encoded = builder.encodeCommand("notify.stop", "A7");
assert.equal(encoded[0], 60);assert.equal(encoded[3], 0x42);
assert.equal(new TextDecoder().decode(encoded.slice(4)), "A7|notify.stop");
const decoder = new builder.FrameDecoder();
const payload = Buffer.from("A7|OK - alerts stopped");
const framed = Uint8Array.from([62,payload.length+1,0,0x1d,...payload]);
assert.deepEqual(decoder.push(framed.slice(0, 2)), []);
assert.deepEqual(decoder.push(framed.slice(2, 8)), []);
const result = decoder.push(framed.slice(8));assert.equal(result.length, 1);
assert.equal(new TextDecoder().decode(result[0].slice(1)), "A7|OK - alerts stopped");
assert.deepEqual(new builder.FrameDecoder().push(Uint8Array.from([62,255,255,...framed])), [result[0]]);
assert.throws(() => builder.encodeCommand("x".repeat(173), "00"));
console.log("Notification builder: pulse, melody, examples, permissions, and fragmented USB frame tests passed");
async function transportTests() {
let incoming, requestedSignals, opened;
const seen = [];
const port = {
readable: new ReadableStream({start(controller) { incoming = controller; }}),
writable: new WritableStream({write(bytes) {
const text = new TextDecoder().decode(bytes.slice(4));
const tag = text.slice(0, 2), command = text.slice(3);
seen.push(command);
const frame = value => {
const body = new TextEncoder().encode(value);
return Uint8Array.from([62, body.length + 1, 0, 0x1d, ...body]);
};
incoming.enqueue(frame("zz|ignored unrelated reply"));
const reply = frame(tag + "|OK");
incoming.enqueue(reply.slice(0, 2));incoming.enqueue(reply.slice(2, 7));incoming.enqueue(reply.slice(7));
}}),
async open(options) { opened = options; },
async setSignals(signals) { requestedSignals = signals; },
async close() {}
};
const client = new builder.SerialClient(port);
await client.open();assert.equal(opened.baudRate, 115200);assert.equal(requestedSignals.dataTerminalReady, true);
const first = client.command("notify.test room:" + "02".repeat(32));
await assert.rejects(client.command("notify.stop"), /Wait for the current command/);
assert.equal(await first, "OK");
assert.equal(await client.command("notify.stop"), "OK");
assert.equal(seen.length, 2);await client.close();
// A failed write must reject promptly and clear the pending request.
const failed = new builder.SerialClient({
...port, readable: new ReadableStream(),
writable: new WritableStream({write() { throw new Error("Cable removed"); }})
});
await failed.open();await assert.rejects(failed.command("get notify"), /Cable removed/);
assert.equal(failed.pending, null);await failed.close();
}
transportTests().then(() => console.log("USB transport: tagged round trips, busy rejection, disconnect failure and clean close passed"), error => { console.error(error);process.exitCode = 1; });