test: node web protocol suite (no devices) + exponential retransmit backoff

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.
This commit is contained in:
Russell Ballestrini 2026-05-28 12:44:20 -04:00
parent a01f1b4aac
commit 7c39f3537a
No known key found for this signature in database
3 changed files with 147 additions and 3 deletions

View file

@ -4,20 +4,26 @@ LDFLAGS = $(shell pkg-config --libs libpulse) -lrt -lpthread
PULSE = src/pulse.c
.PHONY: all clean serve blog test test-all zebrad
.PHONY: all clean serve blog test test-all test-web zebrad
all: tx rx chat bt carrier zebrad
test: test/unit
@./test/unit
test-all: test/unit test/integration test/functional
# web modem/protocol tests — pure Node, no browser or second device needed
test-web:
@node test/web-protocol.test.js
test-all: test/unit test/integration test/functional test-web
@echo "--- unit ---"
@./test/unit
@echo "--- integration ---"
@./test/integration
@echo "--- functional ---"
@./test/functional
@echo "--- web protocol ---"
@node test/web-protocol.test.js
test/unit: test/unit.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/unit.c

137
test/web-protocol.test.js Normal file
View file

@ -0,0 +1,137 @@
#!/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);

View file

@ -1010,7 +1010,8 @@ function renderOutbox() {
function scheduleRetransmit(crc) {
const entry = msgPending.get(crc);
if (!entry) return;
const wait = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
const base = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
const wait = base * Math.pow(1.8, entry.tries - 1); /* exponential backoff — don't hammer */
entry.timer = setTimeout(async () => {
const e = msgPending.get(crc);
if (!e) return; /* already ACKed */