import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_ANDROID, DEVICE_TYPE_EXTENSION } from "./sync-types"; import { encodeChirpPayload } from "./chirp-payload"; import { ChirpExchangeError, createPairingAttempt, runChirpExchange, type AudioIO, } from "./chirp-pairing"; import { __setGgwaveModuleForTest } from "./ggwave"; function fakeGgwaveThatPassesThroughBytes() { // Synthesize a ggwave that round-trips: encode → push raw bytes into the // waveform, decode → return any waveform whose first 64 samples decode as // a payload. This keeps the chirp-pairing logic in scope without needing // a real FSK codec. return { ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 }, GGWAVE_OPERATING_MODE_RX: 1, GGWAVE_OPERATING_MODE_TX: 2, GGWAVE_OPERATING_MODE_RX_AND_TX: 3, getDefaultParameters() { return { sampleRateInp: 48000, sampleRateOut: 48000, samplesPerFrame: 1024, soundMarkerThreshold: 3, payloadLength: -1, operatingMode: 0 }; }, init() { return 1; }, free() { /* no-op */ }, encode(_instance: number, data: Uint8Array | string, _proto: number, _vol: number) { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; // Pack bytes as float samples in the first N positions; the rest // padded with sentinel value 99 so a partial read doesn't match. const out = new Float32Array(Math.max(bytes.length, 128)); for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255; for (let i = bytes.length; i < out.length; i++) out[i] = 99; return out; }, decode(_instance: number, samples: Float32Array | Int16Array) { if (samples.length < CHIRP_PAYLOAD_LENGTH) return null; // Read first 64 samples back as bytes if they look like our encoding. // Sentinel 99 means "no chirp here". const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH); for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) { const v = (samples as Float32Array)[i]; if (v === 99) return null; buf[i] = Math.round(v * 255); } return buf; }, }; } function fakeIO(): AudioIO & { injectIncoming(samples: Float32Array): void; playedCount(): number; closed(): boolean; } { let pending = new Float32Array(0); let playCount = 0; let isClosed = false; return { sampleRate: 48000, async play(_waveform: Float32Array) { playCount++; }, async pullSamples() { const out = pending; pending = new Float32Array(0); return out; }, async close() { isClosed = true; }, injectIncoming(samples: Float32Array) { const next = new Float32Array(pending.length + samples.length); next.set(pending, 0); next.set(samples, pending.length); pending = next; }, playedCount: () => playCount, closed: () => isClosed, }; } describe("createPairingAttempt", () => { it("rolls fresh ephemerals and a 16-byte nonce each call", () => { const a = createPairingAttempt(DEVICE_TYPE_EXTENSION); const b = createPairingAttempt(DEVICE_TYPE_EXTENSION); expect(a.ephemeralPublicKey).not.toEqual(b.ephemeralPublicKey); expect(a.sessionNonce.length).toBe(16); expect(a.outgoingPayload.ephemeralPublicKey).toEqual(a.ephemeralPublicKey); expect(a.outgoingPayload.deviceType).toBe(DEVICE_TYPE_EXTENSION); }); }); describe("runChirpExchange", () => { beforeEach(() => __setGgwaveModuleForTest(fakeGgwaveThatPassesThroughBytes())); afterEach(() => __setGgwaveModuleForTest(null)); it("returns the remote payload when a valid chirp is received", async () => { const io = fakeIO(); const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION); const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID); const theirBytes = encodeChirpPayload(theirs.outgoingPayload); // Convert the 64 bytes into the encoded waveform shape our fake produces. const theirWave = new Float32Array(128); for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255; for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99; io.injectIncoming(theirWave); const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 2000 }); expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey); expect(result.remoteChirp.sessionNonce).toEqual(theirs.sessionNonce); expect(result.remoteChirp.deviceType).toBe(DEVICE_TYPE_ANDROID); expect(io.closed()).toBe(true); }); it("ignores echo of our own chirp", async () => { const io = fakeIO(); const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION); const ourBytes = encodeChirpPayload(ours.outgoingPayload); const ourWave = new Float32Array(128); for (let i = 0; i < ourBytes.length; i++) ourWave[i] = ourBytes[i] / 255; for (let i = ourBytes.length; i < ourWave.length; i++) ourWave[i] = 99; // Inject our own chirp — should be ignored io.injectIncoming(ourWave); // After 200ms inject a real peer chirp const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID); const theirBytes = encodeChirpPayload(theirs.outgoingPayload); const theirWave = new Float32Array(128); for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255; for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99; setTimeout(() => io.injectIncoming(theirWave), 150); const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 }); expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey); }); it("times out after the configured deadline when no chirp arrives", async () => { const io = fakeIO(); const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION); await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 200 })).rejects.toMatchObject({ kind: "timeout", }); expect(io.closed()).toBe(true); }); it("raises version_mismatch on a peer with a different protocol version", async () => { const io = fakeIO(); const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION); const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID); // Mint a chirp with a future protocol version, then re-stamp the CRC const tweaked = { ...theirs.outgoingPayload, version: 0x02 }; const bytes = encodeChirpPayload({ ...tweaked, // encodeChirpPayload writes a fresh CRC32 over the new bytes }); const wave = new Float32Array(128); for (let i = 0; i < bytes.length; i++) wave[i] = bytes[i] / 255; for (let i = bytes.length; i < wave.length; i++) wave[i] = 99; io.injectIncoming(wave); await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 2000 })).rejects.toMatchObject({ kind: "version_mismatch", }); }); it("ignores noise chunks (no chirp detected) and keeps listening", async () => { const io = fakeIO(); const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION); // Push pure noise — fake decoder returns null because samples have value 99 io.injectIncoming(new Float32Array(256).fill(99)); const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID); const theirBytes = encodeChirpPayload(theirs.outgoingPayload); const theirWave = new Float32Array(128); for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255; for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99; setTimeout(() => io.injectIncoming(theirWave), 100); const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 }); expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey); }); }); describe("ChirpExchangeError", () => { it("carries a kind tag", () => { const err = new ChirpExchangeError("timeout"); expect(err.kind).toBe("timeout"); expect(err).toBeInstanceOf(Error); }); });