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(".db-note")) .toBeVisible({ timeout: 1000 }); await expect .soft(page.getByText("1단계 · SNR +6 dB")) .toBeVisible({ timeout: 1000 }); await expect .soft(page.locator(".db-note")) .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: `.agent/evidence/release-${info.project.name}-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); });