|
| 1 | +/** |
| 2 | + * catBlinkV2.test.ts — proves the mobile Cat Blink v2 FRAME-layer decoder is |
| 3 | + * bit-true against the SAME committed vector the JS and Python codecs use. |
| 4 | + * |
| 5 | + * The vector (tests/data/cat_blink_v2_vector.json) is produced by the JS emitter |
| 6 | + * (web_demo/static/cat-blink-v2.js) and consumed by cat_blink_v2.py; reading the |
| 7 | + * real file here keeps the JS-emitter ↔ mobile-decoder round-trip proven, not |
| 8 | + * just self-consistent. Each entry in `frames` is one WHITENED frame bitstring. |
| 9 | + */ |
| 10 | + |
| 11 | +import * as fs from 'fs'; |
| 12 | +import * as path from 'path'; |
| 13 | +import { |
| 14 | + decodeFrame, |
| 15 | + scanFrames, |
| 16 | + crc16, |
| 17 | + bytesToBits, |
| 18 | + bitsToBytes, |
| 19 | + dewhitenBits, |
| 20 | + whitenBits, |
| 21 | + CatBlinkV2Collector, |
| 22 | + collectTarget, |
| 23 | + SYNC, |
| 24 | + VERSION, |
| 25 | +} from '../src/services/catBlinkV2'; |
| 26 | + |
| 27 | +// ── Load the committed cross-language vector ──────────────────────────────── |
| 28 | + |
| 29 | +interface Vector { |
| 30 | + payload_bytes: number[]; |
| 31 | + kBlocks: number; |
| 32 | + blockSize: number; |
| 33 | + originalLength: number; |
| 34 | + frames: string[]; |
| 35 | +} |
| 36 | + |
| 37 | +const VECTOR_PATH = path.resolve(__dirname, '../../tests/data/cat_blink_v2_vector.json'); |
| 38 | +const vector = JSON.parse(fs.readFileSync(VECTOR_PATH, 'utf8')) as Vector; |
| 39 | + |
| 40 | +// ── Frame-layer primitives ────────────────────────────────────────────────── |
| 41 | + |
| 42 | +describe('catBlinkV2 primitives', () => { |
| 43 | + it('crc16 is CRC-16/CCITT-FALSE (known test vector "123456789" = 0x29B1)', () => { |
| 44 | + const bytes = new Uint8Array([...'123456789'].map((c) => c.charCodeAt(0))); |
| 45 | + expect(crc16(bytes)).toBe(0x29b1); |
| 46 | + }); |
| 47 | + |
| 48 | + it('bytesToBits/bitsToBytes round-trip MSB-first', () => { |
| 49 | + const bytes = new Uint8Array([0xb1, 0x17, 0x5e, 0x02, 0x00, 0xff]); |
| 50 | + const bits = bytesToBits(bytes); |
| 51 | + expect(bits).toBe('101100010001011101011110000000100000000011111111'); |
| 52 | + expect([...bitsToBytes(bits)]).toEqual([...bytes]); |
| 53 | + }); |
| 54 | + |
| 55 | + it('dewhitenBits is self-inverse (matches catWhitening)', () => { |
| 56 | + const raw = bytesToBits(new Uint8Array([0x00, 0x00, 0x00, 0xff, 0xa5])); |
| 57 | + expect(dewhitenBits(whitenBits(raw))).toBe(raw); |
| 58 | + }); |
| 59 | +}); |
| 60 | + |
| 61 | +// ── decodeFrame against every committed frame ─────────────────────────────── |
| 62 | + |
| 63 | +describe('catBlinkV2 decodeFrame (committed vector)', () => { |
| 64 | + it('decodes every frame with the vector headers and a well-formed droplet', () => { |
| 65 | + expect(vector.frames.length).toBeGreaterThan(0); |
| 66 | + for (const wf of vector.frames) { |
| 67 | + const f = decodeFrame(wf); |
| 68 | + expect(f).not.toBeNull(); |
| 69 | + expect(f!.version).toBe(VERSION); |
| 70 | + expect(f!.kBlocks).toBe(vector.kBlocks); |
| 71 | + expect(f!.blockSize).toBe(vector.blockSize); |
| 72 | + expect(f!.originalLength).toBe(vector.originalLength); |
| 73 | + // droplet_len = 4(seed)+2(count)+count*2(indices)+blockSize(data) |
| 74 | + expect(f!.dropletBytes.length).toBe(4 + 2 + f!.count * 2 + vector.blockSize); |
| 75 | + // seed = first 4 bytes of dropletBytes, big-endian |
| 76 | + const db = f!.dropletBytes; |
| 77 | + const expectedSeed = |
| 78 | + (db[0]! * 0x1000000 + (db[1]! << 16) + (db[2]! << 8) + db[3]!) >>> 0; |
| 79 | + expect(f!.seed).toBe(expectedSeed); |
| 80 | + } |
| 81 | + }); |
| 82 | + |
| 83 | + it('recovers all kBlocks distinct source-block indices across the frames', () => { |
| 84 | + // The systematic droplets (degree-1) cover every source block; gathering the |
| 85 | + // frames must reference all k blocks at least once. |
| 86 | + const covered = new Set<number>(); |
| 87 | + for (const wf of vector.frames) { |
| 88 | + const f = decodeFrame(wf); |
| 89 | + if (!f) continue; |
| 90 | + const db = f.dropletBytes; |
| 91 | + const count = (db[4]! << 8) | db[5]!; |
| 92 | + for (let i = 0; i < count; i++) { |
| 93 | + covered.add((db[6 + i * 2]! << 8) | db[7 + i * 2]!); |
| 94 | + } |
| 95 | + } |
| 96 | + for (let k = 0; k < vector.kBlocks; k++) { |
| 97 | + expect(covered.has(k)).toBe(true); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + it('rejects a frame whose CRC is corrupted (fail-closed)', () => { |
| 102 | + const wf = vector.frames[0]!; |
| 103 | + // Flip the last (whitened) bit → the recovered CRC no longer matches. |
| 104 | + const flipped = wf.slice(0, -1) + (wf[wf.length - 1] === '1' ? '0' : '1'); |
| 105 | + expect(decodeFrame(flipped)).toBeNull(); |
| 106 | + }); |
| 107 | + |
| 108 | + it('rejects a too-short bitstring and a wrong-SYNC frame', () => { |
| 109 | + expect(decodeFrame('0101')).toBeNull(); |
| 110 | + // Build a header-length whitened frame whose SYNC is wrong. |
| 111 | + const bogus = new Uint8Array(20); // all zero → SYNC mismatch |
| 112 | + expect(decodeFrame(whitenBits(bytesToBits(bogus)))).toBeNull(); |
| 113 | + }); |
| 114 | +}); |
| 115 | + |
| 116 | +// ── scanFrames over a free-running loop (concatenation) ───────────────────── |
| 117 | + |
| 118 | +describe('catBlinkV2 scanFrames', () => { |
| 119 | + /** Build a free-running loop: guard garbage + concatenated whitened frames. */ |
| 120 | + function buildLoop(frames: string[], guard = '0110'): string { |
| 121 | + return guard + frames.join(guard) + guard; |
| 122 | + } |
| 123 | + |
| 124 | + it('locks onto SYNC and recovers every frame from a concatenated stream', () => { |
| 125 | + const stream = vector.frames.join(''); // back-to-back, no guard |
| 126 | + const found = scanFrames(stream); |
| 127 | + expect(found.length).toBe(vector.frames.length); |
| 128 | + for (const sf of found) { |
| 129 | + expect(sf.kBlocks).toBe(vector.kBlocks); |
| 130 | + expect(sf.blockSize).toBe(vector.blockSize); |
| 131 | + } |
| 132 | + // The recovered whitened slices must equal the emitted frames. |
| 133 | + expect(found.map((f) => f.whitenedFrame)).toEqual(vector.frames); |
| 134 | + }); |
| 135 | + |
| 136 | + it('tolerates leading/trailing garbage and inter-frame guard bits', () => { |
| 137 | + const stream = buildLoop(vector.frames); |
| 138 | + const found = scanFrames(stream); |
| 139 | + // Every unique seed present in the vector must be recovered. |
| 140 | + const seeds = new Set(found.map((f) => f.seed)); |
| 141 | + const expected = new Set(vector.frames.map((wf) => decodeFrame(wf)!.seed)); |
| 142 | + expect(seeds).toEqual(expected); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +// ── Collector: dedupe + completion (drop/shuffle case) ────────────────────── |
| 147 | + |
| 148 | +describe('CatBlinkV2Collector', () => { |
| 149 | + it('collectTarget mirrors the QR path: ceil(1.5 × k)', () => { |
| 150 | + expect(collectTarget(3)).toBe(5); // ceil(4.5) |
| 151 | + expect(collectTarget(1)).toBe(2); |
| 152 | + expect(collectTarget(10)).toBe(15); |
| 153 | + }); |
| 154 | + |
| 155 | + it('collects unique droplets and completes at ceil(1.5×k)', () => { |
| 156 | + const c = new CatBlinkV2Collector(); |
| 157 | + // Feed one frame at a time (as the growing sample buffer would). |
| 158 | + for (const wf of vector.frames) c.addStream(wf); |
| 159 | + const r = c.result(); |
| 160 | + expect(r.kBlocks).toBe(vector.kBlocks); |
| 161 | + expect(r.blockSize).toBe(vector.blockSize); |
| 162 | + expect(r.originalLength).toBe(vector.originalLength); |
| 163 | + expect(r.target).toBe(collectTarget(vector.kBlocks)); |
| 164 | + // The vector has >= target unique droplets, so collection completes. |
| 165 | + expect(r.droplets.length).toBeGreaterThanOrEqual(r.target!); |
| 166 | + expect(r.complete).toBe(true); |
| 167 | + }); |
| 168 | + |
| 169 | + it('dedupes: re-feeding the same stream adds nothing', () => { |
| 170 | + const c = new CatBlinkV2Collector(); |
| 171 | + const stream = vector.frames.join(''); |
| 172 | + const first = c.addStream(stream); |
| 173 | + expect(first).toBe(vector.frames.length); |
| 174 | + expect(c.addStream(stream)).toBe(0); // idempotent |
| 175 | + expect(c.size).toBe(vector.frames.length); |
| 176 | + }); |
| 177 | + |
| 178 | + it('drop/shuffle case: recovers all kBlocks from a lossy, reordered subset', () => { |
| 179 | + // Shuffle deterministically and drop a third of the frames. |
| 180 | + const shuffled = [...vector.frames] |
| 181 | + .map((wf, i) => ({ wf, key: (i * 7 + 3) % vector.frames.length })) |
| 182 | + .sort((a, b) => a.key - b.key) |
| 183 | + .map((x) => x.wf); |
| 184 | + const kept = shuffled.filter((_, i) => i % 3 !== 0); // drop every 3rd |
| 185 | + |
| 186 | + const c = new CatBlinkV2Collector(); |
| 187 | + for (const wf of kept) c.addStream(wf); |
| 188 | + const r = c.result(); |
| 189 | + |
| 190 | + expect(r.kBlocks).toBe(vector.kBlocks); |
| 191 | + // Every surviving droplet has a distinct seed → no false dedup. |
| 192 | + const distinct = new Set(kept.map((wf) => decodeFrame(wf)!.seed)); |
| 193 | + expect(r.droplets.length).toBe(distinct.size); |
| 194 | + // The kept subset still covers all k source blocks (systematic droplets). |
| 195 | + const covered = new Set<number>(); |
| 196 | + for (const d of r.droplets) { |
| 197 | + const db = d.dropletBytes; |
| 198 | + const count = (db[4]! << 8) | db[5]!; |
| 199 | + for (let i = 0; i < count; i++) { |
| 200 | + covered.add((db[6 + i * 2]! << 8) | db[7 + i * 2]!); |
| 201 | + } |
| 202 | + } |
| 203 | + for (let k = 0; k < vector.kBlocks; k++) expect(covered.has(k)).toBe(true); |
| 204 | + }); |
| 205 | + |
| 206 | + it('reset clears all collected state', () => { |
| 207 | + const c = new CatBlinkV2Collector(); |
| 208 | + c.addStream(vector.frames[0]!); |
| 209 | + expect(c.size).toBe(1); |
| 210 | + c.reset(); |
| 211 | + expect(c.size).toBe(0); |
| 212 | + expect(c.result().kBlocks).toBeNull(); |
| 213 | + }); |
| 214 | +}); |
| 215 | + |
| 216 | +// Sanity: the SYNC constant is exactly the documented marker. |
| 217 | +it('SYNC marker is 0xB1 0x17 0x5E', () => { |
| 218 | + expect([...SYNC]).toEqual([0xb1, 0x17, 0x5e]); |
| 219 | +}); |
0 commit comments