feat: release Find-Word v0.2.3

This commit is contained in:
oz
2026-10-14 10:01:46 +00:00
parent c92660b514
commit 812540c743
531 changed files with 103693 additions and 325 deletions
+86 -13
View File
@@ -31,7 +31,8 @@ function fixture() {
sampleRate: rate,
numberOfChannels: 1,
getChannelData: () => data,
copyToChannel: vi.fn(),
// 실제 AudioBuffer처럼 복사를 반영해야 정규화 뒤 피크를 검증할 수 있다.
copyToChannel: vi.fn((samples: Float32Array) => data.set(samples)),
};
}
const sources: ReturnType<typeof makeSource>[] = [];
@@ -95,8 +96,9 @@ describe("오디오 재생 수명과 믹서", () => {
f.sources.at(-1)!.onended?.();
await done;
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
"/audio/intro.wav?v=0.1.1",
`/audio/${question.id}.wav?v=0.1.1`,
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
question.environment.audioUrl,
]);
f.engine.dispose();
});
@@ -128,10 +130,12 @@ describe("오디오 재생 수명과 믹서", () => {
f.sources.at(-1)!.onended?.();
await done;
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
"/audio/intro.wav?v=0.1.1",
`/audio/${question.id}.wav?v=0.1.1`,
"/audio/intro.wav?v=0.1.1",
`/audio/${question.id}.wav?v=0.1.1`,
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
question.environment.audioUrl,
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
question.environment.audioUrl,
]);
f.engine.dispose();
},
@@ -150,7 +154,11 @@ describe("오디오 재생 수명과 믹서", () => {
f.fetcher.mock.calls.map(
([url]) => new URL(String(url), "http://localhost").pathname,
),
).toEqual(["/audio/intro.wav", `/audio/${question.id}.wav`]);
).toEqual([
"/audio/intro.wav",
`/audio/${question.id}.wav`,
new URL(question.environment.audioUrl, "http://localhost").pathname,
]);
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
1,
@@ -160,10 +168,9 @@ describe("오디오 재생 수명과 믹서", () => {
10 ** (question.db / 20),
expect.any(Number),
);
expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
0.12,
expect.any(Number),
);
expect(
f.gains[3]!.gain.linearRampToValueAtTime.mock.calls[0]![0],
).toBeCloseTo(0.12, 10);
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
f.sources[1]!.start.mock.calls[0]![0]!,
);
@@ -221,7 +228,7 @@ describe("오디오 재생 수명과 믹서", () => {
);
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(
@@ -246,3 +253,69 @@ describe("오디오 재생 수명과 믹서", () => {
await done;
});
});
// 배경 파일은 길이가 짧아도 루프 소스 하나로 유지하며 목표 ended에서 함께 정리한다.
it("선택한 녹음을 디코드·루프하고 목표 종료에서 모든 소스와 fetch를 정리한다", async () => {
const f = fixture();
const done = f.engine.play(question, false, vi.fn());
await flush();
expect(f.fetcher).toHaveBeenCalledWith(
question.environment.audioUrl,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(f.context.decodeAudioData).toHaveBeenCalledTimes(2);
expect(f.sources[0]!.loop).toBe(true);
expect(f.sources[1]!.loop).toBe(false);
f.sources[1]!.onended?.();
await done;
expect(f.sources.every((s) => s.stop.mock.calls.length === 1)).toBe(true);
for (const [, options] of f.fetcher.mock.calls)
expect(options!.signal!.aborted).toBe(true);
});
it("배경 404·디코드 실패는 한국어 복구 메시지이고 재시도로 성공한다", async () => {
const f = fixture();
f.fetcher.mockImplementationOnce(
async () =>
({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response,
);
f.fetcher.mockResolvedValueOnce({ ok: false } as Response);
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow(
/배경.*다시/,
);
f.context.decodeAudioData.mockRejectedValueOnce(new Error("EncodingError"));
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("로드 시간초과는 fetch를 취소하고 늦은 디코드가 소스를 만들지 않는다", async () => {
vi.useFakeTimers();
try {
const f = fixture();
let release!: (buffer: ReturnType<typeof f.context.createBuffer>) => void;
f.context.decodeAudioData.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const done = f.engine.play(question, false, vi.fn());
const failed = expect(done).rejects.toThrow("준비 시간이 초과");
await flush();
await vi.advanceTimersByTimeAsync(15000);
await failed;
expect(f.fetcher.mock.calls[0]![1]!.signal!.aborted).toBe(true);
release(f.context.createBuffer(1, 100, 24000));
await flush();
expect(f.sources).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
f.engine.dispose();
} finally {
vi.useRealTimers();
}
});