265 lines
11 KiB
Markdown
265 lines
11 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: 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 |
|
|||
|
|
```
|