Files
python-tdd/src/static_src/tests/VoiceGlowSpec.js
Disco DeDisco 92d46b3dce my-sea voice: volume-reactive equalizer glow + muted (--priRd/.fa-ban) state — TDD
Phase 5b of the my-sea voice batch. Resolves the Bug-B-vs-item-7 conflict per
user call 2026-05-29: a click while connected MUTES (leaving stays on BYE/NVM),
and the item-7 "--priRd/.fa-ban disconnected" visual maps onto the MUTED state.
Replaces Phase 3's 2x-pulse stand-in with a real Web-Audio equalizer.

- voice-mesh.js: an AnalyserNode taps each incoming peer stream (lazy
  AudioContext); `inputLevel()` returns the loudest current RMS (~0..1) across
  peers. Analysers torn down per-peer + on call end.
- voice-glow.js: the live-mic glow now resolves to — alone → `.voice-pulse`
  (steady 2s cadence); others connected → `.voice-eq`, whose `--voice-level`
  CSS var is fed each frame from inputLevel() via a self-stopping rAF loop;
  muted → `.voice-muted` modifier on either (recolor only). rAF cancelled on
  destroy.
- _burger.scss: `.voice-eq` box-shadow spread+alpha scale with `--voice-level`
  (no keyframe — audio drives it); `.voice-muted` recolors to --priRd (halo
  stays --ninUser) + flips the voice sub-btn icon to .fa-ban. Drops the unused
  `.voice-pulse--fast`.
- VoiceGlowSpec: +4 specs (equalizer swap, muted-alone, muted-with-others,
  unmute clears); VoiceMeshSpec: +1 (inputLevel 0 with no analysers). 438
  Jasmine specs green.

Live-verify on staging (audio can't be auto-tested): the equalizer reacting to
real speech + the muted/unmuted colour swap on a live call.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:46:08 -04:00

176 lines
7.1 KiB
JavaScript

// ── 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);
});
});
});