# 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=e16]:
    - region [ref=e17]:
      - generic [ref=e18]:
        - paragraph [ref=e19]: 귀를 기울이면, 발견하는 즐거움
        - heading [level=2] [ref=e21]:
          - text: 소음 속 작은 단어,
          - emphasis [ref=e22]: 들리시나요?
        - paragraph [ref=e23]: 도시의 소리 사이에 에스가 숨겨 둔 단어.조금 더 귀 기울여, 오늘의 행운을 찾아보세요.
        - generic [ref=e24]:
          - generic [ref=e25]: 한 라운드 3개의 질문
          - generic [ref=e26]: ·5단계 도전
          - generic [ref=e27]: ·나만의 속도로
      - generic [ref=e28]:
        - generic [ref=e29]:
          - generic [ref=e30]: 에스의 사운드 룸
          - generic [ref=e32]: 8가지 환경
        - generic [aria-hidden] [ref=e35]: ♪
        - generic [aria-hidden] [ref=e36]: ✳
        - generic [ref=e37]: 행운의 단어를 찾아보거라!
        - generic [ref=e44]: S
        - generic [ref=e45]: 소음 사이, 숨겨진 한마디
    - region [ref=e82]:
      - generic [ref=e83]:
        - generic [ref=e84]: "01"
        - generic [ref=e85]:
          - heading "나의 소리 탐험 준비" [level=2] [ref=e86]
          - paragraph [ref=e87]: 한 단계에서 세 문제를 풀고, 완료 후 다음 단계에 도전해요.
        - generic [ref=e88]: 라운드당 약 1–2분
      - generic [ref=e89]:
        - group "어떻게 답할까요?" [ref=e90]:
          - generic [ref=e92]:
            - generic [ref=e93] [cursor=pointer]:
              - radio "골고루 풀기" [checked] [ref=e94]
              - generic [aria-hidden] [ref=e95]: ◈
              - strong [ref=e96]: 골고루 풀기
              - generic [ref=e97]: 선택 + 직접 입력
            - generic [ref=e98] [cursor=pointer]:
              - radio "보기에서 선택" [ref=e99]
              - generic [aria-hidden] [ref=e100]: ☷
              - strong [ref=e101]: 보기에서 선택
              - generic [ref=e102]: 4개 단어 중 하나
            - generic [ref=e103] [cursor=pointer]:
              - radio "직접 입력" [ref=e104]
              - generic [aria-hidden] [ref=e105]: ✎
              - strong [ref=e106]: 직접 입력
              - generic [ref=e107]: 기억한 단어 쓰기
        - generic [ref=e108]:
          - generic [ref=e109]: 배경 환경
          - combobox "배경 환경" [ref=e110] [cursor=pointer]:
            - option "여덟 가지 일상 속으로" [selected]
            - option "길거리"
            - option "쇼핑몰"
            - option "극장"
            - option "백화점"
            - option "시장"
            - option "지하철"
            - option "카페"
            - option "비 오는 거리"
          - paragraph [ref=e111]: 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요.
          - generic [ref=e112]:
            - generic [ref=e113]: 길거리
            - generic [ref=e114]: 카페
            - generic [ref=e115]: 지하철
            - generic [ref=e116]: +5개 환경
      - generic [ref=e117]:
        - generic [ref=e118]: 시작 난이도
        - combobox "시작 난이도" [ref=e119] [cursor=pointer]:
          - option "1단계 · 소리에 익숙해지기" [selected]
          - option "2단계 · 귀 기울이기"
          - option "3단계 · 소음 사이로"
          - option "4단계 · 작은 단서 찾기"
          - option "5단계 · 깊이 집중하기"
      - generic [ref=e120]:
        - generic [ref=e121]: 목표 음성 SNR
        - slider "목표 음성 SNR" [ref=e122] [cursor=pointer]: "6"
        - status [ref=e123]: SNR +6 dB
        - paragraph [ref=e124]: 음성이 배경보다 6 dB 커요. 범위 −12~+12 dB · 1 dB 간격. 양수는 음성이 더 크게, 0은 같게, 음수는 더 작게 들려요. 변경은 다음 재생·재청취부터 적용돼요.
      - generic [ref=e125]:
        - generic [ref=e126]: ◖)) 전체 음량
        - slider "전체 음량" [ref=e127] [cursor=pointer]: "35"
        - status [ref=e128]: 35%
      - generic [ref=e129]:
        - paragraph [ref=e130]: ♬ 편안한 작은 음량으로 시작해 보세요.
        - button "게임 시작 · 소리 듣기" [ref=e131] [cursor=pointer]:
          - text: 게임 시작 · 소리 듣기
          - generic [aria-hidden] [ref=e132]: →
    - region [ref=e133]:
      - heading "작은 단어를 발견하는 방법" [level=2] [ref=e134]
      - generic [ref=e135]:
        - article [ref=e136]:
          - generic [ref=e137]: "01"
          - heading "먼저, 귀 기울이기" [level=3] [ref=e138]
          - paragraph [ref=e139]: 에스의 안내와 소음 속 문장을 들어요.한 라운드 동안 같은 난이도로 들어요.
        - article [ref=e140]:
          - generic [ref=e141]: "02"
          - heading "기억한 단어 찾기" [level=3] [ref=e142]
          - paragraph [ref=e143]: 소리가 끝나면 질문이 나타나요.보기에서 고르거나 직접 입력해요.
        - article [ref=e144]:
          - generic [ref=e145]: "03"
          - heading "나의 발견 돌아보기" [level=3] [ref=e146]
          - paragraph [ref=e147]: 세 문제 뒤 나의 결과를 확인해요.놓쳤다면 언제든 다시 들어도 좋아요.
  - contentinfo [ref=e148]:
    - paragraph [ref=e149]: Designed by OZ at Heat-T Medical Inc.
    - paragraph [ref=e150]: 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.
    - generic [ref=e151]: 개인정보 수집 없이, 오직 플레이만.
    - link "환경음 출처·라이선스" [ref=e152] [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;
```