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(startImmediately = true) { let resolve!: () => void; let reject!: (reason: Error) => void; const audio: AudioPort = { play: vi.fn((_q, _intro, started) => { if (startImmediately) 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: "문제 풀이 종료" })); 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[0]!; 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 === questions.length - 1 ? /결과 보기/ : /다음 문제/, }), ); } 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.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.2.5")).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 === questions.length - 1 ? /결과 보기/ : /다음 문제/, }), ); } expect( screen.getByRole("heading", { name: /1단계 문제 풀이를 마쳤어요/ }), ).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.user.click(screen.getByRole("button", { name: "처음 화면으로" })); await f.finish(); await waitFor(() => expect( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeEnabled(), ); }); }); // 새 라운드의 시작 단계와 수동 SNR은 서로 독립적이다. 다음 단계에서도 명시적 // SNR은 유지하지만 홈으로 돌아온 뒤에는 그 단계의 기본값으로 초기화한다. it("시작 단계·SNR 선택, 재청취, 남은 문제와 다음 단계 라운드에 설정을 적용한다", async () => { const f = fixture(); await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" })); await f.user.selectOptions(screen.getByLabelText("시작 난이도"), "4"); expect(screen.getByRole("slider", { name: "목표 음성 SNR" })).toHaveValue( "-3", ); fireEvent.change(screen.getByRole("slider", { name: "목표 음성 SNR" }), { target: { value: "-8" }, }); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); expect(f.audio.play).toHaveBeenLastCalledWith( expect.objectContaining({ level: 4, db: -8 }), true, expect.any(Function), ); fireEvent.change(screen.getByRole("slider", { name: "목표 음성 SNR" }), { target: { value: "2" }, }); await f.finish(); await f.user.click(screen.getByRole("button", { name: /다시 듣기/ })); expect(f.audio.play).toHaveBeenLastCalledWith( expect.objectContaining({ db: 2 }), false, expect.any(Function), ); await f.finish(); for (let i = 0; i < 3; i++) { if (i) { await f.user.click(screen.getByRole("button", { name: /문장 듣기/ })); await f.finish(); } const question = vi.mocked(f.audio.play).mock.calls.at(-1)![0]; expect(question).toMatchObject({ level: 4, db: 2 }); await f.user.click( screen.getByRole("button", { name: new RegExp(question.word.answer) }), ); await f.user.click( screen.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ }), ); } expect(screen.getByText("정답률 · 3/3")).toBeInTheDocument(); await f.user.click( screen.getByRole("button", { name: "다음 난이도 · 5단계" }), ); expect(f.audio.play).toHaveBeenLastCalledWith( expect.objectContaining({ level: 5, db: 2 }), true, expect.any(Function), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect(screen.getByText("응답 0문항 · 정답 0문항")).toBeInTheDocument(); await f.user.click(screen.getByRole("button", { name: "처음 화면으로" })); expect(screen.getByRole("slider", { name: "목표 음성 SNR" })).toHaveValue( "-3", ); await f.finish(); }); // loading과 playing에서 취소할 때 Promise가 늦게 완료되어도 결과/홈을 지킨다. it.each([false, true])( "오디오 started=%s 중 종료는 즉시 정지하고 결과를 거쳐 홈으로 간다", async (started) => { const f = fixture(started); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect(f.audio.stop).toHaveBeenCalledOnce(); expect( screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }), ).toHaveFocus(); expect(screen.getByText("응답 0문항 · 정답 0문항")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /다음 난이도/ }), ).not.toBeInTheDocument(); await f.finish(); expect( screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }), ).toBeInTheDocument(); await f.user.click(screen.getByRole("button", { name: "처음 화면으로" })); expect( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ).toBeEnabled(); }, ); it("답변 피드백에서 종료하면 답한 한 문항만 집계한다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.finish(); await f.user.click( screen.getByRole("button", { name: new RegExp(createQuestions(42, "mixed")[0]!.word.answer), }), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect(screen.getByText("응답 1문항 · 정답 1문항")).toBeInTheDocument(); expect(screen.getByText("정답률 · 1/1")).toBeInTheDocument(); }); it("5단계 완료는 6단계 대신 같은 단계의 새 세 문항을 시작한다", async () => { const f = fixture(); await f.user.selectOptions(screen.getByLabelText("시작 난이도"), "5"); await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); for (let i = 0; i < 3; i++) { if (i) await f.user.click(screen.getByRole("button", { name: /문장 듣기/ })); await f.finish(); const q = vi.mocked(f.audio.play).mock.calls.at(-1)![0]; await f.user.click( screen.getByRole("button", { name: new RegExp(q.word.answer) }), ); await f.user.click( screen.getByRole("button", { name: i === 2 ? /결과 보기/ : /다음 문제/ }), ); } expect( screen.queryByRole("button", { name: /다음 난이도/ }), ).not.toBeInTheDocument(); await f.user.click(screen.getByRole("button", { name: "5단계 다시 도전" })); expect(f.audio.play).toHaveBeenLastCalledWith( expect.objectContaining({ level: 5, db: -6 }), true, expect.any(Function), ); expect(screen.getByRole("progressbar")).toHaveAttribute("max", "3"); expect(screen.getByRole("progressbar")).toHaveAttribute("value", "1"); await f.finish(); }); it("환경음 저작자·라이선스 고지를 같은 출처 링크로 제공한다", () => { fixture(); expect( screen.getByRole("link", { name: "환경음 출처·라이선스" }), ).toHaveAttribute("href", "/ambience/AMBIENCE_NOTICES.md"); }); it("결과 헤더에 존재하지 않는 플레이 방법 앵커를 표시하지 않는다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect( screen.queryByRole("link", { name: "플레이 방법" }), ).not.toBeInTheDocument(); await f.finish(); }); it("세 문항 라운드에서 가능한 연속 정답 보너스 상한을 설명한다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect( screen.getByText( "정답마다 단계 × 100점 + 연속 정답 보너스 (문항별 최대 20점, 라운드 합계 최대 30점)", ), ).toBeInTheDocument(); await f.finish(); }); // 답변 전과 피드백 중에도 현재 문제 위치를 표시한다. 완주 점수와 안내를 함께 확인한다. it("현재 문항 진행률은 1/3→2/3→3/3이고 1단계 만점은 330점이다", async () => { const f = fixture(); await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" })); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); for (let index = 0; index < 3; index++) { const bar = screen.getByRole("progressbar", { name: "현재 문항 진행률" }); expect(bar).toHaveAttribute("max", "3"); expect(bar).toHaveAttribute("value", String(index + 1)); expect(bar).toHaveAttribute("aria-valuetext", `${index + 1}/3문항`); if (index) await f.user.click(screen.getByRole("button", { name: /문장 듣기/ })); await f.finish(); const q = vi.mocked(f.audio.play).mock.calls.at(-1)![0]; await f.user.click( screen.getByRole("button", { name: new RegExp(q.word.answer) }), ); expect(bar).toHaveAttribute("value", String(index + 1)); await f.user.click( screen.getByRole("button", { name: index === 2 ? /결과 보기/ : /다음 문제/, }), ); } expect( screen.getByRole("heading", { name: "1단계 문제 풀이를 마쳤어요." }), ).toBeInTheDocument(); expect(screen.getByText("330")).toBeInTheDocument(); expect( screen.getByText(/문항별 최대 20점, 라운드 합계 최대 30점/), ).toBeInTheDocument(); }); it("무응답 종료는 정답률 대신 미응답 상태와 단계별 미진행을 보여 준다", async () => { const f = fixture(); await f.user.click( screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }), ); await f.user.click(screen.getByRole("button", { name: "문제 풀이 종료" })); expect( screen.getByRole("heading", { name: "문제 풀이를 중간에 종료했어요." }), ).toBeInTheDocument(); expect(screen.getByText("—")).toBeInTheDocument(); expect(screen.getByText("정답률 · 응답 없음")).toBeInTheDocument(); expect(screen.getAllByRole("cell", { name: "미진행" })).toHaveLength(5); expect(document.body.textContent).not.toMatch(/0%|0\s*\/\s*0/); await f.finish(); });