Add test/web-protocol.test.js — runs the real protocol code from chat.html in Node (unit: crc/frame/ACK/HELLO codec, Hamming, Gray; integration: multi-level modem roundtrip + FEC recovery of off-by-one symbol errors; functional: full frame -> modem -> FEC -> assembler -> parse + ACK roundtrip). 3348 assertions. Wired as `make test-web` (also in test-all). Lets us QA the modem without two devices. Also: retransmit now uses exponential backoff so a lost ACK spaces out retries instead of hammering the channel.
137 lines
6.6 KiB
JavaScript
137 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/* zebra-report web protocol tests — run on the real code in web/chat.html,
|
|
* no browser or second device needed: node test/web-protocol.test.js
|
|
*
|
|
* Pulls the pure protocol functions straight out of chat.html (so the tests
|
|
* track the shipped code) and exercises them across three tiers:
|
|
* unit crc32, frame build/parse (DATA/ACK/HELLO), Hamming, Gray
|
|
* integration multi-level modem TX -> channel(+noise) -> RX roundtrip,
|
|
* and FEC recovery of off-by-one symbol errors
|
|
* functional full frame -> modem -> FEC -> demod -> FrameAssembler -> parse,
|
|
* plus the delivery-ACK roundtrip
|
|
*
|
|
* The modem layer is configured for N=4 levels, FEC on (the shipped defaults).
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'chat.html'), 'utf8');
|
|
|
|
function extract(re) {
|
|
const m = src.match(re);
|
|
if (!m) throw new Error('could not find ' + re + ' in chat.html');
|
|
let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i;
|
|
for (; j < src.length; j++) {
|
|
if (src[j] === '{') depth++;
|
|
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
|
|
}
|
|
return src.slice(m.index, j);
|
|
}
|
|
|
|
const harness = `
|
|
/* ---- constants + helpers lifted from chat.html (N=4, FEC on) ---- */
|
|
const HS_MAGIC=[0x5A,0x42], T_OFFER=1, T_READY=2, T_DATA=3, T_HELLO=4, T_ACK=5, HS_FRAME_LEN=6;
|
|
const ZEBRA_LEVELS=4, ZEBRA_BITS_PER_SYM=2, ZEBRA_CW_BITS=12, ZEBRA_SYMS_PER_CW=6, ZEBRA_FEC=true;
|
|
const ZEBRA_VOL_MARK=0.95, ZEBRA_VOL_SPACE=0.10;
|
|
const _HAM_DATA_POS=[3,5,6,7,9,10,11,12];
|
|
function ampForLevel(L){ return ZEBRA_VOL_SPACE + (L/(ZEBRA_LEVELS-1))*(ZEBRA_VOL_MARK-ZEBRA_VOL_SPACE); }
|
|
async function senderIdBytes(){ return new Uint8Array([0xAA,0xBB,0xCC,0xDD]); }
|
|
${src.match(/const CRC32_TABLE = \(\(\) => \{[\s\S]*?return t;\s*\}\)\(\);/)[0]}
|
|
${extract(/function crc32\(/)}
|
|
${extract(/async function buildDataFrame\(/)}
|
|
${extract(/async function buildAckFrame\(/)}
|
|
${extract(/async function buildHelloFrame\(/)}
|
|
${extract(/function buildHandshakeFrame\(/)}
|
|
${extract(/function xorChecksum\(/)}
|
|
${extract(/function parseFrame\(/)}
|
|
${extract(/function frameCrcOf\(/)}
|
|
${extract(/function hammingEncode\(/)}
|
|
${extract(/function hammingDecode\(/)}
|
|
const grayVal2Phys=new Array(ZEBRA_LEVELS), grayPhys2Val=new Array(ZEBRA_LEVELS);
|
|
for(let L=0;L<ZEBRA_LEVELS;L++){const v=L^(L>>1); grayPhys2Val[L]=v; grayVal2Phys[v]=L;}
|
|
${extract(/class MultiLevelDecoder\b/)}
|
|
${extract(/class FrameAssembler\b/)}
|
|
|
|
/* ---- modem TX mirror of _txOne (FEC + Gray), and a sample-hold channel ---- */
|
|
const N1=ZEBRA_LEVELS-1, effRate=48000/128;
|
|
function txSyms(bytes, perturb){
|
|
const s=[]; for(let i=0;i<10;i++) s.push(ZEBRA_VOL_MARK);
|
|
for(const byte of bytes){
|
|
const cw=ZEBRA_FEC?hammingEncode(byte):byte; const lv=[0];
|
|
for(let j=0;j<ZEBRA_SYMS_PER_CW;j++) lv.push(grayVal2Phys[(cw>>(j*ZEBRA_BITS_PER_SYM))&N1]);
|
|
lv.push(N1);
|
|
if(perturb){ const k=1+(Math.random()*ZEBRA_SYMS_PER_CW|0); lv[k]=Math.max(0,Math.min(N1,lv[k]+(Math.random()<.5?1:-1))); }
|
|
for(const L of lv) s.push(ampForLevel(L));
|
|
}
|
|
for(let i=0;i<5;i++) s.push(ZEBRA_VOL_MARK);
|
|
return s;
|
|
}
|
|
function demod(bytes, baud, perturb, noise){
|
|
const syms=txSyms(bytes,perturb), period=1/baud, out=[];
|
|
const dec=new MultiLevelDecoder(baud, effRate, ZEBRA_LEVELS);
|
|
for(let t=0;t<syms.length*period;t+=1/effRate){
|
|
let v=syms[Math.min(Math.floor(t/period), syms.length-1)];
|
|
if(noise) v+=(Math.random()*2-1)*noise;
|
|
const b=dec.push(Math.max(0,v));
|
|
if(b!==null) out.push(b);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ---- assertions ---- */
|
|
let pass=0, fail=0; const fails=[];
|
|
const ok=(c,m)=>{ if(c) pass++; else { fail++; fails.push(m); } };
|
|
|
|
(async () => {
|
|
/* ===== UNIT ===== */
|
|
ok(crc32(new Uint8Array([1,2,3]))===crc32(new Uint8Array([1,2,3])), 'crc32 deterministic');
|
|
ok(crc32(new Uint8Array([1,2,3]))!==crc32(new Uint8Array([1,2,4])), 'crc32 distinguishes');
|
|
|
|
const df=await buildDataFrame(new Uint8Array([9,8,7,6,5]));
|
|
const pf=parseFrame(df);
|
|
ok(pf.type===T_DATA && pf.payload.length===5 && pf.payload[0]===9, 'DATA frame build/parse');
|
|
ok(frameCrcOf(df)===pf.crc, 'frameCrcOf == parsed crc (message id)');
|
|
const ack=await buildAckFrame(pf.crc); const pa=parseFrame(ack);
|
|
ok(pa.type===T_ACK && (pa.ackCrc>>>0)===(pf.crc>>>0), 'ACK echoes DATA crc');
|
|
const hf=await buildHelloFrame('fxhp', 20); const ph=parseFrame(hf);
|
|
ok(ph.type===T_HELLO && ph.handle==='fxhp' && ph.maxBaud===20, 'HELLO frame build/parse');
|
|
const corrupt=Uint8Array.from(df); corrupt[5]^=0xFF;
|
|
ok((parseFrame(corrupt)||{}).error==='crc', 'corrupt frame flagged by crc');
|
|
|
|
for(let b=0;b<256;b++){
|
|
ok(hammingDecode(hammingEncode(b))===b, 'hamming clean '+b);
|
|
for(let p=0;p<12;p++) ok(hammingDecode(hammingEncode(b)^(1<<p))===b, 'hamming fix bit'+p+' of '+b);
|
|
}
|
|
for(let L=0;L<ZEBRA_LEVELS;L++) ok(grayVal2Phys[grayPhys2Val[L]]===L, 'gray inverse '+L);
|
|
for(let L=0;L+1<ZEBRA_LEVELS;L++)
|
|
ok((grayPhys2Val[L]^grayPhys2Val[L+1]).toString(2).replace(/0/g,'').length===1, 'gray adjacency '+L);
|
|
|
|
/* ===== INTEGRATION ===== */
|
|
const probe=[0x5A,0x42,0x03,0xAA,0x55,0x00,0xFF,0x10,0x99];
|
|
for(const baud of [20,50,100]) ok(demod(probe,baud,false,0).join(',').includes(probe.join(',')), 'modem clean roundtrip baud='+baud);
|
|
let recv=0; for(let i=0;i<40;i++) if(demod(probe,50,true,0).join(',').includes(probe.join(','))) recv++;
|
|
ok(recv>=37, 'FEC recovers >=37/40 with one +/-1 symbol error per byte (got '+recv+')');
|
|
|
|
/* ===== FUNCTIONAL ===== */
|
|
// full frame -> modem -> FEC -> demod -> FrameAssembler -> parse
|
|
const payload=new Uint8Array([1,2,3,4,5,6,7,8]);
|
|
const frame=await buildDataFrame(payload);
|
|
const decoded=demod(Array.from(frame),50,false,0);
|
|
const asm=new FrameAssembler(); let got=null;
|
|
for(const b of decoded){ const f=asm.push(b); if(f) got=f; }
|
|
const gp=got?parseFrame(got):null;
|
|
ok(gp && gp.type===T_DATA && Buffer.from(gp.payload).equals(Buffer.from(payload)), 'functional: frame survives modem+FEC+assembler');
|
|
// ACK roundtrip through the assembler
|
|
const ackFrame=await buildAckFrame(gp.crc);
|
|
const ad=demod(Array.from(ackFrame),50,false,0);
|
|
const asm2=new FrameAssembler(); let agot=null;
|
|
for(const b of ad){ const f=asm2.push(b); if(f) agot=f; }
|
|
const ap=agot?parseFrame(agot):null;
|
|
ok(ap && ap.type===T_ACK && (ap.ackCrc>>>0)===(gp.crc>>>0), 'functional: ACK matches sent frame crc');
|
|
|
|
console.log((fail?'':'\\u2713 ')+'zebra-report web protocol: '+pass+' passed, '+fail+' failed');
|
|
if(fail){ console.log(' failures:'); fails.slice(0,20).forEach(f=>console.log(' - '+f)); }
|
|
globalThis.__fail=fail;
|
|
})();
|
|
`;
|
|
eval(harness);
|
|
setTimeout(() => process.exit(globalThis.__fail ? 1 : 0), 200);
|