Ctrl K
ComunidadGuías…

frontend/test/ui-sounds.test.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 界面音效模块的行为:开关、配方的安全边界、document 上的两路监听。
 * happy-dom 没有 Web Audio,这里搭一个只记录调度的假 AudioContext。模块里的
 * AudioContext 是单例,整份文件从头到尾只该建出这一个,所以用例按顺序共用它。 */
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UI_SOUNDS, playUiSound, setUiSoundsEnabled, uiSoundsEnabled, wireUiSounds } from '../src/ui-kit/sounds';

interface Ramp { value: number; at: number }
interface Param {
  sets: Ramp[];
  ramps: Ramp[];
  value: number;
  setValueAtTime(value: number, at: number): void;
  exponentialRampToValueAtTime(value: number, at: number): void;
}
interface Node {
  kind: string;
  params: Record<string, Param>;
  connected: unknown[];
  started?: number;
  stopped?: number;
  onended?: () => void;
  connect(target: unknown): void;
  disconnect(): void;
  start(at: number): void;
  stop(at: number): void;
}

function param(): Param {
  const created: Param = {
    sets: [], ramps: [], value: 0,
    setValueAtTime(value, at) { created.sets.push({ value, at }); },
    exponentialRampToValueAtTime(value, at) {
      if (value <= 0) throw new RangeError('指数斜坡不能落到 0');
      created.ramps.push({ value, at });
    },
  };
  return created;
}

class FakeContext {
  static created = 0;
  static current: FakeContext | null = null;
  state = 'suspended';
  currentTime = 10;
  sampleRate = 48000;
  destination = { kind: 'destination' };
  nodes: Node[] = [];
  constructor() { FakeContext.created += 1; FakeContext.current = this; }
  resume() { this.state = 'running'; return Promise.resolve(); }
  createOscillator() { return this.node('oscillator', ['frequency'], { type: 'sine' }); }
  createGain() { return this.node('gain', ['gain']); }
  createBiquadFilter() { return this.node('filter', ['frequency', 'Q'], { type: 'lowpass' }); }
  createBuffer(channels: number, length: number, rate: number) {
    const data = new Float32Array(length);
    return { channels, length, rate, getChannelData: () => data };
  }
  createBufferSource() { return this.node('source', []); }
  private node(kind: string, names: string[], extra: Record<string, unknown> = {}) {
    const created: Node & Record<string, unknown> = {
      kind, params: {}, connected: [], ...extra,
      connect(target) { created.connected.push(target); },
      disconnect() { created.connected.length = 0; },
      start(at) { created.started = at; },
      stop(at) { created.stopped = at; },
    };
    for (const name of names) {
      const value = param();
      created.params[name] = value;
      created[name] = value;
    }
    this.nodes.push(created);
    return created;
  }
}

(globalThis as Record<string, unknown>).AudioContext = FakeContext;
const nodes = () => FakeContext.current!.nodes;
const kind = (name: string) => nodes().filter((node) => node.kind === name);

beforeEach(() => { FakeContext.current?.nodes.splice(0); });
afterEach(() => {
  setUiSoundsEnabled(false);
  document.body.replaceChildren();
});

describe('开关', () => {
  it('关着时不出声也不建 AudioContext', () => {
    setUiSoundsEnabled(false);
    expect(uiSoundsEnabled()).toBe(false);
    expect(playUiSound('click')).toBe(false);
    expect(FakeContext.created).toBe(0);
  });
  it('不认识的名字直接抛,别把拼错的音效名当成静音', () => {
    expect(() => playUiSound('ding')).toThrow('ding');
  });
});

describe('配方', () => {
  it('全部音效共用一个 AudioContext,音量不越过 0.8,尾音都收在 0.001', () => {
    setUiSoundsEnabled(true);
    expect(UI_SOUNDS).toEqual(['click', 'toggle-on', 'toggle-off', 'success', 'warning', 'error', 'whoosh', 'pop']);
    for (const name of UI_SOUNDS) expect(playUiSound(name)).toBe(true);
    expect(FakeContext.created).toBe(1);
    const gains = kind('gain');
    expect(gains.length).toBeGreaterThanOrEqual(UI_SOUNDS.length);
    for (const gain of gains) {
      const level = gain.params.gain!;
      const peak = Math.max(...level.sets.map((set) => set.value), ...level.ramps.map((ramp) => ramp.value));
      expect(peak).toBeLessThanOrEqual(0.8);
      expect(level.ramps.at(-1)!.value).toBe(0.001);
    }
    for (const osc of kind('oscillator')) expect(osc.stopped!).toBeGreaterThan(osc.started!);
  });
  it('开关音的方向就是状态:开升、关降', () => {
    setUiSoundsEnabled(true);
    playUiSound('toggle-on');
    const rising = kind('oscillator')[0]!.params.frequency!;
    expect(rising.ramps[0]!.value).toBeGreaterThan(rising.sets[0]!.value);
    nodes().splice(0);
    playUiSound('toggle-off');
    const falling = kind('oscillator')[0]!.params.frequency!;
    expect(falling.ramps[0]!.value).toBeLessThan(falling.sets[0]!.value);
  });
  it('噪声源放完把整条链拆掉', () => {
    setUiSoundsEnabled(true);
    playUiSound('click');
    const source = kind('source')[0]!;
    expect(source.connected).toHaveLength(1);
    source.onended!();
    expect(nodes().every((node) => node.connected.length === 0)).toBe(true);
  });
});

describe('document 监听', () => {
  it('按钮点一下响一声,禁用的不响,开关走 change 而不是 click', () => {
    setUiSoundsEnabled(true);
    const root = document.createElement('div');
    document.body.append(root);
    wireUiSounds(root);
    const button = document.createElement('button');
    const disabled = document.createElement('button');
    disabled.disabled = true;
    const busy = document.createElement('button');
    busy.setAttribute('aria-disabled', 'true');
    const label = document.createElement('label');
    const toggle = document.createElement('input');
    toggle.type = 'checkbox';
    toggle.setAttribute('role', 'switch');
    label.append(toggle);
    root.append(button, disabled, busy, label);

    button.click();
    expect(kind('source')).toHaveLength(1);
    nodes().splice(0);
    disabled.click();
    busy.click();
    expect(nodes()).toHaveLength(0);

    toggle.click();
    expect(kind('source')).toHaveLength(0);
    const oscillators = kind('oscillator');
    expect(oscillators).toHaveLength(1);
    const sweep = oscillators[0]!.params.frequency!;
    expect(sweep.ramps[0]!.value).toBeGreaterThan(sweep.sets[0]!.value);
  });
  it('页面在自己那层 stopPropagation 也拦不住这一声', () => {
    setUiSoundsEnabled(true);
    const root = document.createElement('div');
    document.body.append(root);
    wireUiSounds(root);
    const button = document.createElement('button');
    button.addEventListener('click', (event) => event.stopPropagation());
    root.append(button);
    button.click();
    expect(kind('source')).toHaveLength(1);
    expect(FakeContext.created).toBe(1);
  });
});