// ── VoiceGlowSpec.js ───────────────────────────────────────────────────────── // // Unit specs for voice-glow.js — the my-sea voice-affordance glow/pulse state // machine (Phase 3, user-spec 2026-05-29). // // DOM contract: // #id_burger_btn — burger btn (carries .active when fan open, .glow-handoff // when the sea draw-nudge is on it) // #id_voice_btn — voice sub-btn (.active = voice available; .in-call once // the mic is live) // #id_sea_btn — sea sub-btn (.glow-handoff = sea draw-nudge active) // // Public API under test (window.bindVoiceGlow): // var vg = bindVoiceGlow(); // {render, setVoiceState, destroy} // vg.setVoiceState({inCall, peerCount}); // // Glow classes: .voice-glow (steady), .voice-pulse (alone), .voice-pulse--fast // (a 2nd+ party connected). // ───────────────────────────────────────────────────────────────────────────── describe("VoiceGlow", () => { let burger, voice, sea, vg; function mk(id) { const el = document.createElement("button"); el.id = id; document.body.appendChild(el); return el; } beforeEach(() => { // Ensure no stale singleton subscription from a prior page load. if (window.VoiceRoom) window.VoiceRoom._onState = null; burger = mk("id_burger_btn"); voice = mk("id_voice_btn"); sea = mk("id_sea_btn"); voice.classList.add("active"); // voice available by default }); afterEach(() => { if (vg) { vg.destroy(); vg = null; } [burger, voice, sea].forEach((el) => el && el.remove()); }); const hasGlow = (el) => el.classList.contains("voice-glow"); const hasPulse = (el) => el.classList.contains("voice-pulse"); const hasEq = (el) => el.classList.contains("voice-eq"); const hasMuted = (el) => el.classList.contains("voice-muted"); describe("bindVoiceGlow()", () => { it("returns null when the burger btn is absent", () => { burger.remove(); burger = null; expect(bindVoiceGlow()).toBeNull(); }); it("returns null when the voice btn is absent", () => { voice.remove(); voice = null; expect(bindVoiceGlow()).toBeNull(); }); }); describe("pre-join nudge (voice available, mic not live)", () => { it("glows the burger when the fan is closed and no sea nudge", () => { vg = bindVoiceGlow(); expect(hasGlow(burger)).toBe(true); expect(hasGlow(voice)).toBe(false); }); it("does NOT glow when voice is unavailable (no .active)", () => { voice.classList.remove("active"); vg = bindVoiceGlow(); expect(hasGlow(burger)).toBe(false); expect(hasGlow(voice)).toBe(false); }); it("yields burger precedence to the sea draw-nudge (sea glowing)", () => { sea.classList.add("glow-handoff"); vg = bindVoiceGlow(); expect(hasGlow(burger)).toBe(false); // sea keeps the burger }); it("reclaims the burger once the sea nudge clears", () => { sea.classList.add("glow-handoff"); vg = bindVoiceGlow(); expect(hasGlow(burger)).toBe(false); sea.classList.remove("glow-handoff"); vg.render(); expect(hasGlow(burger)).toBe(true); }); it("moves the glow to the voice sub-btn when the fan opens", () => { vg = bindVoiceGlow(); burger.classList.add("active"); // fan open vg.render(); expect(hasGlow(voice)).toBe(true); expect(hasGlow(burger)).toBe(false); }); }); describe("live (mic in use)", () => { it("pulses the burger (base cadence) while alone + fan closed", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 0 }); expect(hasGlow(burger)).toBe(true); expect(hasPulse(burger)).toBe(true); expect(hasEq(burger)).toBe(false); }); it("pulses the voice sub-btn while alone + fan open", () => { vg = bindVoiceGlow(); burger.classList.add("active"); vg.setVoiceState({ inCall: true, peerCount: 0 }); expect(hasPulse(voice)).toBe(true); expect(hasPulse(burger)).toBe(false); }); it("switches to the equalizer once a 2nd party connects", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 1 }); expect(hasEq(burger)).toBe(true); expect(hasPulse(burger)).toBe(false); }); it("recolors to muted (--priRd) when the mic is muted, both alone…", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 0, muted: true }); expect(hasPulse(burger)).toBe(true); // still pulsing (alone) expect(hasMuted(burger)).toBe(true); // …but in the muted colour }); it("…and while the equalizer runs with others connected", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 2, muted: true }); expect(hasEq(burger)).toBe(true); expect(hasMuted(burger)).toBe(true); }); it("drops the muted colour on unmute", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 0, muted: true }); expect(hasMuted(burger)).toBe(true); vg.setVoiceState({ inCall: true, peerCount: 0, muted: false }); expect(hasMuted(burger)).toBe(false); }); it("hands the pulse to whichever surface is showing (fan toggles)", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 0 }); expect(hasPulse(burger)).toBe(true); burger.classList.add("active"); // fan opens mid-call vg.render(); expect(hasPulse(voice)).toBe(true); expect(hasPulse(burger)).toBe(false); }); it("clears the pulse when the call ends", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 0 }); expect(hasPulse(burger)).toBe(true); vg.setVoiceState({ inCall: false, peerCount: 0 }); expect(hasPulse(burger)).toBe(false); expect(hasGlow(burger)).toBe(true); // back to the pre-join nudge }); }); describe("idempotent reconcile", () => { it("does not stack duplicate classes across repeated renders", () => { vg = bindVoiceGlow(); vg.setVoiceState({ inCall: true, peerCount: 1 }); vg.render(); vg.render(); const eqCount = (burger.className.match(/voice-eq/g) || []).length; expect(eqCount).toBe(1); }); }); });