285 lines
14 KiB
Markdown
285 lines
14 KiB
Markdown
# Instructions
|
||||
|
|
|
|||
|
|
- Following Playwright test failed.
|
|||
|
|
- Explain why, be concise, respect Playwright best practices.
|
|||
|
|
- Provide a snippet of code with the fix, if possible.
|
|||
|
|
|
|||
|
|
# Test info
|
|||
|
|
|
|||
|
|
- Name: audio.spec.ts >> 음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다
|
|||
|
|
- Location: tests/browser/audio.spec.ts:7:1
|
|||
|
|
|
|||
|
|
# Error details
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Error: expect(received).toBe(expected) // Object.is equality
|
|||
|
|
|
|||
|
|
Expected: true
|
|||
|
|
Received: false
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
# Page snapshot
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
- generic [ref=e2]:
|
|||
|
|
- link "본문으로 건너뛰기" [ref=e3] [cursor=pointer]:
|
|||
|
|
- /url: "#main"
|
|||
|
|
- banner [ref=e4]:
|
|||
|
|
- generic [ref=e5]:
|
|||
|
|
- heading "단어를 맞춰라" [level=1] [ref=e11]
|
|||
|
|
- generic [ref=e12]: v0.2.3
|
|||
|
|
- generic [ref=e13]:
|
|||
|
|
- link "플레이 방법" [ref=e14] [cursor=pointer]:
|
|||
|
|
- /url: "#howto"
|
|||
|
|
- text: 플레이 방법 ↓
|
|||
|
|
- text: 소리에 집중하는 시간
|
|||
|
|
- main [ref=e15]:
|
|||
|
|
- region [ref=e16]:
|
|||
|
|
- generic [ref=e17]:
|
|||
|
|
- paragraph [ref=e18]: 귀를 기울이면, 발견하는 즐거움
|
|||
|
|
- heading [level=2] [ref=e20]:
|
|||
|
|
- text: 소음 속 작은 단어,
|
|||
|
|
- emphasis [ref=e21]: 들리시나요?
|
|||
|
|
- paragraph [ref=e22]: 도시의 소리 사이에 에스가 숨겨 둔 단어.조금 더 귀 기울여, 오늘의 행운을 찾아보세요.
|
|||
|
|
- generic [ref=e23]:
|
|||
|
|
- generic [ref=e24]: 한 라운드 3개의 질문
|
|||
|
|
- generic [ref=e25]: ·5단계 도전
|
|||
|
|
- generic [ref=e26]: ·나만의 속도로
|
|||
|
|
- generic [ref=e27]:
|
|||
|
|
- generic [ref=e28]:
|
|||
|
|
- generic [ref=e29]: 에스의 사운드 룸
|
|||
|
|
- generic [ref=e31]: 8가지 환경
|
|||
|
|
- generic [aria-hidden] [ref=e34]: ♪
|
|||
|
|
- generic [aria-hidden] [ref=e35]: ✳
|
|||
|
|
- generic [ref=e36]: 행운의 단어를 찾아보거라!
|
|||
|
|
- generic [ref=e43]: S
|
|||
|
|
- generic [ref=e44]: 소음 사이, 숨겨진 한마디
|
|||
|
|
- region [ref=e81]:
|
|||
|
|
- generic [ref=e82]:
|
|||
|
|
- generic [ref=e83]: "01"
|
|||
|
|
- generic [ref=e84]:
|
|||
|
|
- heading "나의 소리 탐험 준비" [level=2] [ref=e85]
|
|||
|
|
- paragraph [ref=e86]: 한 단계에서 세 문제를 풀고, 완료 후 다음 단계에 도전해요.
|
|||
|
|
- generic [ref=e87]:
|
|||
|
|
- group "어떻게 답할까요?" [ref=e88]:
|
|||
|
|
- generic [ref=e90]:
|
|||
|
|
- generic [ref=e91] [cursor=pointer]:
|
|||
|
|
- radio "골고루 풀기" [checked] [ref=e92]
|
|||
|
|
- generic [aria-hidden] [ref=e93]: ◈
|
|||
|
|
- strong [ref=e94]: 골고루 풀기
|
|||
|
|
- generic [ref=e95]: 선택 + 직접 입력
|
|||
|
|
- generic [ref=e96] [cursor=pointer]:
|
|||
|
|
- radio "보기에서 선택" [ref=e97]
|
|||
|
|
- generic [aria-hidden] [ref=e98]: ☷
|
|||
|
|
- strong [ref=e99]: 보기에서 선택
|
|||
|
|
- generic [ref=e100]: 4개 단어 중 하나
|
|||
|
|
- generic [ref=e101] [cursor=pointer]:
|
|||
|
|
- radio "직접 입력" [ref=e102]
|
|||
|
|
- generic [aria-hidden] [ref=e103]: ✎
|
|||
|
|
- strong [ref=e104]: 직접 입력
|
|||
|
|
- generic [ref=e105]: 기억한 단어 쓰기
|
|||
|
|
- generic [ref=e106]:
|
|||
|
|
- generic [ref=e107]: 배경 환경
|
|||
|
|
- combobox "배경 환경" [ref=e108] [cursor=pointer]:
|
|||
|
|
- option "여덟 가지 일상 속으로" [selected]
|
|||
|
|
- option "길거리"
|
|||
|
|
- option "쇼핑몰"
|
|||
|
|
- option "극장"
|
|||
|
|
- option "백화점"
|
|||
|
|
- option "시장"
|
|||
|
|
- option "지하철"
|
|||
|
|
- option "카페"
|
|||
|
|
- option "비 오는 거리"
|
|||
|
|
- paragraph [ref=e109]: 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요.
|
|||
|
|
- generic [ref=e110]:
|
|||
|
|
- generic [ref=e111]: 길거리
|
|||
|
|
- generic [ref=e112]: 카페
|
|||
|
|
- generic [ref=e113]: 지하철
|
|||
|
|
- generic [ref=e114]: +5개 환경
|
|||
|
|
- generic [ref=e115]:
|
|||
|
|
- generic [ref=e116]: 시작 난이도
|
|||
|
|
- combobox "시작 난이도" [ref=e117] [cursor=pointer]:
|
|||
|
|
- option "1단계 · 소리에 익숙해지기" [selected]
|
|||
|
|
- option "2단계 · 귀 기울이기"
|
|||
|
|
- option "3단계 · 소음 사이로"
|
|||
|
|
- option "4단계 · 작은 단서 찾기"
|
|||
|
|
- option "5단계 · 깊이 집중하기"
|
|||
|
|
- generic [ref=e118]:
|
|||
|
|
- generic [ref=e119]: 목표 음성 SNR
|
|||
|
|
- slider "목표 음성 SNR" [ref=e120] [cursor=pointer]: "6"
|
|||
|
|
- status [ref=e121]: SNR +6 dB
|
|||
|
|
- paragraph [ref=e122]: 음성이 배경보다 6 dB 커요. 범위 −12~+12 dB · 1 dB 간격. 양수는 음성이 더 크게, 0은 같게, 음수는 더 작게 들려요. 변경은 다음 재생·재청취부터 적용돼요.
|
|||
|
|
- generic [ref=e123]:
|
|||
|
|
- generic [ref=e124]: ◖)) 전체 음량
|
|||
|
|
- slider "전체 음량" [ref=e125] [cursor=pointer]: "35"
|
|||
|
|
- status [ref=e126]: 35%
|
|||
|
|
- generic [ref=e127]:
|
|||
|
|
- paragraph [ref=e128]: ♬ 편안한 작은 음량으로 시작해 보세요.
|
|||
|
|
- button "게임 시작 · 소리 듣기" [ref=e129] [cursor=pointer]:
|
|||
|
|
- text: 게임 시작 · 소리 듣기
|
|||
|
|
- generic [aria-hidden] [ref=e130]: →
|
|||
|
|
- region [ref=e131]:
|
|||
|
|
- heading "작은 단어를 발견하는 방법" [level=2] [ref=e132]
|
|||
|
|
- generic [ref=e133]:
|
|||
|
|
- article [ref=e134]:
|
|||
|
|
- generic [ref=e135]: "01"
|
|||
|
|
- heading "먼저, 귀 기울이기" [level=3] [ref=e136]
|
|||
|
|
- paragraph [ref=e137]: 에스의 안내와 소음 속 문장을 들어요. 한 라운드 동안 같은 난이도로 들어요.
|
|||
|
|
- article [ref=e138]:
|
|||
|
|
- generic [ref=e139]: "02"
|
|||
|
|
- heading "기억한 단어 찾기" [level=3] [ref=e140]
|
|||
|
|
- paragraph [ref=e141]: 소리가 끝나면 질문이 나타나요. 보기에서 고르거나 직접 입력해요.
|
|||
|
|
- article [ref=e142]:
|
|||
|
|
- generic [ref=e143]: "03"
|
|||
|
|
- heading "나의 발견 돌아보기" [level=3] [ref=e144]
|
|||
|
|
- paragraph [ref=e145]: 세 문제 뒤 나의 결과를 확인해요. 놓쳤다면 언제든 다시 들어도 좋아요.
|
|||
|
|
- contentinfo [ref=e146]:
|
|||
|
|
- paragraph [ref=e147]: Designed by OZ at Heat-T Medical Inc.
|
|||
|
|
- paragraph [ref=e148]: 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.
|
|||
|
|
- generic [ref=e149]: 개인정보 수집 없이, 오직 플레이만.
|
|||
|
|
- link "환경음 출처·라이선스" [ref=e150] [cursor=pointer]:
|
|||
|
|
- /url: /ambience/AMBIENCE_NOTICES.md
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
# Test source
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
1 | import { readFileSync, writeFileSync } from "node:fs";
|
|||
|
|
2 | import { expect, test } from "@playwright/test";
|
|||
|
|
3 | import { createQuestions } from "../../src/game";
|
|||
|
|
4 | import { isAudioRequest } from "./audio-requests";
|
|||
|
|
5 |
|
|||
|
|
6 | // 과거 evidence의 검증 스크립트는 보존하고, query URL 전수 검사를 정규 suite로 옮긴다.
|
|||
|
|
7 | test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
|
|||
|
|
8 | page,
|
|||
|
|
9 | browser,
|
|||
|
|
10 | }, info) => {
|
|||
|
|
11 | const external: string[] = [];
|
|||
|
|
12 | const errors: string[] = [];
|
|||
|
|
13 | const failed: string[] = [];
|
|||
|
|
14 | const requests: string[] = [];
|
|||
|
|
15 | page.on("request", (request) => {
|
|||
|
|
16 | const url = new URL(request.url());
|
|||
|
|
17 | if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
|
|||
|
|
18 | if (isAudioRequest(url) || url.pathname.startsWith("/ambience/"))
|
|||
|
|
19 | requests.push(url.href);
|
|||
|
|
20 | });
|
|||
|
|
21 | page.on("console", (message) => {
|
|||
|
|
22 | if (message.type() === "error") errors.push(message.text());
|
|||
|
|
23 | });
|
|||
|
|
24 | page.on("requestfailed", (request) => failed.push(request.url()));
|
|||
|
|
25 | page.on("pageerror", (error) => errors.push(error.message));
|
|||
|
|
26 | await page.goto("/");
|
|||
|
|
27 | await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.3");
|
|||
|
|
28 | await expect(page.locator(".version")).toHaveText("v0.2.3");
|
|||
|
|
29 | const plan = JSON.parse(readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"));
|
|||
|
|
30 | const published = await page.evaluate(async () => ({
|
|||
|
|
31 | manifest: await (await fetch("/ambience/manifest.json?v=0.2.3")).json(),
|
|||
|
|
32 | notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.3")).text(),
|
|||
|
|
33 | }));
|
|||
|
|
34 | expect(published.manifest).toEqual(JSON.parse(readFileSync("public/ambience/manifest.json", "utf8")));
|
|||
|
|
35 | for (const asset of plan.assets) {
|
|||
|
|
36 | const start = asset.selectedSegmentStartSeconds;
|
|||
|
|
> 37 | expect(Number.isFinite(start)).toBe(true);
|
|||
|
|
| ^ Error: expect(received).toBe(expected) // Object.is equality
|
|||
|
|
38 | expect(start).toBeGreaterThanOrEqual(0);
|
|||
|
|
39 | expect(start + 12).toBeLessThanOrEqual(asset.directMediaDurationSeconds);
|
|||
|
|
40 | const entry = published.manifest.files.find((e: { environmentId: string }) => e.environmentId === asset.environmentId);
|
|||
|
|
41 | expect(entry.selectedSegmentStartSeconds).toBe(start);
|
|||
|
|
42 | expect(entry.conversion.startSeconds).toBe(start);
|
|||
|
|
43 | expect(entry.conversion.durationSeconds).toBe(12);
|
|||
|
|
44 | const section = published.notice.split("## ").find((s: string) => s.startsWith(`${asset.displayName} — ${asset.environmentId}\n`));
|
|||
|
|
45 | expect(section).toContain(`source seconds ${start}–${start + 12}.`);
|
|||
|
|
46 | }
|
|||
|
|
47 | // intro는 테스트에서 URL을 재조립하지 않고 배포 앱의 실제 요청을 관찰한다.
|
|||
|
|
48 | const introRequest = page.waitForRequest(
|
|||
|
|
49 | (request) => new URL(request.url()).pathname === "/audio/intro.wav",
|
|||
|
|
50 | );
|
|||
|
|
51 | await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
|||
|
|
52 | const introUrl = (await introRequest).url();
|
|||
|
|
53 | await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
|||
|
|
54 | timeout: 30000,
|
|||
|
|
55 | });
|
|||
|
|
56 | await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
|||
|
|
57 | await page.getByRole("button", { name: "처음 화면으로" }).click();
|
|||
|
|
58 | const targetUrls = [
|
|||
|
|
59 | ...new Set(
|
|||
|
|
60 | Array.from({ length: 1000 }, (_, seed) =>
|
|||
|
|
61 | createQuestions(seed, "mixed").map((question) => question.audioUrl),
|
|||
|
|
62 | ).flat(),
|
|||
|
|
63 | ),
|
|||
|
|
64 | ];
|
|||
|
|
65 | expect(targetUrls).toHaveLength(100);
|
|||
|
|
66 | const ambience = JSON.parse(
|
|||
|
|
67 | readFileSync("public/ambience/manifest.json", "utf8"),
|
|||
|
|
68 | );
|
|||
|
|
69 | const urls = [
|
|||
|
|
70 | introUrl,
|
|||
|
|
71 | ...ambience.files.map(
|
|||
|
|
72 | (entry: { file: string }) =>
|
|||
|
|
73 | new URL(`/ambience/${entry.file}?v=0.2.3`, page.url()).href,
|
|||
|
|
74 | ),
|
|||
|
|
75 | ...targetUrls.map((url) => new URL(url, page.url()).href),
|
|||
|
|
76 | ];
|
|||
|
|
77 | expect(
|
|||
|
|
78 | new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
|
|||
|
|
79 | ).toEqual(new Set(["0.2.3"]));
|
|||
|
|
80 | expect(
|
|||
|
|
81 | urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
|
|||
|
|
82 | ).toBe(true);
|
|||
|
|
83 | const manifest = JSON.parse(
|
|||
|
|
84 | readFileSync("public/audio/manifest.json", "utf8"),
|
|||
|
|
85 | ) as {
|
|||
|
|
86 | version: string;
|
|||
|
|
87 | files: { file: string; sha256: string; bytes: number; duration: number }[];
|
|||
|
|
88 | };
|
|||
|
|
89 | expect(manifest.version).toBe("0.2.3");
|
|||
|
|
90 | // 두 manifest의 파일명 충돌 없이 경로를 명시해 109개 응답을 독립 대조한다.
|
|||
|
|
91 | const entries = [
|
|||
|
|
92 | ...manifest.files.map((entry) => ({
|
|||
|
|
93 | ...entry,
|
|||
|
|
94 | path: `/audio/${entry.file}`,
|
|||
|
|
95 | })),
|
|||
|
|
96 | ...ambience.files.map(
|
|||
|
|
97 | (entry: {
|
|||
|
|
98 | file: string;
|
|||
|
|
99 | sha256: string;
|
|||
|
|
100 | bytes: number;
|
|||
|
|
101 | duration: number;
|
|||
|
|
102 | }) => ({ ...entry, path: `/ambience/${entry.file}` }),
|
|||
|
|
103 | ),
|
|||
|
|
104 | ];
|
|||
|
|
105 | expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
|
|||
|
|
106 | entries.map((entry) => entry.path).sort(),
|
|||
|
|
107 | );
|
|||
|
|
108 | requests.length = 0;
|
|||
|
|
109 | const rows = await page.evaluate(
|
|||
|
|
110 | async ({ urls, entries }) => {
|
|||
|
|
111 | const context = new OfflineAudioContext(1, 1, 24000);
|
|||
|
|
112 | const rows = [];
|
|||
|
|
113 | for (const url of urls) {
|
|||
|
|
114 | // query를 파일명으로 취급하지 않는다. 원문 파일과 응답 해시를 독립 대조한다.
|
|||
|
|
115 | const entry = entries.find(
|
|||
|
|
116 | (entry) => entry.path === new URL(url).pathname,
|
|||
|
|
117 | )!;
|
|||
|
|
118 | const response = await fetch(url);
|
|||
|
|
119 | if (!response.ok) throw Error(`${url}: HTTP ${response.status}`);
|
|||
|
|
120 | const bytes = await response.arrayBuffer();
|
|||
|
|
121 | const sha256 = Array.from(
|
|||
|
|
122 | new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
|
|||
|
|
123 | (b) => b.toString(16).padStart(2, "0"),
|
|||
|
|
124 | ).join("");
|
|||
|
|
125 | if (sha256 !== entry.sha256 || bytes.byteLength !== entry.bytes)
|
|||
|
|
126 | throw Error(`${url}: integrity mismatch`);
|
|||
|
|
127 | const buffer = await context.decodeAudioData(bytes);
|
|||
|
|
128 | if (
|
|||
|
|
129 | buffer.sampleRate !== 24000 ||
|
|||
|
|
130 | buffer.numberOfChannels !== 1 ||
|
|||
|
|
131 | Math.abs(buffer.duration - entry.duration) > 1e-9
|
|||
|
|
132 | )
|
|||
|
|
133 | throw Error(`${url}: decode format/duration mismatch`);
|
|||
|
|
134 | let power = 0,
|
|||
|
|
135 | peak = 0;
|
|||
|
|
136 | for (const sample of buffer.getChannelData(0)) {
|
|||
|
|
137 | power += sample * sample;
|
|||
|
|
```
|