feat: release Find-Word v0.1
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { WORDS } from "../../src/content";
|
||||
|
||||
// 실제 WAV를 디코드하고 Web Audio의 ended를 기다린다. 모의 엔진을 사용한
|
||||
// 컴포넌트 검증과 달리 배포 산출물·자동 재생 정책·레이아웃을 함께 확인한다.
|
||||
test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const external: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let audioPath = "";
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().startsWith("http://127.0.0.1:4173"))
|
||||
external.push(request.url());
|
||||
if (
|
||||
request.url().endsWith(".wav") &&
|
||||
!request.url().endsWith("intro.wav")
|
||||
)
|
||||
audioPath = new URL(request.url()).pathname;
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "단어를 맞춰라" }),
|
||||
).toBeVisible();
|
||||
expect(audioPath).toBe("");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-home.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /듣는 중/ }),
|
||||
).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
"animation-name",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible();
|
||||
for (let i = 0; i < 10; 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(
|
||||
await heading.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const answer = WORDS.find((w) =>
|
||||
audioPath.endsWith(`-${w.id}.wav`),
|
||||
)!.answer;
|
||||
if (i === 0) {
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-game.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect(
|
||||
(await new AxeBuilder({ page }).analyze()).violations,
|
||||
).toEqual([]);
|
||||
await page.getByRole("button", { name: /다시 듣기/ }).click();
|
||||
await expect(target).toBeVisible();
|
||||
}
|
||||
if (i % 2) {
|
||||
await target.fill(answer);
|
||||
await target.press("Enter");
|
||||
} else {
|
||||
const button = page.getByRole("button", {
|
||||
name: new RegExp(answer),
|
||||
});
|
||||
await button.focus();
|
||||
await button.press("Enter");
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "정답이에요!" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: i === 9 ? /결과 보기/ : /다음 문제/ })
|
||||
.click();
|
||||
}
|
||||
await expect(page.getByText("100%")).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-results.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
await page.getByRole("button", { name: "다시 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("오디오 HTTP 실패를 화면에 표시하고 재시도한다", async ({ page }) => {
|
||||
await page.route("**/audio/*.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("**/audio/*.wav");
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
// 재시도는 안내부터 재생한다. 최대 8.1205 + 10.1725 + 시작·전환 0.4초에
|
||||
// 로드·디코드·병렬 브라우저 스케줄링 여유를 더해 이 assertion만 30초로 제한한다.
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "처음으로" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// 소스의 사본만 확인하면 public 파일이 배포에서 빠진 경우를 놓친다.
|
||||
// 실제 production preview가 원문 크레딧과 notice 전문을 제공하는지 확인한다.
|
||||
test("릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect
|
||||
.soft(
|
||||
page.getByText("Designed by OZ at Heat-T Medical Inc.", { exact: true }),
|
||||
)
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
const response = await request.get("/THIRD_PARTY_NOTICES.md");
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.text()).toBe(
|
||||
readFileSync("THIRD_PARTY_NOTICES.md", "utf8"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user