Files

265 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<AudioNode, AudioNode>();
27 | const settings = new WeakMap<AudioParam, number>();
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 |
```