import { expect, test, type Locator } from "@playwright/test"; import { createQuestions } from "../../src/game"; test("전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다", async ({ page, }) => { await page.goto("/"); const volume = page.getByRole("slider", { name: "전체 음량" }); // 문서상 소속뿐 아니라 컨트롤 전체가 카드 경계 안에 그려지는지 검증한다. const expectInside = async (panel: Locator) => { const bounds = (await panel.boundingBox())!; const control = (await page.locator(".volume-row").boundingBox())!; expect.soft(control.x).toBeGreaterThanOrEqual(bounds.x); expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); expect .soft(control.x + control.width) .toBeLessThanOrEqual(bounds.x + bounds.width); expect .soft(control.y + control.height) .toBeLessThanOrEqual(bounds.y + bounds.height); }; await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); const start = page.getByRole("button", { name: "게임 시작 · 소리 듣기", }); const control = (await page.locator(".volume-row").boundingBox())!; expect .soft(control.y + control.height) .toBeLessThanOrEqual((await start.boundingBox())!.y); await volume.focus(); await volume.press("ArrowRight"); await expect(volume).toHaveValue("36"); await volume.press("Tab"); await expect(start).toBeFocused(); await start.click(); await expectInside(page.getByRole("complementary")); await expect(volume).toHaveValue("36"); await volume.focus(); await volume.press("ArrowLeft"); await expect(volume).toHaveValue("35"); // 음량을 패널로 옮겨도 카드와 바닥글이 맞붙지 않도록 기존 화면 구분을 유지한다. const cards = (await page.locator(".game-columns").boundingBox())!; const footer = (await page.getByRole("contentinfo").boundingBox())!; expect(footer.y - (cards.y + cards.height)).toBeGreaterThanOrEqual(24); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); }); test("난이도 설명은 모바일에서도 보인다", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); // DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로 // 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다. 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("#snr-help")) .toContainText("음성이 기준보다 6 dB 커요.", { timeout: 1000 }); }); // jsdom은 줄바꿈을 계산하지 않는다. 실제 Chrome에서 한국어 제목의 // computed style을 확인하고 360px에서도 질문과 페이지가 넘치지 않게 한다. test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async ({ page, }, info) => { await page.addInitScript(() => { Date.now = () => 42; }); await page.goto("/"); const wordBreaks = () => page.getByRole("heading").evaluateAll((headings) => headings.map((heading) => ({ title: heading.textContent, wordBreak: getComputedStyle(heading).wordBreak, })), ); const homeHeadings = await wordBreaks(); expect.soft(homeHeadings).toEqual( homeHeadings.map((heading) => ({ ...heading, wordBreak: "keep-all", })), ); await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click(); const question = page.getByRole("heading", { name: createQuestions(42, "mixed")[0]!.pattern.question, }); await expect(question).toBeVisible(); // 전체 화면 캡처 전에 스크롤을 맞춰 화면 밖의 fixed 건너뛰기 링크가 // 이미지 안으로 들어오는 Chrome 캡처 아티팩트를 방지한다. 초점은 유지한다. await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: info.outputPath("heading.png"), fullPage: true, }); const gameHeadings = await wordBreaks(); expect.soft(gameHeadings).toEqual( gameHeadings.map((heading) => ({ ...heading, wordBreak: "keep-all", })), ); expect( await question.evaluate( (heading) => heading.scrollWidth <= heading.clientWidth, ), ).toBe(true); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).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); });