fix: version neural audio requests for v0.1.1
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { isAudioRequest } from "./browser/audio-requests";
|
||||
|
||||
it("WAV 요청 판정은 query의 버전·순서와 무관하게 pathname을 사용한다", () => {
|
||||
for (const path of [
|
||||
"/audio/intro.wav?v=0.1.1",
|
||||
"/audio/luck-apple.wav?v=0.1.1",
|
||||
"/audio/luck-apple.wav?retry=1&v=0.1.1",
|
||||
"/audio/luck-apple.wav?v=0.1.1&retry=1",
|
||||
"/audio/luck-apple.wav",
|
||||
]) {
|
||||
expect
|
||||
.soft(isAudioRequest(new URL(path, "http://localhost")), path)
|
||||
.toBe(true);
|
||||
}
|
||||
for (const path of [
|
||||
"/audio/manifest.json?file=intro.wav",
|
||||
"/other/intro.wav",
|
||||
"/audio-backup/intro.wav",
|
||||
"/audio/intro.wav.txt?v=0.1.1",
|
||||
]) {
|
||||
expect
|
||||
.soft(isAudioRequest(new URL(path, "http://localhost")), path)
|
||||
.toBe(false);
|
||||
}
|
||||
});
|
||||
+53
-4
@@ -88,6 +88,54 @@ const flush = async () => {
|
||||
};
|
||||
|
||||
describe("오디오 재생 수명과 믹서", () => {
|
||||
it("intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다", async () => {
|
||||
const f = fixture();
|
||||
const done = f.engine.play(question, true, vi.fn());
|
||||
await flush();
|
||||
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`,
|
||||
]);
|
||||
f.engine.dispose();
|
||||
});
|
||||
it.each(["intro", "target"])(
|
||||
"%s 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다",
|
||||
async (failed) => {
|
||||
const f = fixture();
|
||||
let pendingFailure = true;
|
||||
f.fetcher.mockImplementation(async (input) => {
|
||||
const pathname = new URL(String(input), "http://localhost").pathname;
|
||||
if (
|
||||
pendingFailure &&
|
||||
pathname ===
|
||||
`/audio/${failed === "intro" ? "intro" : question.id}.wav`
|
||||
) {
|
||||
pendingFailure = false;
|
||||
return { ok: false, status: 404 } as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response;
|
||||
});
|
||||
await expect(f.engine.play(question, true, vi.fn())).rejects.toThrow(
|
||||
"음성 파일",
|
||||
);
|
||||
const done = f.engine.play(question, true, vi.fn());
|
||||
await flush();
|
||||
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`,
|
||||
]);
|
||||
f.engine.dispose();
|
||||
},
|
||||
);
|
||||
it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => {
|
||||
const f = fixture();
|
||||
expect(f.factory).not.toHaveBeenCalled();
|
||||
@@ -98,10 +146,11 @@ describe("오디오 재생 수명과 믹서", () => {
|
||||
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,
|
||||
]);
|
||||
expect(
|
||||
f.fetcher.mock.calls.map(
|
||||
([url]) => new URL(String(url), "http://localhost").pathname,
|
||||
),
|
||||
).toEqual(["/audio/intro.wav", `/audio/${question.id}.wav`]);
|
||||
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
|
||||
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
1,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// 전체 URL의 접미사나 glob은 query가 붙으면 WAV를 놓친다. 경로만 판정하여
|
||||
// 요청 관찰·정답 mapping·404 route·101개 전수 검사가 같은 기준을 사용한다.
|
||||
export function isAudioRequest(url: URL): boolean {
|
||||
return /^\/audio\/[^/]+\.wav$/.test(url.pathname);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createQuestions } from "../../src/game";
|
||||
import { isAudioRequest } from "./audio-requests";
|
||||
|
||||
// 과거 evidence의 검증 스크립트는 보존하고, query URL 전수 검사를 정규 suite로 옮긴다.
|
||||
test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
|
||||
page,
|
||||
browser,
|
||||
}, info) => {
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
|
||||
if (isAudioRequest(url)) requests.push(url.href);
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.1.1");
|
||||
await expect(page.locator(".version")).toHaveText("v0.1.1");
|
||||
// intro는 테스트에서 URL을 재조립하지 않고 배포 앱의 실제 요청을 관찰한다.
|
||||
const introRequest = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/audio/intro.wav",
|
||||
);
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
const introUrl = (await introRequest).url();
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
const targetUrls = [
|
||||
...new Set(
|
||||
Array.from({ length: 200 }, (_, seed) =>
|
||||
createQuestions(seed, "mixed").map((question) => question.audioUrl),
|
||||
).flat(),
|
||||
),
|
||||
];
|
||||
expect(targetUrls).toHaveLength(100);
|
||||
const urls = [
|
||||
introUrl,
|
||||
...targetUrls.map((url) => new URL(url, page.url()).href),
|
||||
];
|
||||
expect(
|
||||
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.1.1"]));
|
||||
expect(
|
||||
urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
|
||||
).toBe(true);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync("public/audio/manifest.json", "utf8"),
|
||||
) as {
|
||||
version: string;
|
||||
files: { file: string; sha256: string; bytes: number; duration: number }[];
|
||||
};
|
||||
expect(manifest.version).toBe("0.1.1");
|
||||
expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
|
||||
manifest.files.map((entry) => `/audio/${entry.file}`).sort(),
|
||||
);
|
||||
requests.length = 0;
|
||||
const rows = await page.evaluate(
|
||||
async ({ urls, entries }) => {
|
||||
const context = new OfflineAudioContext(1, 1, 24000);
|
||||
const rows = [];
|
||||
for (const url of urls) {
|
||||
// query를 파일명으로 취급하지 않는다. 원문 파일과 응답 해시를 독립 대조한다.
|
||||
const entry = entries.find(
|
||||
(entry) => `/audio/${entry.file}` === new URL(url).pathname,
|
||||
)!;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw Error(`${url}: HTTP ${response.status}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const sha256 = Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
|
||||
(b) => b.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
if (sha256 !== entry.sha256 || bytes.byteLength !== entry.bytes)
|
||||
throw Error(`${url}: integrity mismatch`);
|
||||
const buffer = await context.decodeAudioData(bytes);
|
||||
if (
|
||||
buffer.sampleRate !== 24000 ||
|
||||
buffer.numberOfChannels !== 1 ||
|
||||
Math.abs(buffer.duration - entry.duration) > 1e-9
|
||||
)
|
||||
throw Error(`${url}: decode format/duration mismatch`);
|
||||
let power = 0,
|
||||
peak = 0;
|
||||
for (const sample of buffer.getChannelData(0)) {
|
||||
power += sample * sample;
|
||||
peak = Math.max(peak, Math.abs(sample));
|
||||
}
|
||||
const rms = Math.sqrt(power / buffer.length);
|
||||
if (rms <= 100 / 32768 || peak >= 32767 / 32768)
|
||||
throw Error(`${url}: signal failure`);
|
||||
rows.push({
|
||||
url,
|
||||
sha256,
|
||||
bytes: entry.bytes,
|
||||
sampleRate: buffer.sampleRate,
|
||||
channels: buffer.numberOfChannels,
|
||||
duration: buffer.duration,
|
||||
rms,
|
||||
peak,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
{ urls, entries: manifest.files },
|
||||
);
|
||||
expect(rows).toHaveLength(101);
|
||||
expect([...requests].sort()).toEqual([...urls].sort());
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
const path = info.outputPath("browser-audio-integrity.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify(
|
||||
{ chrome: browser.version(), external, errors, files: rows },
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
await info.attach("101 versioned WAV integrity", {
|
||||
path,
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
+74
-27
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { WORDS } from "../../src/content";
|
||||
import { isAudioRequest } from "./audio-requests";
|
||||
|
||||
// 실제 WAV를 디코드하고 Web Audio의 ended를 기다린다. 모의 엔진을 사용한
|
||||
// 컴포넌트 검증과 달리 배포 산출물·자동 재생 정책·레이아웃을 함께 확인한다.
|
||||
@@ -10,11 +11,14 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let audioPath = "";
|
||||
const audioUrls: URL[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().startsWith("http://127.0.0.1:4173"))
|
||||
external.push(request.url());
|
||||
if (request.url().endsWith(".wav") && !request.url().endsWith("intro.wav"))
|
||||
audioPath = new URL(request.url()).pathname;
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== "http://127.0.0.1:4173") external.push(request.url());
|
||||
if (isAudioRequest(url)) {
|
||||
audioUrls.push(url);
|
||||
if (url.pathname !== "/audio/intro.wav") audioPath = url.pathname;
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
@@ -29,12 +33,17 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-home.png`,
|
||||
path: info.outputPath("home.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect.poll(() => audioUrls.length).toBe(2);
|
||||
expect(audioUrls.map((url) => url.searchParams.get("v"))).toEqual([
|
||||
"0.1.1",
|
||||
"0.1.1",
|
||||
]);
|
||||
await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
"animation-name",
|
||||
@@ -67,7 +76,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-game.png`,
|
||||
path: info.outputPath("game.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
@@ -99,7 +108,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-results.png`,
|
||||
path: info.outputPath("results.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
@@ -107,28 +116,66 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeVisible();
|
||||
expect(new Set(audioUrls.map((url) => url.searchParams.get("v")))).toEqual(
|
||||
new Set(["0.1.1"]),
|
||||
);
|
||||
expect(
|
||||
audioUrls.filter((url) => url.pathname !== "/audio/intro.wav"),
|
||||
).toHaveLength(11);
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("오디오 HTTP 실패를 화면에 표시하고 재시도한다", async ({ page }) => {
|
||||
await page.route("**/audio/*.wav", (route) =>
|
||||
route.fulfill({ status: 404, body: "missing" }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
await page.unroute("**/audio/*.wav");
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
// 재시도는 안내부터 재생한다. v0.1.1 최대 7.056 + 11.256 + 시작·전환 0.4초에
|
||||
// 로드·디코드·병렬 브라우저 스케줄링 여유를 더해 이 assertion만 30초로 제한한다.
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
for (const failed of ["intro", "target"]) {
|
||||
test(`${failed} WAV query URL의 404를 표시하고 같은 URL로 재시도·정답 mapping한다`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const audioUrls: string[] = [];
|
||||
const missing: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (isAudioRequest(new URL(request.url()))) audioUrls.push(request.url());
|
||||
});
|
||||
const matchesFailure = (url: URL) =>
|
||||
isAudioRequest(url) &&
|
||||
(failed === "intro"
|
||||
? url.pathname === "/audio/intro.wav"
|
||||
: url.pathname !== "/audio/intro.wav");
|
||||
await page.route(matchesFailure, (route) => {
|
||||
missing.push(route.request().url());
|
||||
return route.fulfill({ status: 404, body: "missing" });
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.1.1");
|
||||
expect(audioUrls).toHaveLength(2);
|
||||
await page.unroute(matchesFailure);
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
// 안내 7.056 + 최대 목표 11.256초와 로드·디코드 여유를 포함한다.
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(audioUrls).toHaveLength(4);
|
||||
expect(audioUrls.slice(2).sort()).toEqual(audioUrls.slice(0, 2).sort());
|
||||
expect(
|
||||
new Set(audioUrls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.1.1"]));
|
||||
const targetPath = audioUrls
|
||||
.map((url) => new URL(url).pathname)
|
||||
.find((path) => path !== "/audio/intro.wav")!;
|
||||
const answer = WORDS.find((word) =>
|
||||
targetPath.endsWith(`-${word.id}.wav`),
|
||||
)!.answer;
|
||||
await page.getByRole("button", { name: new RegExp(answer) }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "정답이에요!" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeFocused();
|
||||
});
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeFocused();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async
|
||||
// 이미지 안으로 들어오는 Chrome 캡처 아티팩트를 방지한다. 초점은 유지한다.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-heading.png`,
|
||||
path: info.outputPath("heading.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
const gameHeadings = await wordBreaks();
|
||||
|
||||
+21
-1
@@ -11,6 +11,24 @@ import {
|
||||
// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이
|
||||
// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다.
|
||||
describe("출제와 정답 판정", () => {
|
||||
it("목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다", () => {
|
||||
const questions = new Map(
|
||||
Array.from({ length: 200 }, (_, seed) => createQuestions(seed, "mixed"))
|
||||
.flat()
|
||||
.map((q) => [q.id, q]),
|
||||
);
|
||||
expect(questions.size).toBe(100);
|
||||
const versions = new Set<string | null>();
|
||||
for (const q of questions.values()) {
|
||||
const url = new URL(q.audioUrl, "http://localhost");
|
||||
expect(url.origin).toBe("http://localhost");
|
||||
expect(url.pathname).toBe(`/audio/${q.pattern.id}-${q.word.id}.wav`);
|
||||
expect(q.id).toBe(`${q.pattern.id}-${q.word.id}`);
|
||||
expect(q.options).toContain(q.word.answer);
|
||||
versions.add(url.searchParams.get("v"));
|
||||
}
|
||||
expect(versions).toEqual(new Set(["0.1.1"]));
|
||||
});
|
||||
it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
|
||||
for (let seed = 0; seed < 20; seed++) {
|
||||
for (const q of createQuestions(seed, "choice")) {
|
||||
@@ -43,7 +61,9 @@ describe("출제와 정답 판정", () => {
|
||||
expect(new Set(q.options).size).toBe(4);
|
||||
expect(q.options.filter((o) => o === q.word.answer)).toHaveLength(1);
|
||||
expect(q.sentence).not.toContain("{word}");
|
||||
expect(q.audioUrl).toBe(`/audio/${q.pattern.id}-${q.word.id}.wav`);
|
||||
expect(new URL(q.audioUrl, "http://localhost").pathname).toBe(
|
||||
`/audio/${q.pattern.id}-${q.word.id}.wav`,
|
||||
);
|
||||
});
|
||||
expect(createQuestions(42, "mixed")).toEqual(questions);
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user