feat: release Find-Word v0.2.3

This commit is contained in:
oz
2026-10-14 10:01:46 +00:00
parent c92660b514
commit 812540c743
531 changed files with 103693 additions and 325 deletions
+75 -14
View File
@@ -4,44 +4,89 @@ import { createQuestions } from "../../src/game";
import { isAudioRequest } from "./audio-requests";
// 과거 evidence의 검증 스크립트는 보존하고, query URL 전수 검사를 정규 suite로 옮긴다.
test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
page,
browser,
}, info) => {
const external: string[] = [];
const errors: string[] = [];
const failed: string[] = [];
const requests: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
if (isAudioRequest(url)) requests.push(url.href);
if (isAudioRequest(url) || url.pathname.startsWith("/ambience/"))
requests.push(url.href);
});
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("requestfailed", (request) => failed.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.1.1");
await expect(page.locator(".version")).toHaveText("v0.1.1");
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.3");
await expect(page.locator(".version")).toHaveText("v0.2.3");
const plan = JSON.parse(
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
);
const published = await page.evaluate(async () => ({
manifest: await (await fetch("/ambience/manifest.json?v=0.2.3")).json(),
notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.3")).text(),
}));
expect(published.manifest).toEqual(
JSON.parse(readFileSync("public/ambience/manifest.json", "utf8")),
);
for (const asset of plan.assets) {
const start = asset.selectedSegmentStartSeconds;
expect(Number.isFinite(start)).toBe(true);
expect(start).toBeGreaterThanOrEqual(0);
expect(start + 12).toBeLessThanOrEqual(asset.directMediaDurationSeconds);
const entry = published.manifest.files.find(
(e: { environmentId: string }) => e.environmentId === asset.environmentId,
);
expect(entry.selectedSegmentStartSeconds).toBe(start);
expect(entry.conversion.startSeconds).toBe(start);
expect(entry.conversion.durationSeconds).toBe(12);
const section = published.notice
.split("## ")
.find((s: string) =>
s.startsWith(`${asset.displayName} — ${asset.environmentId}\n`),
);
expect(section).toContain(`source seconds ${start}–${start + 12}.`);
}
// intro는 테스트에서 URL을 재조립하지 않고 배포 앱의 실제 요청을 관찰한다.
const introRequest = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/audio/intro.wav",
);
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
const introUrl = (await introRequest).url();
await page.getByRole("button", { name: "처음으로" }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await page.getByRole("button", { name: "처음 화면으로" }).click();
const targetUrls = [
...new Set(
Array.from({ length: 200 }, (_, seed) =>
Array.from({ length: 1000 }, (_, seed) =>
createQuestions(seed, "mixed").map((question) => question.audioUrl),
).flat(),
),
];
expect(targetUrls).toHaveLength(100);
const ambience = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
const urls = [
introUrl,
...ambience.files.map(
(entry: { file: string }) =>
new URL(`/ambience/${entry.file}?v=0.2.3`, page.url()).href,
),
...targetUrls.map((url) => new URL(url, page.url()).href),
];
expect(
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
).toEqual(new Set(["0.1.1"]));
).toEqual(new Set(["0.2.3"]));
expect(
urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
).toBe(true);
@@ -51,9 +96,24 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
version: string;
files: { file: string; sha256: string; bytes: number; duration: number }[];
};
expect(manifest.version).toBe("0.1.1");
expect(manifest.version).toBe("0.2.3");
// 두 manifest의 파일명 충돌 없이 경로를 명시해 109개 응답을 독립 대조한다.
const entries = [
...manifest.files.map((entry) => ({
...entry,
path: `/audio/${entry.file}`,
})),
...ambience.files.map(
(entry: {
file: string;
sha256: string;
bytes: number;
duration: number;
}) => ({ ...entry, path: `/ambience/${entry.file}` }),
),
];
expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
manifest.files.map((entry) => `/audio/${entry.file}`).sort(),
entries.map((entry) => entry.path).sort(),
);
requests.length = 0;
const rows = await page.evaluate(
@@ -63,7 +123,7 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
for (const url of urls) {
// query를 파일명으로 취급하지 않는다. 원문 파일과 응답 해시를 독립 대조한다.
const entry = entries.find(
(entry) => `/audio/${entry.file}` === new URL(url).pathname,
(entry) => entry.path === new URL(url).pathname,
)!;
const response = await fetch(url);
if (!response.ok) throw Error(`${url}: HTTP ${response.status}`);
@@ -103,22 +163,23 @@ test("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코
}
return rows;
},
{ urls, entries: manifest.files },
{ urls, entries },
);
expect(rows).toHaveLength(101);
expect(rows).toHaveLength(109);
expect([...requests].sort()).toEqual([...urls].sort());
expect(external).toEqual([]);
expect(errors).toEqual([]);
expect(failed).toEqual([]);
const path = info.outputPath("browser-audio-integrity.json");
writeFileSync(
path,
JSON.stringify(
{ chrome: browser.version(), external, errors, files: rows },
{ chrome: browser.version(), external, errors, failed, files: rows },
null,
2,
) + "\n",
);
await info.attach("101 versioned WAV integrity", {
await info.attach("109 versioned WAV integrity", {
path,
contentType: "application/json",
});
+12 -11
View File
@@ -41,8 +41,8 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
await expect.poll(() => audioUrls.length).toBe(2);
expect(audioUrls.map((url) => url.searchParams.get("v"))).toEqual([
"0.1.1",
"0.1.1",
"0.2.3",
"0.2.3",
]);
await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
@@ -50,14 +50,14 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
"none",
);
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 3; i++) {
if (i) await page.getByRole("button", { name: /문장 듣기/ }).click();
const target =
i % 2
? page.getByRole("textbox", { name: "들은 단어" })
: page.getByRole("group", { name: "답 선택" });
await expect(target).toBeVisible();
// 열 가지 질문 모두 실제 렌더링을 거쳐 어절 보존과 패널 내 표시를 검증한다.
// 세 문항 모두 실제 렌더링을 거쳐 어절 보존과 패널 내 표시를 검증한다.
const heading = page.locator(".question-panel h2");
await expect(heading).toHaveCSS("word-break", "keep-all");
expect(
@@ -97,7 +97,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
page.getByRole("heading", { name: "정답이에요!" }),
).toBeVisible();
await page
.getByRole("button", { name: i === 9 ? /결과 보기/ : /다음 문제/ })
.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ })
.click();
}
await expect(page.getByText("100%")).toBeVisible();
@@ -112,16 +112,16 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
fullPage: true,
});
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
await page.getByRole("button", { name: "다시 시작" }).click();
await page.getByRole("button", { name: "처음 화면으로" }).click();
await expect(
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeVisible();
expect(new Set(audioUrls.map((url) => url.searchParams.get("v")))).toEqual(
new Set(["0.1.1"]),
new Set(["0.2.3"]),
);
expect(
audioUrls.filter((url) => url.pathname !== "/audio/intro.wav"),
).toHaveLength(11);
).toHaveLength(4);
expect(external).toEqual([]);
expect(errors).toEqual([]);
});
@@ -150,7 +150,7 @@ for (const failed of ["intro", "target"]) {
"소리를 재생하지 못했어요",
);
expect(missing).toHaveLength(1);
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.1.1");
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.2.3");
expect(audioUrls).toHaveLength(2);
await page.unroute(matchesFailure);
await page.getByRole("button", { name: "다시 시도" }).click();
@@ -162,7 +162,7 @@ for (const failed of ["intro", "target"]) {
expect(audioUrls.slice(2).sort()).toEqual(audioUrls.slice(0, 2).sort());
expect(
new Set(audioUrls.map((url) => new URL(url).searchParams.get("v"))),
).toEqual(new Set(["0.1.1"]));
).toEqual(new Set(["0.2.3"]));
const targetPath = audioUrls
.map((url) => new URL(url).pathname)
.find((path) => path !== "/audio/intro.wav")!;
@@ -173,7 +173,8 @@ for (const failed of ["intro", "target"]) {
await expect(
page.getByRole("heading", { name: "정답이에요!" }),
).toBeVisible();
await page.getByRole("button", { name: "처음으로" }).click();
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await page.getByRole("button", { name: "처음 화면으로" }).click();
await expect(
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
).toBeFocused();
+18
View File
@@ -19,3 +19,21 @@ test("릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공
readFileSync("THIRD_PARTY_NOTICES.md", "utf8"),
);
});
// Accept: text/html을 보내도 누락 오디오는 SPA 문서로 대체하지 않아야 한다.
test("누락 음성·환경음은 query와 HTML accept에도 404이고 출처 고지는 번들된다", async ({
request,
}) => {
for (const directory of ["audio", "ambience"]) {
const response = await request.get(`/${directory}/not-found.wav?v=0.2.3`, {
headers: { Accept: "text/html" },
});
expect(response.status()).toBe(404);
expect(await response.text()).not.toContain('<div id="root">');
}
const notice = await request.get("/ambience/AMBIENCE_NOTICES.md");
expect(notice.ok()).toBe(true);
expect(await notice.text()).toBe(
readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8"),
);
});
+135
View File
@@ -0,0 +1,135 @@
import { expect, test, type Page } from "@playwright/test";
import { WORDS } from "../../src/content";
// 글자별 스타일 추측 대신 실제 Range 줄 상자를 비교해 어절 내부 분리를 검출한다.
async function copyLayout(page: Page, selector: string) {
return page.locator(selector).evaluateAll((elements) =>
elements.flatMap((element) => {
const problems: string[] = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
for (const match of (node.textContent ?? "").matchAll(/[가-힣]+/g)) {
const range = document.createRange();
range.setStart(node, match.index!);
range.setEnd(node, match.index! + match[0].length);
if (new Set(Array.from(range.getClientRects(), (r) => r.y)).size > 1)
problems.push(`split: ${match[0]}`);
}
}
if (element.scrollWidth > element.clientWidth)
problems.push(`overflow: ${element.textContent}`);
return problems;
}),
);
}
test("리뷰: 한국어 모드·결과 어절은 320px와 360px에서도 갈라지거나 넘치지 않는다", async ({
page,
}, info) => {
for (const width of [320, 360]) {
await page.setViewportSize({ width, height: 800 });
await page.goto("/");
expect.soft(await copyLayout(page, ".mode-option small")).toEqual([]);
await page.screenshot({
path: info.outputPath(`copy-${width}-home.png`),
fullPage: true,
});
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
expect
.soft(
await copyLayout(
page,
".results-panel h2, .results-panel p, .stats span, .results-panel th, .results-panel td",
),
)
.toEqual([]);
expect
.soft(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
)
.toBe(true);
await page.screenshot({
path: info.outputPath(`copy-${width}-results.png`),
fullPage: true,
});
}
});
test("리뷰: 실제 진행 막대는 현재 문항의 1/3·2/3·3/3을 채운다", async ({
page,
}, info) => {
let target = "";
page.on("request", (r) => {
const path = new URL(r.url()).pathname;
if (path.startsWith("/audio/") && path !== "/audio/intro.wav")
target = path;
});
await page.goto("/");
await page.getByRole("radio", { name: "보기에서 선택" }).check();
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
for (let index = 0; index < 3; index++) {
const bar = page.getByRole("progressbar");
expect
.soft(await bar.evaluate((el) => (el as HTMLProgressElement).position))
.toBeCloseTo((index + 1) / 3);
await expect
.soft(bar)
.toHaveAccessibleName("현재 문항 진행률", { timeout: 1000 });
const png = await bar.screenshot({
path: info.outputPath(`progress-${index + 1}.png`),
});
// 실제 그려진 중앙 행의 녹색 채움 비율까지 측정한다. DOM value만 맞는 빈 막대는 실패한다.
const fill = await page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
new Blob([Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))], {
type: "image/png",
}),
);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d")!;
context.drawImage(bitmap, 0, 0);
const row = context.getImageData(
0,
Math.floor(canvas.height / 2),
canvas.width,
1,
).data;
let green = 0;
for (let x = 0; x < canvas.width; x++) {
if (
Math.abs(row[x * 4]! - 101) < 12 &&
Math.abs(row[x * 4 + 1]! - 132) < 12 &&
Math.abs(row[x * 4 + 2]! - 80) < 12
)
green++;
}
bitmap.close();
return { pixels: green, width: canvas.width };
}, png.toString("base64"));
expect
.soft(Math.abs(fill.pixels - (fill.width * (index + 1)) / 3))
.toBeLessThanOrEqual(2);
if (index) await page.getByRole("button", { name: /문장 듣기/ }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
const answer = WORDS.find((w) => target.endsWith(`-${w.id}.wav`))!.answer;
await page.getByRole("button", { name: new RegExp(answer) }).click();
await page
.getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/ })
.click();
}
await page.screenshot({
path: info.outputPath("normal-results.png"),
fullPage: true,
});
expect(
await copyLayout(page, ".results-panel h2, .results-panel p, .stats span"),
).toEqual([]);
});
+194
View File
@@ -0,0 +1,194 @@
import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { ENVIRONMENTS, WORDS } from "../../src/content";
import { isAudioRequest } from "./audio-requests";
// 실제 소스 시작/정지를 관찰하되 브라우저의 원래 Web Audio 메서드는 그대로 실행한다.
// 테스트가 ended를 조작하거나 가짜 음원을 주입하면 실제 재생 검증이 되지 않는다.
declare global {
interface Window {
audioProbe: {
starts: { loop: boolean; duration: number }[];
stops: number;
};
}
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.audioProbe = { starts: [], stops: 0 };
const start = AudioBufferSourceNode.prototype.start;
const stop = AudioBufferSourceNode.prototype.stop;
AudioBufferSourceNode.prototype.start = function (...args) {
window.audioProbe.starts.push({
loop: this.loop,
duration: this.buffer?.duration ?? 0,
});
return start.apply(this, args);
};
AudioBufferSourceNode.prototype.stop = function (...args) {
window.audioProbe.stops++;
return stop.apply(this, args);
};
});
});
test("시작 단계·SNR·3문항 완료→다음 단계·재생 중 종료·응답 후 종료·홈 초기화", async ({
page,
}, info) => {
const errors: string[] = [],
failed: string[] = [],
external: string[] = [];
let target = "";
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("requestfailed", (r) => failed.push(r.url()));
page.on("request", (r) => {
const url = new URL(r.url());
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
if (isAudioRequest(url) && url.pathname !== "/audio/intro.wav")
target = url.pathname;
});
await page.goto("/");
await page.getByRole("radio", { name: "보기에서 선택" }).check();
await page.getByLabel("시작 난이도").selectOption("4");
const snr = page.getByRole("slider", { name: "목표 음성 SNR" });
await expect(snr).toHaveValue("-3");
await snr.fill("-4");
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
for (let index = 0; index < 3; index++) {
if (index) await page.getByRole("button", { name: /문장 듣기/ }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
await expect(page.getByText("4단계 · SNR -4 dB")).toBeVisible();
const answer = WORDS.find((word) =>
target.endsWith(`-${word.id}.wav`),
)!.answer;
await page.getByRole("button", { name: new RegExp(answer) }).click();
await page
.getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/ })
.click();
}
await expect(page.getByText("응답 3문항 · 정답 3문항")).toBeVisible();
await page.getByRole("button", { name: "다음 난이도 · 5단계" }).click();
await expect(page.getByText("5단계 · SNR -4 dB")).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.audioProbe.starts.length))
.toBe(10);
// 예약 시작보다 조금 뒤 종료하여 fetch 취소뿐 아니라 실제 loop 정지를 검증한다.
await page.waitForTimeout(250);
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible();
expect(await page.evaluate(() => window.audioProbe.stops)).toBe(10);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: info.outputPath("early-exit.png"),
fullPage: true,
});
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
await page.getByRole("button", { name: "처음 화면으로" }).click();
await expect(snr).toHaveValue("-3");
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
await snr.fill("0");
await page.getByRole("button", { name: /다시 듣기/ }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
await expect(page.getByText("4단계 · SNR 0 dB")).toBeVisible();
await page
.getByRole("button", {
name: new RegExp(
WORDS.find((w) => target.endsWith(`-${w.id}.wav`))!.answer,
),
})
.click();
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await expect(page.getByText("응답 1문항 · 정답 1문항")).toBeVisible();
await expect(
page.getByRole("cell", { name: "SNR 0 dB" }).first(),
).toBeVisible();
await page.getByRole("button", { name: "처음 화면으로" }).click();
await expect(snr).toHaveValue("-3");
expect({ errors, failed, external }).toEqual({
errors: [],
failed: [],
external: [],
});
await info.attach("runtime-audit", {
body: JSON.stringify(
{
errors,
failed,
external,
audio: await page.evaluate(() => window.audioProbe),
},
null,
2,
),
contentType: "application/json",
});
});
test("여덟 환경의 실제 WAV를 각각 디코드하고 루프 재생한 뒤 즉시 정지한다", async ({
page,
}) => {
const requests: string[] = [],
errors: string[] = [],
failed: string[] = [],
external: string[] = [];
page.on("request", (r) => {
const url = new URL(r.url());
if (url.pathname.startsWith("/ambience/")) requests.push(url.pathname);
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
});
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => {
if (m.type() === "error") errors.push(m.text());
});
page.on("requestfailed", (r) => failed.push(r.url()));
await page.goto("/");
for (const [index, environment] of ENVIRONMENTS.entries()) {
await page.getByLabel("배경 환경").selectOption(environment.id);
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
await expect
.poll(() => page.evaluate(() => window.audioProbe.starts.length))
.toBe((index + 1) * 3);
await page.waitForTimeout(250);
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await expect(page.getByText("응답 0문항 · 정답 0문항")).toBeVisible();
await page.getByRole("button", { name: "처음 화면으로" }).click();
}
const probe = await page.evaluate(() => window.audioProbe);
expect(probe.starts.filter((s) => s.loop).map((s) => s.duration)).toEqual(
Array(8).fill(12),
);
expect(probe.stops).toBe(24);
expect(requests).toEqual(ENVIRONMENTS.map((e) => `/ambience/${e.id}.wav`));
expect({ errors, failed, external }).toEqual({
errors: [],
failed: [],
external: [],
});
});
// 장애 주입은 정상 흐름의 네트워크 무결성 집계와 분리하고 재시도 성공을 확인한다.
test("환경음 404는 한국어 오류와 재시도 후 정상 답변으로 복구한다", async ({
page,
}) => {
await page.route(/\/ambience\/.*\.wav/, (route) =>
route.fulfill({ status: 404, body: "missing" }),
);
await page.goto("/");
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
await expect(page.getByRole("alert")).toContainText(
"소리를 재생하지 못했어요",
);
await page.unroute(/\/ambience\/.*\.wav/);
await page.getByRole("button", { name: "다시 시도" }).click();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
});
+42 -2
View File
@@ -54,12 +54,12 @@ test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
// DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로
// 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다.
await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 });
await expect.soft(page.locator("#snr-help")).toBeVisible({ timeout: 1000 });
await expect
.soft(page.getByText("1단계 · SNR +6 dB"))
.toBeVisible({ timeout: 1000 });
await expect
.soft(page.locator(".db-note"))
.soft(page.locator("#snr-help"))
.toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 });
});
@@ -116,3 +116,43 @@ test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async
),
).toBe(true);
});
// 새 설정도 기존 카드와 같은 터치 크기를 갖고 360px에서 레이블과 함께 넘치지 않아야 한다.
test("시작 단계와 SNR 설정은 카드 안에 들어가고 터치 영역을 확보한다", async ({
page,
}) => {
await page.goto("/");
const panel = (await page
.getByRole("region", { name: "나의 소리 탐험 준비" })
.boundingBox())!;
for (const control of [
page.getByLabel("시작 난이도"),
page.getByRole("slider", { name: "목표 음성 SNR" }),
]) {
const bounds = (await control.boundingBox())!;
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(bounds.x).toBeGreaterThanOrEqual(panel.x);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(panel.x + panel.width);
}
// select의 상자만 들어가도 선택한 글자는 화살표 뒤에서 잘릴 수 있다.
const labelFits = await page.getByLabel("시작 난이도").evaluate((element) => {
const select = element as HTMLSelectElement;
const style = getComputedStyle(select);
const context = document.createElement("canvas").getContext("2d")!;
context.font = style.font;
const available =
select.clientWidth -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight) -
24;
return (
context.measureText(select.selectedOptions[0]!.text).width <= available
);
});
expect(labelFits).toBe(true);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});