# 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: gains.spec.ts >> 실제 GainNode의 배경 배율은 8환경 × SNR 경계·0에서 동일하다 - Location: tests/browser/gains.spec.ts:74:1 # Error details ``` Error: expect(received).toBeCloseTo(expected, precision) Expected: 0.4198884518664126 Received: 1 Expected precision: 6 Expected difference: < 0.0000005 Received difference: 0.5801115481335875 ``` # 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.5 - generic [ref=e13]: - button "문제 풀이 종료" [ref=e14] [cursor=pointer] - text: 소리에 집중하는 시간 - main [ref=e16]: - region "단어 찾기 게임" [ref=e17]: - generic [ref=e18]: - generic [ref=e19]: 나의 소리 탐험 - generic [ref=e20]: 문제 01 / 3 - progressbar "현재 문항 진행률" [ref=e21] - generic [ref=e22]: - complementary [ref=e23]: - paragraph [ref=e24]: 지금 듣고 있는 공간 - heading "길거리" [level=2] [ref=e25] - paragraph [ref=e26]: 지나가는 차와 도시의 웅성임 - generic [ref=e33]: S - generic [ref=e70]: 1단계 · SNR -12 dB - paragraph [ref=e71]: 소리에 익숙해지기 - generic [ref=e72]: - generic [ref=e73]: 목표 음성 SNR - slider "목표 음성 SNR" [ref=e74] [cursor=pointer]: "-12" - status [ref=e75]: SNR -12 dB - paragraph [ref=e76]: 음성이 기준보다 12 dB 작아요. 범위 −12~+12 dB · 1 dB 간격. 배경 크기는 일정해요. 양수는 음성을 더 크게, 0은 기준 크기로, 음수는 더 작게 들려줘요. 변경은 다음 재생·재청취부터 적용돼요. - generic [ref=e77]: - generic [ref=e78]: ◖)) 전체 음량 - slider "전체 음량" [ref=e79] [cursor=pointer]: "100" - status [ref=e80]: 100% - generic [ref=e81]: - generic [ref=e82]: - text: 점수 - generic [ref=e83]: "0" - generic [ref=e84]: - text: 연속 정답 - generic [ref=e85]: "0" - generic [ref=e86]: - generic [ref=e87]: - generic [ref=e88]: 두 번의 힌트 - generic [ref=e89]: 객관식 - generic [ref=e90]: - generic [aria-hidden] [ref=e91]: ♬ - heading "소리 사이에 집중해 보세요." [level=2] [ref=e92] - paragraph [ref=e93]: 문장이 끝나면 질문이 나타나요.작은 단어 하나를 기억해 주세요. - status [ref=e94]: 에스의 목소리를 듣고 있어요… - generic [ref=e95]: - button "듣는 중…" [disabled] [ref=e96] - button "듣기 중단" [ref=e97] [cursor=pointer] - generic [ref=e98]: 다시 들어도 점수는 줄어들지 않아요. - contentinfo [ref=e99]: - paragraph [ref=e100]: Designed by OZ at Heat-T Medical Inc. - paragraph [ref=e101]: 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. - generic [ref=e102]: 개인정보 수집 없이, 오직 플레이만. - link "환경음 출처·라이선스" [ref=e103] [cursor=pointer]: - /url: /ambience/AMBIENCE_NOTICES.md ``` # Test source ```ts 19 | }[]; 20 | }; 21 | } 22 | } 23 | test.beforeEach(async ({ page }) => { 24 | await page.addInitScript(() => { 25 | window.mixerProbe = { sources: [] }; 26 | const edges = new WeakMap(); 27 | const settings = new WeakMap(); 28 | const connect = AudioNode.prototype.connect; 29 | const set = AudioParam.prototype.setValueAtTime; 30 | const start = AudioBufferSourceNode.prototype.start; 31 | AudioNode.prototype.connect = function ( 32 | this: AudioNode, 33 | destination: AudioNode, 34 | output?: number, 35 | input?: number, 36 | ) { 37 | const result = Reflect.apply(connect, this, [ 38 | destination, 39 | output ?? 0, 40 | input ?? 0, 41 | ]); 42 | edges.set(this, destination); 43 | return result; 44 | } as typeof connect; 45 | AudioParam.prototype.setValueAtTime = function (value, time) { 46 | const result = set.call(this, value, time); 47 | if (value > 0) settings.set(this, value); 48 | return result; 49 | }; 50 | AudioBufferSourceNode.prototype.start = function (...args) { 51 | const gain = edges.get(this) as GainNode; 52 | const master = edges.get(gain) as GainNode; 53 | const data = this.buffer!.getChannelData(0); 54 | const record = { 55 | loop: this.loop, 56 | duration: this.buffer!.duration, 57 | rate: this.buffer!.sampleRate, 58 | peak: data.reduce((max, x) => Math.max(max, Math.abs(x)), 0), 59 | rms: Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length), 60 | scheduled: settings.get(gain.gain)!, 61 | master: master.gain.value, 62 | observed: undefined as number | undefined, 63 | }; 64 | window.mixerProbe.sources.push(record); 65 | if (this.loop) 66 | setTimeout(() => { 67 | record.observed = gain.gain.value; 68 | }, 350); 69 | return start.apply(this, args); 70 | }; 71 | }); 72 | }); 73 | 74 | test("실제 GainNode의 배경 배율은 8환경 × SNR 경계·0에서 동일하다", async ({ 75 | page, 76 | browser, 77 | }, info) => { 78 | const errors: string[] = [], 79 | failed: string[] = [], 80 | external: string[] = []; 81 | page.on("pageerror", (e) => errors.push(e.message)); 82 | page.on("console", (m) => { 83 | if (m.type() === "error") errors.push(m.text()); 84 | }); 85 | page.on("requestfailed", (r) => failed.push(r.url())); 86 | page.on("request", (r) => { 87 | if (new URL(r.url()).origin !== "http://127.0.0.1:4173") 88 | external.push(r.url()); 89 | }); 90 | await page.goto("/"); 91 | // master=1에서도 같은 안전 상한을 만족하는지 확인한다. 92 | await page.getByRole("slider", { name: "전체 음량" }).fill("100"); 93 | const rows = []; 94 | for (const environment of ENVIRONMENTS) 95 | for (const db of [-12, 0, 12]) { 96 | await page.getByLabel("배경 환경").selectOption(environment.id); 97 | await page 98 | .getByRole("slider", { name: "목표 음성 SNR" }) 99 | .fill(String(db)); 100 | await page.evaluate(() => { 101 | window.mixerProbe.sources = []; 102 | }); 103 | await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); 104 | await expect 105 | .poll(() => 106 | page.evaluate( 107 | () => 108 | window.mixerProbe.sources.filter((s) => s.observed !== undefined) 109 | .length, 110 | ), 111 | ) 112 | .toBe(1); 113 | const sources = await page.evaluate(() => window.mixerProbe.sources); 114 | expect(sources).toHaveLength(3); 115 | const [background, intro, voice] = sources; 116 | expect(background!.loop).toBe(true); 117 | expect(background!.duration).toBe(12); 118 | expect(background!.scheduled).toBe(FIXED_HEADROOM_GAIN); > 119 | expect(background!.observed).toBeCloseTo(FIXED_HEADROOM_GAIN, 6); | ^ Error: expect(received).toBeCloseTo(expected, precision) 120 | expect(voice!.scheduled / background!.scheduled).toBeCloseTo( 121 | 10 ** (db / 20), 122 | 10, 123 | ); 124 | expect(intro!.scheduled).toBeCloseTo(FIXED_HEADROOM_GAIN * 1.8, 10); 125 | expect(voice!.rms).toBeCloseTo(0.03, 6); 126 | expect(intro!.rms).toBeCloseTo(0.03, 6); 127 | for (const [kind, source] of [ 128 | ["background", background], 129 | ["voice", voice], 130 | ["intro", intro], 131 | ] as const) { 132 | expect(source!.peak).toBeLessThanOrEqual(PEAK_BOUNDS[kind]); 133 | expect(source!.master).toBe(1); 134 | } 135 | rows.push({ environment: environment.id, db, sources }); 136 | await page.getByRole("button", { name: "문제 풀이 종료" }).click(); 137 | await page.getByRole("button", { name: "처음 화면으로" }).click(); 138 | } 139 | // 같은 PCM의 RMS도 모든 SNR에서 보존된다. 배경 재정규화나 gain 보상이 없어야 한다. 140 | for (const environment of ENVIRONMENTS) { 141 | const rms = rows 142 | .filter((r) => r.environment === environment.id) 143 | .map((r) => r.sources[0]!.rms); 144 | expect(new Set(rms).size).toBe(1); 145 | } 146 | expect({ errors, failed, external }).toEqual({ 147 | errors: [], 148 | failed: [], 149 | external: [], 150 | }); 151 | await info.attach("actual-gain-nodes", { 152 | body: JSON.stringify( 153 | { 154 | chrome: browser.version(), 155 | fixedGain: FIXED_HEADROOM_GAIN, 156 | rows, 157 | errors, 158 | failed, 159 | external, 160 | }, 161 | null, 162 | 2, 163 | ), 164 | contentType: "application/json", 165 | }); 166 | }); 167 | 168 | // HTTP 200으로 반환되는 실제 WAV 한 sample만 바꾼다. decoder가 허용하는 손상도 hash가 차단해야 한다. 169 | test("실제 WAV 변조는 소스 시작 전에 거부하고 원본 재시도로 회복한다", async ({ 170 | page, 171 | }) => { 172 | await page.route(/\/ambience\/.*\.wav/, async (route) => { 173 | const response = await route.fetch(); 174 | const body = await response.body(); 175 | body[100] = body[100]! ^ 1; 176 | await route.fulfill({ response, body }); 177 | }); 178 | await page.goto("/"); 179 | await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); 180 | await expect(page.getByRole("alert")).toContainText( 181 | "소리를 재생하지 못했어요", 182 | ); 183 | expect(await page.evaluate(() => window.mixerProbe.sources)).toEqual([]); 184 | await page.unroute(/\/ambience\/.*\.wav/); 185 | await page.getByRole("button", { name: "다시 시도" }).click(); 186 | await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({ 187 | timeout: 30000, 188 | }); 189 | }); 190 | ```