feat: release Find-Word v0.1
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AudioEngine } from "../src/audio/engine";
|
||||
import { createQuestions } from "../src/game";
|
||||
|
||||
// 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리
|
||||
// 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다.
|
||||
function fixture() {
|
||||
const param = () => ({
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
setTargetAtTime: vi.fn(),
|
||||
});
|
||||
const node = () => ({ connect: vi.fn(), disconnect: vi.fn() });
|
||||
const gains: ReturnType<typeof makeGain>[] = [];
|
||||
function makeGain(): ReturnType<typeof node> & {
|
||||
gain: ReturnType<typeof param>;
|
||||
} {
|
||||
const gain = { ...node(), gain: param() };
|
||||
gains.push(gain);
|
||||
return gain;
|
||||
}
|
||||
function buffer(length = 16000, rate = 16000) {
|
||||
const data = Float32Array.from(
|
||||
{ length },
|
||||
(_, i) => 0.2 * Math.sin(i / 20),
|
||||
);
|
||||
return {
|
||||
duration: length / rate,
|
||||
length,
|
||||
sampleRate: rate,
|
||||
numberOfChannels: 1,
|
||||
getChannelData: () => data,
|
||||
copyToChannel: vi.fn(),
|
||||
};
|
||||
}
|
||||
const sources: ReturnType<typeof makeSource>[] = [];
|
||||
function makeSource(): ReturnType<typeof node> & {
|
||||
buffer: AudioBuffer | null;
|
||||
loop: boolean;
|
||||
start: ReturnType<typeof vi.fn<(when?: number) => void>>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
onended: null | (() => void);
|
||||
} {
|
||||
const source = {
|
||||
...node(),
|
||||
buffer: null,
|
||||
loop: false,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
onended: null as null | (() => void),
|
||||
};
|
||||
sources.push(source);
|
||||
return source;
|
||||
}
|
||||
const context = {
|
||||
currentTime: 2,
|
||||
sampleRate: 16000,
|
||||
state: "suspended",
|
||||
destination: {},
|
||||
resume: vi.fn(async () => {
|
||||
context.state = "running";
|
||||
}),
|
||||
close: vi.fn(async () => {}),
|
||||
createGain: makeGain,
|
||||
createBufferSource: makeSource,
|
||||
createBuffer: (_channels: number, length: number, rate: number) =>
|
||||
buffer(length, rate),
|
||||
decodeAudioData: vi.fn(async () => buffer()),
|
||||
};
|
||||
const fetcher = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response,
|
||||
);
|
||||
const factory = vi.fn(() => context as unknown as AudioContext);
|
||||
return {
|
||||
engine: new AudioEngine(factory, fetcher),
|
||||
context,
|
||||
sources,
|
||||
gains,
|
||||
fetcher,
|
||||
factory,
|
||||
};
|
||||
}
|
||||
const question = createQuestions(42, "choice")[0]!;
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("오디오 재생 수명과 믹서", () => {
|
||||
it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => {
|
||||
const f = fixture();
|
||||
expect(f.factory).not.toHaveBeenCalled();
|
||||
const started = vi.fn();
|
||||
const done = f.engine.play(question, true, started);
|
||||
expect(f.factory).toHaveBeenCalledOnce();
|
||||
expect(f.context.resume).toHaveBeenCalledOnce();
|
||||
await flush();
|
||||
expect(started).toHaveBeenCalledOnce();
|
||||
expect(f.sources).toHaveLength(3);
|
||||
expect(f.fetcher.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/audio/intro.wav",
|
||||
question.audioUrl,
|
||||
]);
|
||||
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
|
||||
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
10 ** (question.db / 20),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
|
||||
0.12,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
|
||||
f.sources[1]!.start.mock.calls[0]![0]!,
|
||||
);
|
||||
f.sources[2]!.onended?.();
|
||||
await done;
|
||||
expect(f.sources.every((s) => s.disconnect.mock.calls.length > 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다", async () => {
|
||||
const f = fixture();
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
const cancelled = expect(done).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"재생 중",
|
||||
);
|
||||
await flush();
|
||||
f.engine.stop();
|
||||
await cancelled;
|
||||
expect(f.sources.every((s) => s.stop.mock.calls.length > 0)).toBe(true);
|
||||
expect(f.sources.every((s) => s.onended === null)).toBe(true);
|
||||
f.engine.dispose();
|
||||
expect(f.context.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다", async () => {
|
||||
const f = fixture();
|
||||
let release!: (value: Response) => void;
|
||||
f.fetcher.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
const cancelled = expect(done).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
await flush();
|
||||
f.engine.stop();
|
||||
await cancelled;
|
||||
release({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
await flush();
|
||||
expect(f.sources).toHaveLength(0);
|
||||
});
|
||||
it("HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다", async () => {
|
||||
const f = fixture();
|
||||
f.fetcher.mockResolvedValueOnce({ ok: false } as Response);
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"음성 파일",
|
||||
);
|
||||
f.context.decodeAudioData.mockRejectedValueOnce(new Error("decode"));
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"decode",
|
||||
);
|
||||
f.context.resume.mockRejectedValueOnce(new Error("권한"));
|
||||
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
|
||||
"권한",
|
||||
);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
await flush();
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
});
|
||||
it("전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다", async () => {
|
||||
const f = fixture();
|
||||
f.engine.setVolume(0.3);
|
||||
const done = f.engine.play(question, false, vi.fn());
|
||||
await flush();
|
||||
expect(f.gains[0]!.gain.value).toBe(0.3);
|
||||
f.engine.setVolume(8);
|
||||
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2, 0.02);
|
||||
f.engine.setVolume(-1);
|
||||
expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(0, 2, 0.02);
|
||||
f.sources.at(-1)!.onended?.();
|
||||
await done;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user