import { act, fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import App from "../src/App"; import type { AudioPort } from "../src/audio/engine"; import { createQuestions } from "../src/game"; // 사용자가 보는 의미 역할로만 화면을 조작한다. 오디오 완료를 수동 제어하여 // 재생 중에는 문제·선택지가 노출되지 않는지, 오류 뒤 재시도가 가능한지 본다. function fixture() { let resolve!: () => void; let reject!: (reason: Error) => void; const audio: AudioPort = { play: vi.fn((_q, _intro, started) => { started(); return new Promise((yes, no) => { resolve = yes; reject = no; }); }), stop: vi.fn(), dispose: vi.fn(), setVolume: vi.fn(), }; const user = userEvent.setup(); const view = render(); return { audio, user, ...view, finish: async () => { await act(async () => resolve()); }, fail: async () => { await act(async () => reject(new Error("파일 로드 실패"))); }, }; } describe("시작부터 결과까지 사용자 흐름", () => { it("시작 안내는 장식 번호 대신 8가지 환경을 표시한다", () => { fixture(); expect(screen.getByText("8가지 환경", { exact: true })).toBeInTheDocument(); expect(screen.queryByText("01 / 08")).not.toBeInTheDocument(); }); it("시작 안내의 현재 페이지 링크는 아래 화살표를 표시한다", () => { fixture(); const link = screen.getByRole("link", { name: "플레이 방법" }); expect(link).toHaveAttribute("href", "#howto"); expect(link).toHaveTextContent("플레이 방법 ↓"); expect(within(link).getByText("↓")).toHaveAttribute("aria-hidden", "true"); }); it("시작 안내의 CTA는 게임 시작과 소리 재생을 함께 알린다", async () => { const f = fixture(); const start = screen.getByRole("button", { name: "게임 시작 · 소리 듣기", }); expect(start).toHaveTextContent("게임 시작 · 소리 듣기"); expect(f.audio.play).not.toHaveBeenCalled(); await f.user.click(start); expect(f.audio.play).toHaveBeenCalledTimes(1); await f.finish(); }); it("전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다", async () => { const f = fixture(); const setup = screen.getByRole("region", { name: "나의 소리 탐험 준비", }); const volume = screen.getByRole("slider", { name: "전체 음량" }); const start = screen.getByRole("button", { name: "게임 시작 · 소리 듣기", }); expect.soft(setup).toContainElement(volume); // CSS 배치만 바꾸지 않고 키보드·스크린리더도 음량→시작 순서로 읽어야 한다. expect .soft( volume.compareDocumentPosition(start) & Node.DOCUMENT_POSITION_FOLLOWING, ) .not.toBe(0); fireEvent.change(volume, { target: { value: "20" } }); await f.user.click(start); const gameVolume = screen.getByRole("slider", { name: "전체 음량" }); expect.soft(screen.getByRole("complementary")).toContainElement(gameVolume); expect(screen.getAllByRole("slider", { name: "전체 음량" })).toHaveLength( 1, ); expect(gameVolume).toHaveValue("20"); fireEvent.change(gameVolume, { target: { value: "60" } }); expect(f.audio.setVolume).toHaveBeenLastCalledWith(0.6); await f.finish(); await f.user.click(screen.getByRole("button", { name: "처음으로" })); expect(screen.getByRole("slider", { name: "전체 음량" })).toHaveValue("60"); }); it("다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다", async () => { const f = fixture(); const questions = createQuestions(42, "choice"); // 양수·0·음수를 각각 명시해 부호만 바꾸고 설명은 그대로인 회귀도 잡는다. const levels = [ ["SNR +6 dB", "음성이 배경보다 6 dB 커요."], ["SNR +3 dB", "음성이 배경보다 3 dB 커요."], ["SNR 0 dB", "음성과 배경의 크기가 같아요."], ["SNR -3 dB", "음성이 배경보다 3 dB 작아요."], ["SNR -6 dB", "음성이 배경보다 6 dB 작아요."], ] as const; await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); for (const [index, question] of questions.entries()) { if (index) await f.user.click(screen.getByRole("button", { name: /문장 듣기/ })); await f.finish(); const [label, description] = levels[Math.floor(index / 2)]!; expect .soft(screen.queryByText(`${question.level}단계 · ${label}`)) .toBeInTheDocument(); expect .soft(screen.queryByText(description, { exact: false })) .toBeInTheDocument(); await f.user.click( screen.getByRole("button", { name: new RegExp(question.word.answer), }), ); await f.user.click( screen.getByRole("button", { name: index === 9 ? /결과 보기/ : /다음 문제/, }), ); } const table = within(screen.getByRole("table", { name: "난이도별 결과" })); expect .soft(table.queryByRole("columnheader", { name: "SNR" })) .toBeInTheDocument(); for (const [label] of levels) { expect .soft(table.queryByRole("cell", { name: label })) .toBeInTheDocument(); } }); it("한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다", async () => { const f = fixture(); await f.user.click(screen.getByRole("radio", { name: "직접 입력" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.finish(); fireEvent.compositionStart( screen.getByRole("textbox", { name: "들은 단어" }), ); await f.user.click(screen.getByRole("button", { name: "처음으로" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.finish(); fireEvent.compositionStart( screen.getByRole("textbox", { name: "들은 단어" }), ); await f.user.click(screen.getByRole("button", { name: /다시 듣기/ })); await f.finish(); await f.user.type( screen.getByRole("textbox", { name: "들은 단어" }), createQuestions(43, "text")[0]!.word.answer, ); await f.user.click(screen.getByRole("button", { name: "정답 확인" })); expect(screen.getByText("정답이에요!")).toBeInTheDocument(); }); it("한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다", async () => { const f = fixture(); await f.user.click(screen.getByRole("radio", { name: "직접 입력" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.finish(); const textbox = screen.getByRole("textbox", { name: "들은 단어" }); fireEvent.compositionStart(textbox); fireEvent.change(textbox, { target: { value: createQuestions(42, "text")[0]!.word.answer }, }); fireEvent.submit(textbox.closest("form")!); expect(screen.queryByText("정답이에요!")).not.toBeInTheDocument(); fireEvent.compositionEnd(textbox); fireEvent.submit(textbox.closest("form")!); expect(screen.getByText("정답이에요!")).toBeInTheDocument(); }); it("브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다", () => { const f = fixture(); expect( screen.getByRole("heading", { name: "단어를 맞춰라" }), ).toBeInTheDocument(); expect( screen.getByText("Designed by OZ at Heat-T Medical Inc.", { exact: true, }), ).toBeInTheDocument(); expect(screen.getByText("v0.1")).toBeInTheDocument(); expect(f.audio.play).not.toHaveBeenCalled(); expect( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeEnabled(); }); it("재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); expect(f.audio.play).toHaveBeenCalledTimes(1); expect( screen.queryByRole("group", { name: "답 선택" }), ).not.toBeInTheDocument(); expect( screen.queryByText(createQuestions(42, "mixed")[0]!.sentence), ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /듣는 중/ })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /듣는 중/ })); expect(f.audio.play).toHaveBeenCalledTimes(1); await f.finish(); const group = screen.getByRole("group", { name: "답 선택" }); expect(group.querySelectorAll("button")).toHaveLength(4); expect( screen.getByRole("heading", { name: createQuestions(42, "mixed")[0]!.pattern.question, }), ).toHaveFocus(); await f.user.click(screen.getByRole("button", { name: /다시 듣기/ })); expect(f.audio.play).toHaveBeenCalledTimes(2); await f.finish(); }); it("혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다", async () => { const f = fixture(); const questions = createQuestions(42, "mixed"); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); for (let i = 0; i < questions.length; i++) { if (i) await f.user.click(screen.getByRole("button", { name: /문장 듣기/ })); await f.finish(); const q = questions[i]!; if (q.mode === "choice") { screen.getByRole("button", { name: new RegExp(q.word.answer) }).focus(); await f.user.keyboard("{Enter}"); } else { await f.user.type( screen.getByRole("textbox", { name: "들은 단어" }), ` ${q.word.answer} `, ); await f.user.keyboard("{Enter}"); } expect(screen.getByText("정답이에요!")).toBeInTheDocument(); expect(screen.getByText(q.sentence)).toBeInTheDocument(); await f.user.click( screen.getByRole("button", { name: i === 9 ? /결과 보기/ : /다음 문제/, }), ); } expect( screen.getByRole("heading", { name: /오늘의 소리 탐험/ }), ).toBeInTheDocument(); expect(screen.getByText("100%")).toBeInTheDocument(); expect( screen.getByRole("table", { name: "난이도별 결과" }), ).toBeInTheDocument(); await f.user.click(screen.getByRole("button", { name: "다시 시작" })); expect( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeEnabled(); expect(f.audio.stop).toHaveBeenCalled(); }); it("오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.fail(); expect(screen.getByRole("alert")).toHaveTextContent( /소리를 재생하지 못했어요/, ); await f.user.click(screen.getByRole("button", { name: "다시 시도" })); expect(f.audio.play).toHaveBeenCalledTimes(2); await f.finish(); f.unmount(); expect(f.audio.dispose).toHaveBeenCalled(); }); it("선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다", async () => { const f = fixture(); await f.user.click(screen.getByRole("radio", { name: /직접 입력/ })); await f.user.selectOptions(screen.getByLabelText("배경 환경"), "rain"); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.finish(); expect(f.audio.play).toHaveBeenCalledWith( expect.objectContaining({ mode: "text", environment: expect.objectContaining({ id: "rain" }), }), true, expect.any(Function), ); expect(screen.getByRole("button", { name: "정답 확인" })).toBeDisabled(); await f.user.type( screen.getByRole("textbox", { name: "들은 단어" }), "", ); await f.user.click(screen.getByRole("button", { name: "정답 확인" })); expect(screen.getByText("다음엔 찾을 수 있어요.")).toBeInTheDocument(); expect(document.querySelector("img")).toBeNull(); }); it("음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다", async () => { const f = fixture(); fireEvent.change(screen.getByRole("slider", { name: "전체 음량" }), { target: { value: "20" }, }); expect(f.audio.setVolume).toHaveBeenCalledWith(0.2); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.user.click(screen.getByRole("button", { name: "처음으로" })); await f.finish(); await waitFor(() => expect( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeEnabled(), ); }); });