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([]); });