From 7c39f3537adc21cf4a08aad975cbfe126852e936 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 28 May 2026 12:44:20 -0400 Subject: [PATCH] test: node web protocol suite (no devices) + exponential retransmit backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Makefile | 10 ++- test/web-protocol.test.js | 137 ++++++++++++++++++++++++++++++++++++++ web/chat.html | 3 +- 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 test/web-protocol.test.js diff --git a/Makefile b/Makefile index dff0609..0a84183 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/test/web-protocol.test.js b/test/web-protocol.test.js new file mode 100644 index 0000000..a14cdc3 --- /dev/null +++ b/test/web-protocol.test.js @@ -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>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>(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{ 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<=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); diff --git a/web/chat.html b/web/chat.html index 29632a1..d069bba 100644 --- a/web/chat.html +++ b/web/chat.html @@ -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 */