diff --git a/.agent/ACCEPTANCE.md b/.agent/ACCEPTANCE.md new file mode 100644 index 0000000..675f45d --- /dev/null +++ b/.agent/ACCEPTANCE.md @@ -0,0 +1,44 @@ +# ACCEPTANCE — Find-Word v0.1 + +## 제품 + +- [ ] H1 또는 브랜드 영역에 `단어를 맞춰라` 표시 +- [ ] `Designed by OZ at Heat-T Medical Inc.` 표시 +- [ ] v0.1 표시 +- [ ] 게임 시작부터 결과·재시작까지 완결된 흐름 +- [ ] 객관식과 주관식 모두 실제 플레이 가능 +- [ ] 최소 10개 문제 유형이 코드·데이터·테스트로 확인됨 +- [ ] 최소 8개 배경 환경 프리셋 +- [ ] 최소 5개 dB 기반 난이도 +- [ ] 문제 조합이 고정 10문제를 넘어 확장 가능 +- [ ] 사전 단어 기반 오답과 변형 규칙 metadata + +## 오디오 + +- [ ] Web Audio API로 배경과 목표 음성 gain을 분리 +- [ ] 난이도별 상대 dB가 수학적으로 검증됨 +- [ ] 첫 재생은 사용자 gesture 이후 실행 +- [ ] 재생 중 중복 시작·다음 문제 전환 race 방지 +- [ ] 오디오 실패 UI와 재시도 +- [ ] provenance 문서와 생성 절차 +- [ ] 런타임 외부 TTS/API 호출 없음 + +## 품질 + +- [ ] strict TypeScript +- [ ] 직접 작성한 소스에 상세한 한국어 주석 +- [ ] unit/component 테스트에서 문제 유형 수, 선택지 무결성, 정답 판정, 주관식 정규화, 난이도 dB, 점수, 오디오 상태 검증 +- [ ] lint 통과 +- [ ] test 통과 +- [ ] production build 통과 +- [ ] 접근성 이름, 키보드 focus, reduced motion +- [ ] 360px 모바일과 1280px 데스크톱 레이아웃 +- [ ] secret scan 무검출 + +## 문서 + +- [ ] README에 개발·테스트·빌드 방법 +- [ ] ARCHITECTURE에 데이터·상태·오디오 설계 +- [ ] AUDIO_PROVENANCE에 생성·라이선스·재생 방식 +- [ ] DEPLOYMENT에 Nginx static 배포와 SPA fallback +- [ ] `.agent/RESULT.md`에 실제 RED/GREEN 및 최종 검증 기록 diff --git a/.agent/CONTEXT.md b/.agent/CONTEXT.md new file mode 100644 index 0000000..8d5600b --- /dev/null +++ b/.agent/CONTEXT.md @@ -0,0 +1,31 @@ +# CONTEXT — Find-Word v0.1 + +## 저장소와 책임 경계 + +- canonical repository: `https://source.ibit.kr/OZ-Group/Find-Word` +- 작업 브랜치: `feat/v0.1` +- 현재 worktree: `/home/hermes/worktrees/find-word-v0.1` +- Codex는 이 worktree만 수정한다. +- Neo가 최종 diff 검토, 테스트, source push, Heart 서버 배포, Nginx/TLS/DNS 검증을 담당한다. + +## 배포 예정 환경 + +- Ubuntu 24.04 +- Nginx 1.24 +- Node.js 22는 설치되어 있으나 프로덕션은 정적 `dist/`를 Nginx가 직접 제공하는 구성을 선호한다. +- 예정 도메인 `findword.heart-m.com`은 현재 Heart 서버 IP로 DNS 해석된다. +- 배포 파일·서비스·포트는 아직 존재하지 않는다. + +## 제품 해석 + +- “4개의 단어”를 우선한다. 정답 1개와 변형 규칙이 서로 다른 사전 단어 오답 3개를 표시한다. +- 사용자 지정 표기를 해석하거나 교정하지 않는다. UI·README·문서·테스트에는 정확히 `Designed by OZ at Heat-T Medical Inc.`를 사용한다. +- 전체 판매/의료 판단 서비스가 아니라 청각적 주의·식별 게임이다. 의료 효과·치료·진단을 주장하지 않는다. +- 오디오 난이도는 디지털 gain의 상대 dB로 계산하고 UI에 난이도와 상대 dB를 표시한다. + +## 보안·라이선스 + +- 외부 CDN·분석·런타임 API 의존을 두지 않는다. +- 외부 음원을 다운로드할 경우 명시적 라이선스와 원본 URL이 필요하다. 불확실하면 직접 합성한다. +- 앱은 사용자 입력을 HTML로 삽입하지 않는다. +- 결과는 localStorage에 선택적으로 저장할 수 있으나 개인정보는 저장하지 않는다. diff --git a/.agent/RESULT.md b/.agent/RESULT.md new file mode 100644 index 0000000..2629084 --- /dev/null +++ b/.agent/RESULT.md @@ -0,0 +1,410 @@ +# Find-Word v0.1 작업 결과 + +작성일: 2026-10-14 UTC. 작업 위치: `/home/hermes/worktrees/find-word-v0.1`. +입력 문서 `.agent/CONTEXT.md`, `TASK.md`, `ACCEPTANCE.md`를 모두 읽고 적용했다. +기존 입력 문서는 수정하지 않았다. + +## 구현 결과 + +- `단어를 맞춰라`, `Designed by OZ at Heat-T Medical Inc.`, `v0.1` 표시. +- 반응형 시작·게임·결과 화면, 10문제 완주, 재시작, 다시 듣기, 중단, 진행률. +- 객관식·주관식·혼합 모드, 10유형 × 10단어 = 100개 저장 문장. +- 4지선다의 고유한 정답 1개·사전 단어 오답 3개, 변형 규칙과 예상 혼동 난이도. +- 8개 합성 배경 환경, +6/+3/0/−3/−6 dB의 5단계 상대 음량. +- 사용자 gesture에서 AudioContext 시작, 음성·배경 gain 분리, 안내 fade, + 재생 중 경합 방지, 취소된 로드의 늦은 완료 무시, 오류와 재시도. +- 점수·정답률·연속 정답·단계별 결과, 한글 NFC/공백/문장부호 정규화, + 한글 조합 중 Enter·재시작·재청취의 입력 상태 보호. +- 키보드/터치, 질문·피드백·결과 focus, ARIA, reduced motion, 색 대비. +- TypeScript strict, 직접 작성 소스 22개 파일의 한국어 설계 설명. +- 직접 합성 WAV 101개(10,864,582바이트), 결정론적 배경 합성, + 생성 도구 실측 버전·원문·파일 해시 manifest. +- 런타임 외부 CDN/TTS/API/분석 요청 없음. 개인정보 저장·로그인·추적 없음. + +서버·DNS·인증정보에 접근하지 않았으며 **배포, Git commit, Git push를 실행하지 않았다.** +Playwright의 localhost preview는 개발 검증용으로만 실행했다. + +## 실제 strict TDD 기록 + +먼저 테스트와 타입 계약/비동작 stub을 작성하고 RED 명령의 종료 코드 **1**을 +확인한 뒤 최소 동작을 구현했다. 각 GREEN은 종료 코드 **0**이다. 아래 원문 +로그는 `evidence/`에 보관했다. 테스트를 건너뛰거나 실패 조건을 지워 GREEN으로 +만들지 않았다. 초기 stub의 throw는 구현 전 상태를 표현한다. + +| 순서·핵심 동작 | 실제 RED 명령·결과 | 최소 구현 뒤 GREEN 명령·결과 | 원문 로그 | +| --- | --- | --- | --- | +| 01 데이터: 10유형·단어 무결성·8환경·5단계 | `npm test -- tests/content.test.ts`: **3 failed**, 빈 목록의 개수/환경 불일치 | 같은 명령: **3 passed** | [RED](evidence/01-content-red.log), [GREEN](evidence/01-content-green.log) | +| 02 출제·판정·게임 전이·점수 | `npm test -- tests/game.test.ts`: **6 failed**, 출제 미구현·정규화 불일치 | 같은 명령: **6 passed** | [RED](evidence/02-game-red.log), [GREEN](evidence/02-game-green.log) | +| 03 dB·RMS·결정론적 환경 합성 | `npm test -- tests/dsp.test.ts`: **3 failed**, gain=1·잘못된 RMS·무음 | 같은 명령: **3 passed** | [RED](evidence/03-dsp-red.log), [GREEN](evidence/03-dsp-green.log) | +| 04 오디오 gesture·gain·fade·중복 시작·취소·실패·음량 | `npm test -- tests/audio.test.ts`: **5 failed**, 오디오 미구현 | 같은 명령: **5 passed** | [RED](evidence/04-audio-red.log), [GREEN](evidence/04-audio-green.log) | +| 05 실제 저장 음성·원문·해시·포맷·크기 | `npm test -- tests/assets.test.ts`: **1 failed**, manifest 없음(ENOENT) | `npm run audio:generate` 후 `npm test -- tests/App.test.tsx tests/assets.test.ts`: **2 files / 7 passed** (음성 1 + UI 6) | [RED](evidence/05-assets-red.log), [GREEN](evidence/06-ui-05-assets-green.log) | +| 06 시작→재생→답변→결과→재시작·오류·포커스·보안 출력 | `npm test -- tests/App.test.tsx`: **6 failed**, 빈 화면에서 브랜드/시작/입력 역할 없음 | 위 결합 명령: **7 passed** 중 UI **6 passed** | [RED](evidence/06-ui-red.log), [GREEN](evidence/06-ui-05-assets-green.log) | +| 07 production 음성 검증기 | `npm test -- tests/build.test.ts`: **1 failed**, 자산 수 0 ≠ 101 | 같은 명령: **1 passed**, 정상 통과 및 파일 변조·누락 시 실제 실패 검증 | [RED](evidence/07-build-red.log), [GREEN](evidence/07-build-green.log) | +| 08 오답 변형별 혼동 난이도 | `npm test -- tests/content.test.ts`: **1 failed / 3 passed**, 순위 undefined | 같은 명령: **4 passed** | [RED](evidence/08-rule-red.log), [GREEN](evidence/08-rule-green.log) | +| 09 한글 조합 중 제출 방지 | `npm test -- tests/App.test.tsx`: **1 failed / 6 passed**, 조합 중 정답 피드백이 나타남 | 같은 명령: **7 passed** | [RED](evidence/09-ime-red.log), [GREEN](evidence/09-ime-green.log) | +| 10 한글 조합 도중 세션 재시작 | `npm test -- tests/App.test.tsx -t '한글 조합 도중'`: **1 failed / 7 skipped**, 새 세션 제출이 막힘 | 같은 명령: **1 passed / 7 skipped** | [RED](evidence/10-ime-reset-red.log), [GREEN](evidence/10-ime-reset-green.log) | +| 11 실측 음성 도구 버전 provenance | `npm test -- tests/assets.test.ts -t '도구 버전'`: **1 failed / 1 skipped**, 버전 정보 undefined | 생성기 수정·재생성 후 `npm test -- tests/assets.test.ts`: **2 passed** | [RED](evidence/11-provenance-red.log), [GREEN](evidence/11-provenance-green.log) | +| 12 한글 조합 도중 다시 듣기 | 위 10의 테스트에 재청취 전환을 추가한 동일 명령: **1 failed / 7 skipped**, 제출이 막힘 | 같은 명령: **1 passed / 7 skipped** | [RED](evidence/12-ime-replay-red.log), [GREEN](evidence/12-ime-replay-green.log) | + +`-t`의 skipped는 해당 회귀만 고른 실행 결과이며 최종 전체 실행에는 **skip 없음**. + +중간 실패도 남겼다. + +- DSP의 첫 구현에서 음수 0(`-0`)이 경계 샘플로 남아 **1 failed / 2 passed**였다. + 경계값을 명시적으로 0으로 써 수정했다. [중간 로그](evidence/03-dsp-intermediate.log) +- UI 첫 구현에서 라디오의 보조 설명에 ‘직접 입력’이 중복되어 **1 failed / 6 passed**였다. + 각 라디오의 접근성 이름을 명확히 했다. [중간 로그](evidence/06-ui-intermediate.log) +- 오디오 최초 RED의 미구현 throw로 미처리 rejection 3개도 보고되었다. + 실제 엔진의 Promise 종료·정리 구현 뒤 GREEN과 최종 실행에서는 0개다. +- 첫 TypeScript 검사에서 TypedArray의 ArrayBuffer 타입과 테스트 mock의 순환 + 추론 오류를 발견했다. 반환 타입·mock 타입을 명시하여 strict 검사를 통과시켰다. + +## 브라우저 통합 검증과 접근성 수정 + +실제 Chrome, production `dist/`, 실제 WAV/Web Audio로 검증했다. 모의 재생으로 +시간을 단축하지 않고 `ended`까지 기다렸다. 모든 접근성 검사는 axe 규칙을 +비활성화하지 않고 실행했다. + +명령: `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` + +| 실행 | 실제 결과 | 발견·조치 | +| --- | --- | --- | +| [첫 실행](evidence/browser-first.log) | 2 passed / 2 failed | 시작 화면 글자 대비 3.75, 4.07 < 4.5. 두 색을 어둡게 조정 | +| [두 번째](evidence/browser-second.log) | 2 passed / 2 failed | 게임 화면 글자 대비 4.08, 4.41 < 4.5. 두 색을 조정 | +| [세 번째](evidence/browser-third.log) | 2 passed / 2 failed | 두 크기 모두 10문제 완주·100% 도달. 결과 설명 대비 4.38 < 4.5 수정 | +| [수정 후](evidence/browser-final.log) | **4 passed (1.2m)** | 시작·게임·결과 axe 위반 0, 두 크기 완주·재시작·404 후 재시도 통과 | + +최종 빌드에서도 **4 passed (1.2m), 종료 코드 0**을 확인했다. reduced motion의 +실제 계산된 `animation-name: none`까지 검증했다. +[최종 브라우저 로그](evidence/final-browser.log)에 실제 결과를 보관했다. + +스크린샷: + +- [1280px 시작](evidence/desktop-home.png), [게임](evidence/desktop-game.png), [결과](evidence/desktop-results.png) +- [360px 시작](evidence/mobile-home.png), [게임](evidence/mobile-game.png), [결과](evidence/mobile-results.png) + +## 최종 검증 + +환경: Node.js **26.7.0**, npm **11.19.0**, Vite **8.3.4**, Vitest **5.0.4**. +최소 Node 안내는 아래 릴리스 재검토에서 **22.22.2 이상**으로 바로잡았다. +실제 Node 22에서의 별도 실행은 하지 않았으며 현재 실행 환경은 아래 최신 검증을 따른다. + +| 명령/검증 | 실제 결과 | +| --- | --- | +| `npm run lint` | **PASS**, ESLint + `tsc --noEmit` 오류 0 · [로그](evidence/final-lint.log) | +| `npm test` | **7 files / 29 tests passed**, skip 0 · [로그](evidence/final-test.log) | +| `npm run build` | **PASS**, `dist/` 생성 및 음성 101개 원본 SHA-256 대조 · [로그](evidence/final-build.log) | +| `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` | **4 passed (1.2m)**, 360px/1280px 완주·오류 복구·접근성 위반 0·외부 요청 0·JS 예외 0 · [로그](evidence/final-browser.log) | +| `npm run audio:generate` 후 이전 manifest `files` 비교 | **101개 원문·바이트 수·SHA-256 전부 동일** · [로그](evidence/audio-reproduce.log) | +| `npm audit --audit-level=high` | **found 0 vulnerabilities** · [로그](evidence/final-audit.log) | +| 비밀값 정적 패턴 검사 | **34개 텍스트 파일, 0 matches** · [로그](evidence/final-security.log) | +| 직접 작성 소스의 한글 설명 | **22개 파일, 누락 0**; Python 생성기 AST 파싱 PASS | +| `git diff --check` | 종료 0. 초기 저장소가 비어 새 파일들은 아직 untracked 상태 | + +production 주요 번들: JS **244.26 kB**(gzip 76.95 kB), CSS **14.87 kB**(gzip 4.29 kB), +HTML **0.89 kB**. WAV는 별도로 10.86 MB이고 문항 단위로 로드한다. + +비밀값 검사는 소스·테스트·스크립트·문서·루트 metadata·입력 `.agent/*.md`에 대해 +AWS access-key 형식, GitHub/OpenAI 토큰 형식, private-key 블록, 긴 password/secret/API-key +문자열 할당을 Python 정규식으로 검사했다. 일치 값은 출력하지 않는다. +의존성 디렉터리와 PCM 바이너리는 대상에서 제외했다. 이는 특정 형식의 정적 검사다. + +## 변경 파일 + +초기 저장소에는 입력 문서만 존재했다. 다음 파일을 새로 작성했다. 파일 전체 +목록은 [changed-files.txt](evidence/changed-files.txt)에 남긴다(입력 문서는 보존 표시). + +- 앱: `src/App.tsx`, `src/main.tsx`, `src/styles.css`, `src/content.ts`, `src/game.ts`, + `src/audio/dsp.ts`, `src/audio/engine.ts`, `index.html`. +- 설정: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts`, + `eslint.config.js`, `playwright.config.ts`, `.gitignore`. +- 생성/검증: `scripts/generate_audio.py`, `scripts/check-build.mjs`. +- 테스트: `tests/setup.ts`, `tests/content.test.ts`, `tests/game.test.ts`, + `tests/dsp.test.ts`, `tests/audio.test.ts`, `tests/assets.test.ts`, + `tests/App.test.tsx`, `tests/build.test.ts`, `tests/browser/game.spec.ts`. +- 자산: `public/audio/intro.wav`, 100개의 `-.wav`, + `public/audio/manifest.json`. +- 문서: `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, + `docs/DEPLOYMENT.md`, `docs/WORDS.md`, `.agent/RESULT.md` 및 `evidence/` 로그·화면. +- 생성된 `dist/`, `node_modules/`, `.cache/`와 Playwright 임시 보고서는 Git 제외. + +## 남은 위험·인수 사항 + +1. 한국어 포먼트 합성은 자연스러운 사람의 발화보다 기계적이다. 자동 검증은 + 오디오의 존재·디코딩·전력·흐름을 확인했고, 실제 사용자의 발음 이해도와 청감 + 난이도를 평가하지 않았다. Neo의 직접 청취 및 사용자 평가가 필요하다. +2. 환경음은 장소를 추상화한 합성 잡음이다. 실제 시장·카페의 사람 대화 녹음이 + 아니다. 상대 dB와 오답 혼동 순위는 의료·청력 측정치가 아니다. +3. 모바일은 Chrome의 360px 터치 에뮬레이션으로 검증했다. 실물 iOS Safari, + Android 장치, Bluetooth 출력, 보조공학 장치의 전체 조합은 미검증이다. +4. 사용자 기록은 메모리에만 있어 새로고침 시 사라진다. 오프라인 PWA는 아니며 + 음성의 첫 로드에는 정적 호스트 연결이 필요하다. 전체 오디오 용량은 약 10.9MB다. +5. 정답은 정적 번들에 포함된다. 개발자 도구로 답을 알아낼 수 있어 보안 시험이나 + 순위 경쟁 서비스 용도가 아니다. +6. 서버 배포·Nginx/TLS/DNS는 요청 범위 밖이며 실행하지 않았다. Neo가 별도 + 검토·커밋·배포하고 실제 도메인에서 MIME·캐시·오디오 404·HTTPS를 확인해야 한다. + +## Neo 스크린샷 검수 후 출시 전 UI 수정 — 2026-10-14 UTC + +앞의 기록과 기존 스크린샷은 보존하고 이번 작업의 RED/GREEN을 이어서 기록한다. +기록 위치는 `.agent/TASK.md`와 `.agent/ACCEPTANCE.md`의 `.agent/RESULT.md` 지정을 따랐다. + +### 변경 결과 + +- 한국어 `h1`·`h2`·`h3`에 `word-break: keep-all`을 적용했다. 실제 Chrome의 + 360px computed style이 `normal`인 RED부터 확인했고, 열 가지 질문 모두 + 360px/1280px에서 `keep-all`과 제목 내부 가로 넘침 여부를 검사한다. +- 게임과 결과 난이도를 `SNR +6 dB`, `SNR +3 dB`, `SNR 0 dB`, `SNR -3 dB`, + `SNR -6 dB`로 표시한다. 게임에서는 “음성이 배경보다 6 dB 커요.”, + “음성과 배경의 크기가 같아요.”, “음성이 배경보다 3 dB 작아요.”처럼 + 크기 관계를 풀어 설명한다. 모바일에서도 설명을 숨기지 않는다. +- 전체 음량은 시작 화면의 설정 카드 안에서 CTA보다 앞에, 게임에서는 환경 + 패널 안에 배치했다. `전체 음량` 접근성 이름, label 연결, 엔진 전달, + 화면 전환 시 값 유지와 결과 화면의 기존 음량 조절을 보존했다. +- 사운드 룸의 `01 / 08`은 실제 환경 수를 이용한 `8가지 환경`으로 바꿨다. + `플레이 방법`의 `#howto` 앵커는 유지하고 장식 화살표를 `↓`로 바꿨다. + CTA의 표시와 접근성 이름은 `게임 시작 · 소리 듣기`로 통일했다. +- 기존 색상·캐릭터·게임 흐름을 유지했다. README의 CTA와 SNR 설명도 맞췄다. + 출제·채점·음원·오디오 gain 구현은 수정하지 않았다. + +### 추가 strict TDD 기록 + +각 RED는 해당 구현을 바꾸기 **전에** 실행해 종료 코드 **1**을 확인했다. +각 GREEN은 같은 테스트 계약으로 종료 코드 **0**을 확인했다. 브라우저는 +각 단계의 소스를 먼저 빌드한 뒤 localhost production preview로 검사했다. + +| 순서·계약 | 실제 RED | 구현 후 GREEN | 증거 | +| --- | --- | --- | --- | +| 13 한국어 제목의 어절 보존 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --project=mobile`: **1 failed**. 시작·질문 heading의 computed `wordBreak`가 `normal` | 같은 명령: **1 passed**, `keep-all` 및 가로 넘침 없음 | [RED](evidence/13-heading-red.log), [GREEN](evidence/13-heading-green.log), [변경 전 빌드](evidence/13-heading-build-before.log), [변경 후 빌드](evidence/13-heading-build-after.log) | +| 14 다섯 SNR·크기 설명·결과 표시 | `npm test -- tests/App.test.tsx -t '다섯 난이도의 SNR'`: **1 failed / 8 skipped**. 열 문제의 SNR·크기 설명과 결과 표기 누락 | 같은 명령: **1 passed / 8 skipped** | [RED](evidence/14-snr-red.log), [GREEN](evidence/14-snr-green.log) | +| 14 모바일 난이도 설명 가시성 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --project=mobile --grep '난이도 설명'`: **1 failed**. `.db-note`가 hidden이고 새 설명·배지 없음 | 같은 명령: **1 passed** | [RED](evidence/14-snr-browser-red.log), [GREEN](evidence/14-snr-browser-green.log), [빌드](evidence/14-snr-build.log) | +| 15 음량의 소속·문서 순서·이름·값·엔진 | `npm test -- tests/App.test.tsx -t '전체 음량을 시작 CTA'`: **1 failed / 9 skipped**. 설정 카드·환경 패널 바깥이고 CTA 뒤에 있음 | 같은 명령: **1 passed / 9 skipped**. 이름·단일 컨트롤·20→60% 전환 및 엔진 전달 확인 | [RED](evidence/15-volume-red.log), [GREEN](evidence/15-volume-green.log) | +| 15 실제 음량 위치·키보드 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --grep '전체 음량'`: **2 failed**. 두 화면 크기에서 컨트롤이 카드 경계와 CTA 아래로 벗어남 | 같은 명령: **2 passed**, 카드 내부·CTA 이전 배치와 방향키·값 유지 확인 | [RED](evidence/15-volume-browser-red.log), [GREEN](evidence/15-volume-browser-green.log), [빌드](evidence/15-volume-build.log) | +| 16 환경 수·앵커 화살표·명확한 CTA | `npm test -- tests/App.test.tsx -t '시작 안내'`: **3 failed / 10 skipped**. `8가지 환경`·`↓`·새 CTA 없음 | 같은 명령: **3 passed / 10 skipped**, CTA 클릭에서 재생 1회 확인 | [RED](evidence/16-copy-red.log), [GREEN](evidence/16-copy-green.log) | +| 17 음량 이동 후 게임 카드·바닥글 여백 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --grep '전체 음량'`: **2 failed**. 스크린샷 검토에서 발견한 두 화면 크기의 카드·바닥글 간격 **0px < 24px** | 게임 하단에 32px 여백 적용 후 같은 명령: **2 passed** | [RED](evidence/17-spacing-red.log), [GREEN](evidence/17-spacing-green.log) | + +필터 실행의 skipped는 해당 회귀만 선택한 결과다. 기존 테스트의 시작 버튼 +선택자도 새 접근성 이름에 맞췄으며, 이전 이름까지 허용하는 정규식으로 +검증을 완화하지 않았다. 최종 브라우저 테스트에는 음량에서 Tab을 누르면 +시작 CTA로 이동하는 검사와 열 가지 질문의 computed style 검사도 포함한다. + +전체 검증의 첫 타입 검사에서 새 Testing Library `getByRole` 호출에 지원되지 +않는 `exact` 옵션을 발견했다. 문자열 `name` 자체가 정확 일치를 하므로 불필요한 +옵션만 제거했다. [중간 lint](evidence/neo-intermediate-lint.log)와 +[중간 build](evidence/neo-intermediate-build.log)를 보존했다. + +여백 회귀는 첫 전체 브라우저 실행 **10 passed (1.5m)** 뒤 스크린샷 검토에서 +추가로 발견했다. [당시 로그](evidence/neo-before-spacing-browser.log)를 보존하고 +17의 측정 계약을 먼저 실패시킨 뒤 수정했다. 전체 캡처 전에는 스크롤을 +맨 위로 맞춰 화면 밖 fixed 건너뛰기 링크가 Chrome의 full-page 이미지에 +들어오는 캡처 아티팩트를 방지했다. 앱의 초점 처리와 링크는 바꾸지 않았다. + +### 이번 수정의 최종 전체 검증 + +17의 여백 수정까지 포함한 최종 소스로 아래 명령을 모두 실행했다. 종료 코드는 +전부 **0**이며 unit/component와 브라우저 전체 실행 모두 **skip 없음**이다. + +| 명령 | 최종 결과 | 로그 | +| --- | --- | --- | +| `npm run lint` | **PASS**, ESLint와 TypeScript 오류 0 | [lint](evidence/neo-final-lint.log) | +| `npm test` | **7 files / 34 tests passed** | [test](evidence/neo-final-test.log) | +| `npm run build` | **PASS**, production 빌드·음성 101개 무결성 확인 | [build](evidence/neo-final-build.log) | +| `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` | **10 passed (1.4m)**, 실제 Chrome 360px/1280px | [browser](evidence/neo-final-browser.log) | + +브라우저에서는 실제 WAV 재생을 기다려 혼합 모드 열 문제를 완주하고, +재청취·100% 결과·재시작·404 후 재시도·초점 복귀를 검증했다. 두 화면 크기에서 +시작·게임·결과 axe 위반 **0**, 외부 요청 **0**, JavaScript 예외 **0**이며, +reduced motion과 새 줄바꿈·SNR 설명·음량 위치·키보드 계약도 모두 통과했다. +스크린샷을 직접 열어 시작 CTA 앞 음량, 게임 환경 패널의 SNR·음량, 어절을 +보존한 질문, 게임 카드와 바닥글의 여백, 결과 표기를 확인했다. + +최종 스크린샷(기존 파일과 구별되는 `neo-` 접두사): + +- 1280px: [시작](evidence/neo-desktop-home.png), [게임](evidence/neo-desktop-game.png), [결과](evidence/neo-desktop-results.png), [고정 질문](evidence/neo-desktop-heading.png) +- 360px: [시작](evidence/neo-mobile-home.png), [게임](evidence/neo-mobile-game.png), [결과](evidence/neo-mobile-results.png), [고정 질문](evidence/neo-mobile-heading.png) + +이번 변경 파일은 `src/App.tsx`, `src/styles.css`, `tests/App.test.tsx`, +`tests/browser/game.spec.ts`, 신규 `tests/browser/ui.spec.ts`, `README.md`, +이 `.agent/RESULT.md` 및 추가 evidence 로그·스크린샷이다. 의존성과 음원은 +변경하지 않았다. `git diff --check`는 종료 0이며 저장소의 기존 untracked 상태는 +유지했다. 변경 텍스트 7개 파일의 후행 공백 0과 RESULT의 evidence 링크 존재도 +별도로 확인했다. + +요청된 UI 수정에서 남은 실패는 없다. 실물 모바일·Safari 및 청감 평가의 기존 +검증 범위 한계는 그대로다. 이번에도 로컬 preview 검증만 수행했으며, +**서버 배포, Git commit, Git push는 실행하지 않았다.** + + +## 독립 릴리스 리뷰·Neo literal-preservation 재검토 반영 — 2026-10-14 UTC + +이번 사용자 지시를 이전 입력 문서의 브랜드 해석보다 우선 적용했다. +정확한 표기는 **Designed by OZ at Heat-T Medical Inc.**다. UI, README, +`.agent/CONTEXT.md`·`TASK.md`·`ACCEPTANCE.md`의 요구사항과 이 문서의 표기도 +일치시켰다. 이전 실행의 raw 로그와 스크린샷은 당시 상태의 증거이므로 보존한다. +아래 검증이 현재 릴리스 상태이며 앞의 결과·바이트 수·테스트 수는 당시 기록이다. + +### 변경 내용 + +- 브랜드 원문을 고정한 컴포넌트·문서·실제 브라우저 회귀 검증을 추가했다. + 회사명을 문맥으로 추측해 교정하던 입력 문서 규칙도 제거했다. +- 프로젝트 자체는 `private: true`, `license: UNLICENSED`, root `LICENSE`의 + private / all-rights-reserved로 명시했다. package repository·homepage와 + README·배포 문서의 canonical URL은 `https://source.ibit.kr/OZ-Group/Find-Word`다. +- root와 public의 `THIRD_PARTY_NOTICES.md`에 잠긴 React 19.3.0, ReactDOM 19.3.0, + scheduler 0.28.0의 MIT 전문을 포함했다. 빌드 검증기는 전이 의존성을 포함한 + 런타임 목록을 lockfile에서 찾고, 설치된 LICENSE 전문·버전과 root/public/dist의 + 고지 사본을 대조한다. notice의 누락·축약을 실제 실패시키는 테스트도 추가했다. +- `Pattern.mechanic`에 10개 고유 연산을 지정했다. 직접 목표 식별, 암호 구별, + 잊고 기억하기, 첫째·마지막·가운데, 반복, 정정, 신호 조건, 오른쪽 표지를 각각 + 다르게 듣고 판단하게 했다. `renderSentence`가 실제 보기의 오답도 문장에 + 삽입하며 UI와 음성 생성기가 같은 원문을 사용한다. 문장 계약 검증은 100개 + 조합에서 순서·반복 횟수·취소·조건·방향 단서와 질문 의도를 확인한다. +- 101개 WAV와 manifest를 재생성했다. 합계 **20,497,222바이트**, 16kHz mono + PCM이며 기존 20MiB 제한을 유지했다. 방향은 말로 전달하는 의미 단서다. +- package와 lockfile의 Node 최소 버전은 `>=22.22.2`로 맞췄다. README·배포 + 문서에 잠긴 jsdom 계열의 24.x 최소 24.15.0 요구도 함께 설명했다. +- Playwright는 `chromiumSandbox: true`가 기본이다. 미설정·빈 값·`0`·`false`는 + sandbox를 유지하며, `PLAYWRIGHT_NO_SANDBOX=1`일 때만 끈다. Chrome 경로 + 지정은 sandbox와 독립적이다. 새 브라우저 스크린샷은 `release-` 접두사로 저장한다. + +### 실제 strict TDD + +아래 RED는 구현을 바꾸기 전에 실제 실행했고 모두 종료 코드 **1**이었다. +GREEN은 같은 계약으로 모두 종료 코드 **0**이었다. 브라우저 전달 계약은 +기존 dist에서 먼저 실패시킨 후 새 소스를 빌드했다. 테스트 실패를 제거하거나 +허용치를 완화하지 않았다. `-t`로 지정한 실행의 skip은 선택 실행에 한정된다. + +| 순서·계약 | RED 명령·관찰 | GREEN 명령·관찰 | 원문 로그 | +| --- | --- | --- | --- | +| 18 literal 보존 | `npm test -- tests/App.test.tsx tests/release.test.ts -t '브랜드'`: **2 failed**, UI·문서의 기존 표기 불일치 | 같은 명령: **2 passed / 12 skipped** | [RED](evidence/18-literal-red.log), [GREEN](evidence/18-literal-green.log) | +| 19 프로젝트 metadata·MIT 고지·배포 검증기 | `npm test -- tests/release.test.ts tests/build.test.ts`: **4 failed / 2 passed**, metadata·LICENSE·고지 누락 및 검증기의 누락/축약 미검출 | 같은 명령: **6 passed** | [RED](evidence/19-notices-red.log), [GREEN](evidence/19-notices-green.log) | +| 20 고유 청취 연산·문장 계약·출제 | `npm test -- tests/content.test.ts tests/game.test.ts`: **12 failed / 10 passed**, mechanic 없음·경쟁 단어/순서/의미 계약 미충족 | 같은 명령: **22 passed** | [RED](evidence/20-mechanics-red.log), [GREEN](evidence/20-mechanics-green.log) | +| 20 변경 원문과 WAV 대응 | `npm test -- tests/assets.test.ts`: **1 failed / 1 passed**, 기존 manifest 원문이 새 문장과 다름 | `npm run audio:generate` 후 `npm test -- tests/content.test.ts tests/game.test.ts tests/assets.test.ts`: **24 passed**, 문장·해시·PCM·비무음·크기·피크 검증 | [RED](evidence/20-audio-red.log), [생성](evidence/20-audio-generate.log), [GREEN](evidence/20-audio-green.log) | +| 21 Node 최소 버전 | `npm test -- tests/release.test.ts -t 'Node 최소'`: **1 failed / 3 skipped**, package/lock/docs 값이 잠금 의존성 요구와 다름 | 같은 명령: **1 passed / 3 skipped** | [RED](evidence/21-node-red.log), [GREEN](evidence/21-node-green.log) | +| 22 sandbox 기본값과 명시적 opt-in | `npm test -- tests/playwright-config.test.ts`: **5 failed**, 기본 sandbox 비활성화·무조건 적용된 비활성화 플래그 | 같은 명령: **5 passed**, 각 환경변수 값을 별도 프로세스로 검증 | [RED](evidence/22-sandbox-red.log), [GREEN](evidence/22-sandbox-green.log) | +| 23 실제 HTTP 배포 응답 | `env -u PLAYWRIGHT_NO_SANDBOX CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/release.spec.ts --project=mobile`: **1 failed**, 이전 dist의 크레딧 불일치와 notice 대신 SPA HTML 응답 | 새 build 후 전체 브라우저 실행: **12 passed (2.2m)**, 두 화면 크기의 전달 계약 포함 | [RED](evidence/23-delivery-browser-red.log), [전체 GREEN](evidence/release-final-browser.log) | + +중간 lint에서 새 sandbox 테스트의 env 객체 타입이 좁게 추론되어 TypeScript +오류 2개가 났다. `NodeJS.ProcessEnv`를 명시하여 수정했다. +[실패 원문](evidence/release-intermediate-lint.log)을 보존했다. + + +### 최종 검증 결과 + +환경: Node.js **26.7.0**, npm **11.19.0**, 시스템 Chrome +`/usr/bin/google-chrome`. 최소 Node 22.22.2에서 별도로 실행한 결과는 아니다. +새 소스의 production build를 완료한 뒤 다음 명령으로 브라우저 전체를 실행했다. + +```bash +env -u PLAYWRIGHT_NO_SANDBOX CHROME_PATH=/usr/bin/google-chrome DEBUG=pw:browser npm run test:browser +``` + +| 검증 | 실제 결과·증거 | +| --- | --- | +| `npm run lint` | **PASS**, ESLint + TypeScript strict 오류 0 · [로그](evidence/release-final-lint.log) | +| `npm test` | **9 files / 57 tests passed**, skip 0 · [로그](evidence/release-final-test.log) | +| `npm run build` | **PASS**, WAV 101개 SHA-256 및 root/public/dist 런타임 MIT notice 전문 대조 · [로그](evidence/release-final-build.log) | +| 위 Chrome 전체 명령 | **12 passed (2.2m)**, 360px/1280px, 실제 재생·10문제 완주·재청취·404 복구·키보드·SNR·레이아웃·axe·크레딧·MIT HTTP 응답 · [로그](evidence/release-final-browser.log) | +| sandbox 실제 실행 인자 | Chrome 4회 실행 모두 `--no-sandbox` 없음. opt-in 환경변수를 지운 상태로 실행 · [검증](evidence/release-integrity.log) | +| `npm audit --audit-level=high` | **found 0 vulnerabilities**, 개발 의존성 포함 · [로그](evidence/release-final-audit.log) | +| `npm ls --omit=dev` | React / ReactDOM 19.3.0, 해석 오류 없음 | +| 브랜드·기록·정적 산출물 최종 확인 | 현재 UI·테스트·문서에 대체 브랜드 표기 없음, evidence 링크 존재, 101 WAV/notice 검증 통과 · [로그](evidence/release-integrity.log) | +| `git diff --check` | 종료 코드 0. 시작 시 이미 소스 전체가 untracked였으므로 변경 텍스트의 후행 공백도 별도 검사 | + +브라우저의 페이지 JS 예외·외부 페이지 요청·axe 위반은 모두 0이었다. +Chrome 자체 진단 출력과 앱 페이지의 오류·네트워크 검증은 구별했다. +실제 360px 게임 스크린샷에서도 올바른 브랜드, 질문 줄바꿈, 보기·음량·SNR 표시를 확인했다. + +- [360px 시작](evidence/release-mobile-home.png), [게임](evidence/release-mobile-game.png), [결과](evidence/release-mobile-results.png) +- [1280px 시작](evidence/release-desktop-home.png), [게임](evidence/release-desktop-game.png), [결과](evidence/release-desktop-results.png) + +### 이번 변경 파일과 범위 + +- 앱·출제: `src/App.tsx`, `src/content.ts`, `src/game.ts`. +- 라이선스·metadata·브라우저 설정: `LICENSE`, `THIRD_PARTY_NOTICES.md`, + `public/THIRD_PARTY_NOTICES.md`, `package.json`, `package-lock.json`, `playwright.config.ts`. + 의존성 버전과 integrity는 바꾸지 않았다. +- 생성·빌드: `scripts/generate_audio.py`, `scripts/check-build.mjs`, + `public/audio/`의 101 WAV와 manifest. 안내 WAV도 다시 생성했으며 원문은 유지했다. +- 회귀 검증: `tests/App.test.tsx`, `tests/content.test.ts`, `tests/game.test.ts`, + `tests/assets.test.ts`, `tests/build.test.ts`, 새 `tests/release.test.ts`, + `tests/playwright-config.test.ts`, `tests/browser/release.spec.ts`. + 기존 `tests/browser/game.spec.ts`·`ui.spec.ts`는 새 스크린샷 이름을 사용한다. +- 문서·증거: `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, + `docs/DEPLOYMENT.md`, `.agent/CONTEXT.md`, `TASK.md`, `ACCEPTANCE.md`, + 이 `RESULT.md` 및 새 evidence 파일. + +요청된 수정·자동 검증에 남은 실패는 없다. 한국어 합성 발음의 청감과 실물 +모바일 Safari는 기존과 같이 별도 인수 대상이다. WAV 용량은 약 20.5MB로 늘었고 +질문별로 로드한다. **서버 배포, Git commit, Git push는 실행하지 않았다.** + +## Neo canonical 오디오 재시도 assertion 회귀 수정 — 2026-10-14 UTC + +### RED 근거와 재현 조건 + +- **Neo가 보고한 기존 canonical 실패를 이번 RED 근거로 사용한다.** + 전체 `npm run test:browser`(12 tests, workers=2)에서 desktop의 + `오디오 HTTP 실패를 화면에 표시하고 재시도한다`가 재시도 후 + `getByRole("group", { name: "답 선택" })`를 global expect timeout + **20,000ms** 안에 보지 못해 1회 실패했다. +- 같은 수정 전 테스트를 desktop 단독 `--workers=1 --repeat-each=3`으로 + 실행하면 **16.3~17.3초에 3회 모두 통과**했다는 Neo의 보고다. + 실패는 병렬 전체 실행에서 간헐적으로 발생했다. 이는 이번 로컬 실행의 + 원문 로그가 아니며, 새로 실패를 재현했다고 주장하지 않는다. +- 사용자 메시지에 첨부된 **12 passed (2.2m)** 출력은 성공 실행 기록이다. + 이를 실패 로그로 취급하지 않으며 기존 evidence도 보존한다. + +### 원인 추적과 최소 수정 범위 + +`systematic-debugging` 절차로 실패한 assertion에서 상태 전이를 역추적했다. +404가 난 최초 재생은 `src/App.tsx`의 `introDone`을 설정하지 않는다. +재시도는 안내와 목표 WAV를 다시 로드·디코드하고, `src/audio/engine.ts`의 +목표 소스 `onended` → `play()` 완료 → `ENDED` → `answer` 상태가 된 뒤에야 +답 선택 그룹을 표시한다. 출제 seed는 `Date.now()`여서 실행별 목표 길이도 +달라진다. 정상 단독 실행과 병렬 실패의 차이는 이 대기 경로의 시간 여유다. + +현재 101개 WAV를 Python `wave`의 프레임 수 / sample rate로 실측했다. +안내는 **8.1205초**, 최장 목표 `message-market.wav`는 **10.1725초**다. +엔진의 시작 여유 **0.05초**와 안내→목표 간격 **0.35초**를 더한 최대 +재생 일정은 **18.693초**다. 기존 20초 assertion에는 HTTP 로드·디코드·배경 +합성·브라우저 스케줄링·DOM 반영·assertion 관찰을 위한 여유가 **1.307초**만 +남는다. Neo의 병렬 실패와 단독 성공은 이 좁은 assertion 예산에 부합한다. +당시 trace가 없어 실패 1회의 세부 지연 분포는 확인할 수 없다. +[실측과 제어 흐름 기록](evidence/24-retry-audio-budget.log) + +수정은 `tests/browser/game.spec.ts`의 **재시도 완료 `toBeVisible` 하나에만 +30,000ms 상한**을 지정한다. 최대 재생 일정 이후 **11.307초**의 여유를 두며 +조건이 충족되면 즉시 통과한다. 제품의 준비 제한 15초·재생 종료 후 5초 +watchdog, global expect 20초, test timeout 120초, workers=2는 유지한다. +sleep, 자동 재시도, 오디오 mock, 테스트 skip, 제품 코드 변경은 추가하지 않는다. + +### 수정 후 검증 + +환경: Node.js **26.7.0**, npm **11.19.0**, 시스템 Chrome +`/usr/bin/google-chrome`. 새 production build에서 실제 WAV를 사용했다. +브라우저 실행은 `env -u PLAYWRIGHT_NO_SANDBOX CHROME_PATH=/usr/bin/google-chrome` +접두사로 기본 sandbox를 유지했다. 아래 명령은 모두 종료 코드 **0**이다. + +| 검증 명령 | 실제 GREEN 결과 | 로그 | +| --- | --- | --- | +| `npm run lint` | **PASS**, ESLint + TypeScript 오류 0 | [lint](evidence/24-retry-final-lint.log) | +| `npm test` | **9 files / 57 tests passed**, skip 0 | [test](evidence/24-retry-final-test.log) | +| `npm run build` | **PASS**, WAV 101개와 런타임 notice 전문 확인 | [build](evidence/24-retry-final-build.log) | +| `npm run test:browser -- tests/browser/game.spec.ts --project=desktop --workers=1 --repeat-each=3 --grep '오디오 HTTP 실패'` | **3 passed (52.9s)**, 각 **17.7s / 15.5s / 16.8s** | [targeted](evidence/24-retry-targeted-green.log) | +| `npm run test:browser` | **12 passed (2.2m)**, desktop/mobile, workers=2, skip 0 | [browser](evidence/24-retry-final-browser.log) | + +전체 병렬 실행의 재시도 테스트는 desktop **19.9초**, mobile **14.8초**에 +통과했다. 이 수치는 Playwright가 보고한 **테스트 전체 시간**이며 assertion +자체의 대기 시간으로 해석하지 않는다. 실제 404 오류 표시→재시도→답 선택→ +처음으로 이동 후 초점 복귀 계약을 유지한 채 통과했다. 기존의 실제 오디오 +10문제 완주, 레이아웃·접근성·외부 요청 없음, 릴리스·UI 검사도 모두 통과했다. + +이번 변경은 `tests/browser/game.spec.ts`의 assertion 한 곳과 이 기록, +`evidence/24-retry-*.log`에 한정한다. 시작 시 소스 전체가 untracked였으므로 +작업 전 SHA-256 snapshot과 비교해 `src/`, 음원, 의존성, Playwright 설정 및 +기존 evidence가 그대로임을 확인했다. 전체 브라우저 실행이 다시 생성한 +기존 `release-*.png`는 작업 전 사본으로 복원해 과거 검증 증거를 보존했다. +`git diff --check`, 수정 텍스트의 후행 공백 검사와 evidence 링크 확인도 +통과했다. [변경 범위와 무결성 확인](evidence/24-retry-integrity.log) + +요청한 반복·전체 검증은 모두 GREEN이다. **Git commit, push, 배포는 실행하지 않았다.** diff --git a/.agent/TASK.md b/.agent/TASK.md new file mode 100644 index 0000000..cd2e40d --- /dev/null +++ b/.agent/TASK.md @@ -0,0 +1,77 @@ +# TASK — Find-Word v0.1 + +## 역할 + +당신은 구현 담당 Codex다. 이 저장소에서만 작업하고 프로덕션 서버·DNS·인증정보에는 접근하지 않는다. Neo가 코드 리뷰·배포를 별도로 수행한다. + +## 제품 + +- 서비스명: **단어를 맞춰라** +- 표기: **Designed by OZ at Heat-T Medical Inc.** +- 버전: **0.1** +- 배포 예정 도메인: `findword.heart-m.com` +- 언어: 사용자 UI와 상세 소스 주석은 한국어 + +## 목표 + +여러 환경 소음 속에 작은 음량으로 섞인 특정 한국어 단어나 문장을 듣고 질문에 답하는 웹 게임을 만든다. 쉬운 단계부터 소음 대비 키 문장의 dB 차이를 늘려 난이도를 높인다. 객관식과 주관식을 모두 지원한다. + +## 핵심 시나리오 + +1. S 캐릭터가 “으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.”라고 안내한다. +2. 웃음과 안내 음성이 배경 소음 속으로 점차 묻힌다. +3. “오늘의 내 행운은 ‘사과’에 있다.” 같은 키 문장을 재생한다. +4. 재생이 끝난 뒤 질문과 네 개의 선택지를 표시한다. +5. 선택지는 정답 1개와 사전에 존재하는 유사어 오답 3개로 구성한다. +6. 오답은 자음·모음 변형 규칙의 서로 다른 난이도를 반영하고, 데이터에 변형 종류를 명시한다. + +## 기능 요구사항 + +- 반응형 SPA, 데스크톱·모바일 키보드/터치 지원 +- 시작 화면, 게임 화면, 결과 화면, 다시 듣기, 다음 문제, 전체 진행률 +- 사용자가 브라우저 정책상 필요한 “소리 시작” 조작을 직접 누르도록 설계 +- Web Audio API로 보이스와 배경을 믹싱하고 목표 음성 gain을 dB 기준으로 적용 +- 최소 5단계 난이도. 단계가 높아질수록 목표 문장과 배경의 상대 dB가 낮아짐 +- 최소 10개 문제 유형. 객관식·주관식을 포함하고 데이터 조합으로 실제 문제가 더 늘어남 +- 최소 8개 배경 환경: 길거리, 쇼핑몰, 극장, 백화점, 시장, 지하철, 카페, 비 오는 거리 +- 기본 패턴 문장, 시험 문장, 질문, 답, 오답 후보, 배경 프리셋을 저장된 데이터로 분리 +- 사전 단어만 사용하는 4지선다 데이터와 각 오답의 변형 규칙 메타데이터 +- 점수, 정답률, 연속 정답, 난이도별 결과 +- 접근성: aria, 명확한 focus, 키보드 조작, reduced motion, 색 대비 +- 오디오가 없거나 로드 실패해도 사용자에게 명확한 오류와 재시도 제공 +- 개인 데이터 수집·로그인·추적기 없음 +- 버전 0.1을 UI와 package metadata에 표시 + +## 오디오 요구사항 + +- 라이선스 불명확한 외부 자산을 사용하지 않는다. +- 직접 생성한 배경 소음·효과음을 저장하거나, 브라우저에서 결정론적으로 합성하는 프리셋을 사용한다. +- 한국어 목표 음성은 가능하면 직접 생성한 오디오 파일로 저장한다. 네트워크 TTS를 사용했다면 생성 스크립트·출처·생성 절차를 문서화하고 앱은 런타임에 외부 TTS를 호출하지 않는다. +- 모든 자산의 provenance를 `docs/AUDIO_PROVENANCE.md`에 기록한다. +- 오디오 파일을 커밋할 경우 크기를 합리적으로 제한하고 build 결과에 포함되는지 테스트한다. + +## 개발 규칙 + +- strict TDD: 각 동작은 실패하는 테스트를 먼저 작성하고 실제 RED→GREEN 근거를 `.agent/RESULT.md`에 기록한다. +- TypeScript strict mode를 사용한다. +- 모든 직접 작성 소스 코드에 상세한 한국어 주석을 단다. 자명한 문법 줄마다 반복 주석을 붙이지 말고 알고리즘, 상태, 오디오, 난이도, 접근성, 보안 의도를 설명한다. +- generated lockfile과 기계 생성 바이너리는 주석 대상이 아니다. +- 비밀값, 서버 자격증명, API 키를 저장하지 않는다. +- 서버 배포는 하지 않는다. +- Git 커밋은 하지 않는다. Neo가 검증 후 커밋한다. + +## 권장 기술 + +- Vite + React + TypeScript +- Vitest + Testing Library +- 순수 CSS 또는 가벼운 CSS 구조 +- 정적 빌드(`dist/`)로 Nginx 배포 가능 + +## 산출물 + +- 동작하는 v0.1 소스 +- 최소 10개 문제 유형과 조합 가능한 데이터 +- 오디오 생성·재생 구조 +- unit/component 테스트 +- `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, `docs/DEPLOYMENT.md` +- `.agent/RESULT.md`: 변경 파일, RED/GREEN 명령과 실제 결과, 전체 검증 결과, 미해결 위험 diff --git a/.agent/evidence/01-content-green.log b/.agent/evidence/01-content-green.log new file mode 100644 index 0000000..4f9c117 --- /dev/null +++ b/.agent/evidence/01-content-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 3 passed (3) + Start at 04:54:59 + Duration 835ms (environment 78%, setup 13%, transform 6%, tests 1%, worker 1%) diff --git a/.agent/evidence/01-content-red.log b/.agent/evidence/01-content-red.log new file mode 100644 index 0000000..a9c8fc8 --- /dev/null +++ b/.agent/evidence/01-content-red.log @@ -0,0 +1,72 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/content.test.ts (3 tests | 3 failed) 12ms + ❯ 저장된 문제·환경 데이터 (3) + × 서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다 5ms + × 네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다 1ms + × 여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다 5ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > 서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다 +AssertionError: expected 0 to be greater than or equal to 10 + ❯ tests/content.test.ts:8:29 + 6| describe('저장된 문제·환경 데이터', () => { + 7| it('서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다', () => { + 8| expect(PATTERNS.length).toBeGreaterThanOrEqual(10); + | ^ + 9| expect(new Set(PATTERNS.map(p => p.id)).size).toBe(PATTERNS.length… + 10| expect(new Set(PATTERNS.map(p => p.question)).size).toBe(PATTERNS.… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > 네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다 +AssertionError: expected 0 to be greater than or equal to 10 + ❯ tests/content.test.ts:14:26 + 12| }); + 13| it('네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다', () => { + 14| expect(WORDS.length).toBeGreaterThanOrEqual(10); + | ^ + 15| WORDS.forEach(entry => { + 16| expect(entry.distractors).toHaveLength(3); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > 여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다 +AssertionError: expected [] to deeply equal [ '길거리', '쇼핑몰', '극장', '백화점', …(4) ] + +- Expected ++ Received + +- [ +- "길거리", +- "쇼핑몰", +- "극장", +- "백화점", +- "시장", +- "지하철", +- "카페", +- "비 오는 거리", +- ] ++ [] + + ❯ tests/content.test.ts:36:43 + 34| }); + 35| it('여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다', () => { + 36| expect(ENVIRONMENTS.map(e => e.name)).toEqual(['길거리', '쇼핑몰', '극장',… + | ^ + 37| expect(new Set(ENVIRONMENTS.map(e => e.seed)).size).toBe(8); + 38| expect(LEVELS).toHaveLength(5); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + + Test Files 1 failed (1) + Tests 3 failed (3) + Start at 04:54:05 + Duration 899ms (environment 78%, setup 13%, transform 6%, tests 2%, worker 1%) diff --git a/.agent/evidence/02-game-green.log b/.agent/evidence/02-game-green.log new file mode 100644 index 0000000..b387310 --- /dev/null +++ b/.agent/evidence/02-game-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/game.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 6 passed (6) + Start at 04:56:41 + Duration 875ms (environment 75%, setup 13%, transform 5%, tests 5%, worker 1%, import 1%) diff --git a/.agent/evidence/02-game-red.log b/.agent/evidence/02-game-red.log new file mode 100644 index 0000000..dbf0701 --- /dev/null +++ b/.agent/evidence/02-game-red.log @@ -0,0 +1,105 @@ + +> find-word@0.1.0 test +> vitest run tests/game.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/game.test.ts (6 tests | 6 failed) 14ms + ❯ 출제와 정답 판정 (3) + × 열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다 6ms + × 선택한 응답 방식과 환경을 모든 문제에 적용한다 1ms + × 한글 정규형·공백·문장부호만 정리하고 부분 정답은 인정하지 않는다 4ms + ❯ 게임 상태·점수·경합 방지 (3) + × 재생 완료 이전에는 답변·다음 문제를 허용하지 않으며 중복 채점을 막는다 1ms + × 오디오 오류 후 재시도와 다시 듣기는 점수를 변경하지 않는다 0ms + × 열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다 0ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/game.test.ts > 출제와 정답 판정 > 열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다 +Error: 출제 미구현 + ❯ createQuestions src/game.ts:17:106 + 15| } + 16| export type Action = { type: 'PLAY' | 'STARTED' | 'ENDED' | 'NEXT' } |… + 17| export function createQuestions(_seed: number, _mode: Mode, _environme… + | ^ + 18| export function normalizeAnswer(value: string): string { return value;… + 19| export function isCorrect(input: string, answer: string): boolean { re… + ❯ tests/game.test.ts:8:23 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/6]⎯ + + FAIL tests/game.test.ts > 출제와 정답 판정 > 선택한 응답 방식과 환경을 모든 문제에 적용한다 +Error: 출제 미구현 + ❯ createQuestions src/game.ts:17:106 + 15| } + 16| export type Action = { type: 'PLAY' | 'STARTED' | 'ENDED' | 'NEXT' } |… + 17| export function createQuestions(_seed: number, _mode: Mode, _environme… + | ^ + 18| export function normalizeAnswer(value: string): string { return value;… + 19| export function isCorrect(input: string, answer: string): boolean { re… + ❯ tests/game.test.ts:26:12 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/6]⎯ + + FAIL tests/game.test.ts > 출제와 정답 판정 > 한글 정규형·공백·문장부호만 정리하고 부분 정답은 인정하지 않는다 +AssertionError: expected ' “사 과.” ' to be '사과' // Object.is equality + +Expected: "사과" +Received: " “사 과.” " + + ❯ tests/game.test.ts:30:43 + 28| }); + 29| it('한글 정규형·공백·문장부호만 정리하고 부분 정답은 인정하지 않는다', () => { + 30| expect(normalizeAnswer(' “사 과.” ')).toBe('사과'); + | ^ + 31| expect(isCorrect('사과'.normalize('NFD'), '사과')).toBe(true); + 32| expect(isCorrect('사과 또는 바다', '사과')).toBe(false); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/6]⎯ + + FAIL tests/game.test.ts > 게임 상태·점수·경합 방지 > 재생 완료 이전에는 답변·다음 문제를 허용하지 않으며 중복 채점을 막는다 +Error: 출제 미구현 + ❯ createQuestions src/game.ts:17:106 + 15| } + 16| export type Action = { type: 'PLAY' | 'STARTED' | 'ENDED' | 'NEXT' } |… + 17| export function createQuestions(_seed: number, _mode: Mode, _environme… + | ^ + 18| export function normalizeAnswer(value: string): string { return value;… + 19| export function isCorrect(input: string, answer: string): boolean { re… + ❯ tests/game.test.ts:40:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/6]⎯ + + FAIL tests/game.test.ts > 게임 상태·점수·경합 방지 > 오디오 오류 후 재시도와 다시 듣기는 점수를 변경하지 않는다 +Error: 출제 미구현 + ❯ createQuestions src/game.ts:17:106 + 15| } + 16| export type Action = { type: 'PLAY' | 'STARTED' | 'ENDED' | 'NEXT' } |… + 17| export function createQuestions(_seed: number, _mode: Mode, _environme… + | ^ + 18| export function normalizeAnswer(value: string): string { return value;… + 19| export function isCorrect(input: string, answer: string): boolean { re… + ❯ tests/game.test.ts:56:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/6]⎯ + + FAIL tests/game.test.ts > 게임 상태·점수·경합 방지 > 열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다 +Error: 출제 미구현 + ❯ createQuestions src/game.ts:17:106 + 15| } + 16| export type Action = { type: 'PLAY' | 'STARTED' | 'ENDED' | 'NEXT' } |… + 17| export function createQuestions(_seed: number, _mode: Mode, _environme… + | ^ + 18| export function normalizeAnswer(value: string): string { return value;… + 19| export function isCorrect(input: string, answer: string): boolean { re… + ❯ tests/game.test.ts:69:29 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/6]⎯ + + + Test Files 1 failed (1) + Tests 6 failed (6) + Start at 04:56:04 + Duration 872ms (environment 78%, setup 13%, transform 6%, tests 2%, worker 1%, import 1%) diff --git a/.agent/evidence/03-dsp-green.log b/.agent/evidence/03-dsp-green.log new file mode 100644 index 0000000..799d98f --- /dev/null +++ b/.agent/evidence/03-dsp-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/dsp.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 3 passed (3) + Start at 04:58:27 + Duration 1.23s (environment 51%, tests 37%, setup 8%, transform 3%, worker 1%) diff --git a/.agent/evidence/03-dsp-intermediate.log b/.agent/evidence/03-dsp-intermediate.log new file mode 100644 index 0000000..ff73efa --- /dev/null +++ b/.agent/evidence/03-dsp-intermediate.log @@ -0,0 +1,37 @@ + +> find-word@0.1.0 test +> vitest run tests/dsp.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/dsp.test.ts (3 tests | 1 failed) 82ms + ❯ 상대 dB와 결정론적 배경 합성 (3) + × 여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다 76ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/dsp.test.ts > 상대 dB와 결정론적 배경 합성 > 여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다 +AssertionError: expected -0 to be +0 // Object.is equality + +- Expected ++ Received + +- 0 ++ -0 + + ❯ tests/dsp.test.ts:29:25 + 27| expect(Math.max(...signal.map(Math.abs))).toBeLessThan(1); + 28| // 루프 경계에서 불연속 클릭이 생기지 않도록 양 끝을 0으로 감싼다. + 29| expect(signal[0]).toBe(0); + | ^ + 30| expect(signal.at(-1)).toBe(0); + 31| signatures.add([...signal.slice(0, 100)].join(',')); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 2 passed (3) + Start at 04:57:34 + Duration 912ms (environment 72%, setup 12%, tests 10%, transform 5%, worker 1%, import 1%) diff --git a/.agent/evidence/03-dsp-red.log b/.agent/evidence/03-dsp-red.log new file mode 100644 index 0000000..7f8fb14 --- /dev/null +++ b/.agent/evidence/03-dsp-red.log @@ -0,0 +1,56 @@ + +> find-word@0.1.0 test +> vitest run tests/dsp.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/dsp.test.ts (3 tests | 3 failed) 70ms + ❯ 상대 dB와 결정론적 배경 합성 (3) + × 20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다 5ms + × 평균 전력을 맞추고 원본 배열을 변경하지 않으며 무음은 오류로 알린다 1ms + × 여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다 62ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/dsp.test.ts > 상대 dB와 결정론적 배경 합성 > 20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다 +AssertionError: expected +0 to be close to 6, received difference is 6, but expected 5e-11 + ❯ tests/dsp.test.ts:10:77 + 8| describe('상대 dB와 결정론적 배경 합성', () => { + 9| it('20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다', () => { + 10| for (const level of LEVELS) expect(20 * Math.log10(dbToGain(level.… + | ^ + 11| expect(dbToGain(-6)).toBeCloseTo(.501187, 5); + 12| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL tests/dsp.test.ts > 상대 dB와 결정론적 배경 합성 > 평균 전력을 맞추고 원본 배열을 변경하지 않으며 무음은 오류로 알린다 +AssertionError: expected 0.2738612848738597 to be close to 0.12, received difference is 0.1538612848738597, but expected 5e-7 + ❯ tests/dsp.test.ts:16:25 + 14| const input = new Float32Array([.4, -.2, .1, -.3]); + 15| const output = normalizeRms(input); + 16| expect(rms(output)).toBeCloseTo(.12, 6); + | ^ + 17| expect(output).not.toBe(input); + 18| expect(() => normalizeRms(new Float32Array(4))).toThrow('무음'); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL tests/dsp.test.ts > 상대 dB와 결정론적 배경 합성 > 여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다 +AssertionError: expected +0 to be close to 0.12, received difference is 0.12, but expected 0.000005 + ❯ tests/dsp.test.ts:26:27 + 24| expect(signal).toHaveLength(16000); + 25| expect(signal).toEqual(synthesizeBackground(preset, 16000, 1)); + 26| expect(rms(signal)).toBeCloseTo(.12, 5); + | ^ + 27| expect(Math.max(...signal.map(Math.abs))).toBeLessThan(1); + 28| // 루프 경계에서 불연속 클릭이 생기지 않도록 양 끝을 0으로 감싼다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + + Test Files 1 failed (1) + Tests 3 failed (3) + Start at 04:57:18 + Duration 902ms (environment 72%, setup 12%, tests 9%, transform 5%, worker 1%, import 1%) diff --git a/.agent/evidence/04-audio-green.log b/.agent/evidence/04-audio-green.log new file mode 100644 index 0000000..bb8fc6a --- /dev/null +++ b/.agent/evidence/04-audio-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/audio.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 5 passed (5) + Start at 04:59:31 + Duration 969ms (environment 71%, setup 11%, tests 11%, transform 5%, import 1%, worker 1%) diff --git a/.agent/evidence/04-audio-red.log b/.agent/evidence/04-audio-red.log new file mode 100644 index 0000000..e2287b2 --- /dev/null +++ b/.agent/evidence/04-audio-red.log @@ -0,0 +1,179 @@ + +> find-word@0.1.0 test +> vitest run tests/audio.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/audio.test.ts (5 tests | 5 failed) 22ms + ❯ 오디오 재생 수명과 믹서 (5) + × 생성 시 무음이며 gesture의 play에서만 장치를 연다 7ms + × 동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다 8ms + × 로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다 3ms + × HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다 1ms + × 전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다 1ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > 생성 시 무음이며 gesture의 play에서만 장치를 연다 +AssertionError: expected "vi.fn()" to be called once, but got 0 times + ❯ tests/audio.test.ts:35:23 + 33| const started = vi.fn(); + 34| const done = f.engine.play(question, true, started); + 35| expect(f.factory).toHaveBeenCalledOnce(); + | ^ + 36| expect(f.context.resume).toHaveBeenCalledOnce(); + 37| await flush(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/6]⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > 동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다 +AssertionError: expected [Function] to throw error including '재생 중' but got '오디오 미구현' + +Expected: "재생 중" +Received: "오디오 미구현" + + ❯ tests/audio.test.ts:54:58 + 52| const done = f.engine.play(question, false, vi.fn()); + 53| const cancelled = expect(done).rejects.toMatchObject({ name: 'Abor… + 54| await expect(f.engine.play(question, false, vi.fn())).rejects.toTh… + | ^ + 55| await flush(); + 56| f.engine.stop(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/6]⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > 동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다 +Error: Promise returned by `expect(actual).rejects.toMatchObject(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes: + +await expect(actual).rejects.toMatchObject(expected) + + ❯ tests/audio.test.ts:53:35 + 51| const f = fixture(); + 52| const done = f.engine.play(question, false, vi.fn()); + 53| const cancelled = expect(done).rejects.toMatchObject({ name: 'Abor… + | ^ + 54| await expect(f.engine.play(question, false, vi.fn())).rejects.toTh… + 55| await flush(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/6]⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > 로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다 +AssertionError: expected Error: 오디오 미구현 to match object { name: 'AbortError' } + +- Expected ++ Received + +- { +- "name": "AbortError", ++ Error { ++ "message": "오디오 미구현", + } + + ❯ tests/audio.test.ts:68:35 + 66| f.fetcher.mockImplementationOnce(() => new Promise(resolve => { re… + 67| const done = f.engine.play(question, false, vi.fn()); + 68| const cancelled = expect(done).rejects.toMatchObject({ name: 'Abor… + | ^ + 69| await flush(); + 70| f.engine.stop(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/6]⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다 +AssertionError: expected [Function] to throw error including '음성 파일' but got '오디오 미구현' + +Expected: "음성 파일" +Received: "오디오 미구현" + + ❯ tests/audio.test.ts:79:58 + 77| const f = fixture(); + 78| f.fetcher.mockResolvedValueOnce({ ok: false } as Response); + 79| await expect(f.engine.play(question, false, vi.fn())).rejects.toTh… + | ^ + 80| f.context.decodeAudioData.mockRejectedValueOnce(new Error('decode'… + 81| await expect(f.engine.play(question, false, vi.fn())).rejects.toTh… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/6]⎯ + + FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > 전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다 +TypeError: Cannot read properties of undefined (reading 'gain') + ❯ tests/audio.test.ts:94:24 + 92| const done = f.engine.play(question, false, vi.fn()); + 93| await flush(); + 94| expect(f.gains[0]!.gain.value).toBe(.3); + | ^ + 95| f.engine.setVolume(8); + 96| expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/6]⎯ + +⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯ + +Vitest caught 3 unhandled errors during the test run. +This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected. + +⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯ +Error: 오디오 미구현 + ❯ AudioEngine.play src/audio/engine.ts:13:99 + 11| export class AudioEngine implements AudioPort { + 12| constructor(_factory: () => AudioContext = () => new AudioContext(),… + 13| async play(_question: Question, _intro: boolean, _onStarted: () => v… + | ^ + 14| stop(): void { /* RED 확인을 위한 인터페이스 자리만 확보한다. */ } + 15| dispose(): void { /* 정리 동작은 취소 테스트 후 구현한다. */ } + ❯ tests/audio.test.ts:34:27 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:1632:35 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:2794:26 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3334:20 + ❯ runWithCancel node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3329:10 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3314:20 + ❯ runWithTimeout node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3272:10 + +This error originated in "tests/audio.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. + +⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯ +AssertionError: expected Error: 오디오 미구현 to match object { name: 'AbortError' } + ❯ Assertion.__VITEST_REJECTS__ node_modules/vitest/dist/chunks/index.uwTEEBsB.js:2480:33 + ❯ Assertion.propertyGetter node_modules/chai/index.js:1607:27 + ❯ Object.proxyGetter [as get] node_modules/chai/index.js:1696:22 + ❯ tests/audio.test.ts:53:35 + 51| const f = fixture(); + 52| const done = f.engine.play(question, false, vi.fn()); + 53| const cancelled = expect(done).rejects.toMatchObject({ name: 'Abor… + | ^ + 54| await expect(f.engine.play(question, false, vi.fn())).rejects.toTh… + 55| await flush(); + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:1632:35 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:2794:26 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3334:20 + ❯ runWithCancel node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3329:10 + +This error originated in "tests/audio.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. + +⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯ +Error: 오디오 미구현 + ❯ AudioEngine.play src/audio/engine.ts:13:99 + 11| export class AudioEngine implements AudioPort { + 12| constructor(_factory: () => AudioContext = () => new AudioContext(),… + 13| async play(_question: Question, _intro: boolean, _onStarted: () => v… + | ^ + 14| stop(): void { /* RED 확인을 위한 인터페이스 자리만 확보한다. */ } + 15| dispose(): void { /* 정리 동작은 취소 테스트 후 구현한다. */ } + ❯ tests/audio.test.ts:92:27 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:1632:35 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:2794:26 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3334:20 + ❯ runWithCancel node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3329:10 + ❯ node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3314:20 + ❯ runWithTimeout node_modules/vitest/dist/chunks/run.Cm2sd7-x.js:3272:10 + +This error originated in "tests/audio.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + + + Test Files 1 failed (1) + Tests 5 failed (5) + Errors 3 errors + Start at 04:58:29 + Duration 885ms (environment 75%, setup 13%, transform 7%, tests 3%, import 1%, worker 1%) diff --git a/.agent/evidence/05-assets-red.log b/.agent/evidence/05-assets-red.log new file mode 100644 index 0000000..1b6b8cb --- /dev/null +++ b/.agent/evidence/05-assets-red.log @@ -0,0 +1,30 @@ + +> find-word@0.1.0 test +> vitest run tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/assets.test.ts (1 test | 1 failed) 7ms + ❯ 저장한 한국어 음성 자산 (1) + × 모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다 5ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/assets.test.ts > 저장한 한국어 음성 자산 > 모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다 +Error: ENOENT: no such file or directory, open 'public/audio/manifest.json' + ❯ tests/assets.test.ts:11:33 + 9| describe('저장한 한국어 음성 자산', () => { + 10| it('모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다', () => { + 11| const manifest = JSON.parse(readFileSync('public/audio/manifest.js… + | ^ + 12| expect(manifest.generator).toContain('eSpeak NG'); + 13| const expected = [{ file: 'intro.wav', text: INTRO }, ...PATTERNS.… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed (1) + Start at 04:59:59 + Duration 280ms (setup 65%, transform 24%, import 5%, tests 4%, worker 3%) diff --git a/.agent/evidence/06-ui-05-assets-green.log b/.agent/evidence/06-ui-05-assets-green.log new file mode 100644 index 0000000..358dd51 --- /dev/null +++ b/.agent/evidence/06-ui-05-assets-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 2 passed (2) + Tests 7 passed (7) + Start at 05:07:53 + Duration 3.03s (tests 71%, environment 18%, setup 7%, transform 3%, import 1%) diff --git a/.agent/evidence/06-ui-intermediate.log b/.agent/evidence/06-ui-intermediate.log new file mode 100644 index 0000000..c50c8da --- /dev/null +++ b/.agent/evidence/06-ui-intermediate.log @@ -0,0 +1,638 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (6 tests | 1 failed) 1799ms + ❯ 시작부터 결과까지 사용자 흐름 (6) + × 선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다 49ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다 +TestingLibraryElementError: Found multiple elements with the role "radio" and name `/직접 입력/` + +Here are the matching elements: + +Ignored nodes: comments, script, style + + +Ignored nodes: comments, script, style + + +(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). + +Ignored nodes: comments, script, style + + 
 +  + 본문으로 건너뛰기 +  +  +  +  +  +  +  +  +  + 

 + 단어를 맞춰라 + 

 +  + v0.1 +  + 
 +  +  + 플레이 방법  +  + ↗ +  +  +  + 소리에 집중하는 시간 +  +  +  +  +  +  +  +  귀를 기울이면, 발견하는 즐거움 + 

 +  + 소음 속 작은 단어, + 
 +  + 들리시나요? +  +  +  + 도시의 소리 사이에 에스가 숨겨 둔 단어. + 
 + 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. + 

 +  +  + 10개의 질문 +  +  + 5단계 도전 +  +  + 나만의 속도로 +  +  +  +  +  +  +  +  에스의 사운드 룸 +  +  + 01 / 08 +  +  +  +  +  + ♪ + 
 +  + ✳ +  +  + 행운의 단어를 찾아보거라! +  +  +  +  +  +  +  +  + S +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 소음 사이, 숨겨진 한마디 +  +  +  +  +  +  +  + 01 +  + 
 +  + 나의 소리 탐험 준비 +  + 

 + 편한 방식으로 시작하세요. 난이도는 조금씩 높아져요. + 

 + 
 +  + 약 3–5분 +  +  +  +  +  + 어떻게 답할까요? +  +  +  +  +  + ◈ +  +  + 골고루 풀기 +  +  + 선택 + 직접 입력 +  +  +  +  +  + ☷ +  +  + 보기에서 선택 +  +  + 4개 단어 중 하나 +  +  +  +  +  + ✎ +  +  + 직접 입력 +  +  + 기억한 단어 쓰기 +  +  +  +  +  +  + 배경 환경 +  +  +  + 여덟 가지 일상 속으로 +  +  + 길거리 +  +  + 쇼핑몰 +  +  + 극장 +  +  + 백화점 +  +  + 시장 +  +  + 지하철 +  +  + 카페 +  +  + 비 오는 거리 +  +  + 

 + 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요. + 

 +  +  + 길거리 +  +  + 카페 +  +  + 지하철 +  +  + +5개 환경 +  +  +  +  +  + 

 +  + ♬ +  +  편안한 작은 음량으로 시작해 보세요. + 

 +  + 소리 시작  +  + → +  +  +  +  +  +  + 작은 단어를 발견하는 방법 +  +  + 
 +  + 01 +  + 

 + 먼저, 귀 기울이기 + 

 + 

 + 에스의 안내와 소음 속 문장을 들어요. + 
 + 처음에는 또렷하게, 점점 더 작게. + 

 + 
 + 
 +  + 02 +  + 

 + 기억한 단어 찾기 + 

 + 

 + 소리가 끝나면 질문이 나타나요. + 
 + 보기에서 고르거나 직접 입력해요. + 

 + 
 + 
 +  + 03 +  + 

 + 나의 발견 돌아보기 + 

 + 

 + 열 문제 뒤 나의 결과를 확인해요. + 
 + 놓쳤다면 언제든 다시 들어도 좋아요. + 

 + 
 +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  +  +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ getElementError node_modules/@testing-library/dom/dist/query-helpers.js:20:35 + ❯ getMultipleElementsFoundError node_modules/@testing-library/dom/dist/query-helpers.js:23:10 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:55:13 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:87:31 + 85| it('선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다', async () => { + 86| const f = fixture(); + 87| await f.user.click(screen.getByRole('radio', { name: /직접 입력/ })); + | ^ + 88| await f.user.selectOptions(screen.getByLabelText('배경 환경'), 'rain'); + 89| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed | 1 passed (2) + Tests 1 failed | 6 passed (7) + Start at 05:07:31 + Duration 2.86s (tests 69%, environment 19%, setup 6%, transform 3%, import 2%) diff --git a/.agent/evidence/06-ui-red.log b/.agent/evidence/06-ui-red.log new file mode 100644 index 0000000..d7e3ac9 --- /dev/null +++ b/.agent/evidence/06-ui-red.log @@ -0,0 +1,215 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (6 tests | 6 failed) 192ms + ❯ 시작부터 결과까지 사용자 흐름 (6) + × 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 147ms + × 재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다 11ms + × 혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다 9ms + × 오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다 10ms + × 선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다 7ms + × 음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다 6ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 +TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name "단어를 맞춰라" + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:25:19 + 23| it('브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다', () => { + 24| const f = fixture(); + 25| expect(screen.getByRole('heading', { name: '단어를 맞춰라' })).toBeInThe… + | ^ + 26| expect(screen.getByText('Designed by OZ at HearT-M Medical Inc.'))… + 27| expect(screen.getByText('v0.1')).toBeInTheDocument(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/6]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다 +TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/소리 시작/` + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:33:31 + 31| it('재생 동안 답을 숨기고 중복 클릭을 막으며 끝나면 네 선택지에 접근할 수 있다', async () => { + 32| const f = fixture(); + 33| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + | ^ + 34| expect(f.audio.play).toHaveBeenCalledTimes(1); + 35| expect(screen.queryByRole('group', { name: '답 선택' })).not.toBeInTh… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/6]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 혼합 모드 열 문제를 키보드·텍스트로 풀고 결과에서 다시 시작한다 +TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/소리 시작/` + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:51:31 + 49| const f = fixture(); + 50| const questions = createQuestions(42, 'mixed'); + 51| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + | ^ + 52| for (let i = 0; i < questions.length; i++) { + 53| if (i) await f.user.click(screen.getByRole('button', { name: /문장… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/6]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다 +TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/소리 시작/` + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:76:31 + 74| it('오디오 오류를 알리고 재시도하며 나가면 장치를 정리한다', async () => { + 75| const f = fixture(); + 76| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + | ^ + 77| await f.fail(); + 78| expect(screen.getByRole('alert')).toHaveTextContent(/소리를 재생하지 못했어요… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/6]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다 +TestingLibraryElementError: Unable to find an accessible element with the role "radio" and name `/직접 입력/` + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:87:31 + 85| it('선택한 주관식·배경 환경으로 시작하고 빈 답변을 제출하지 않는다', async () => { + 86| const f = fixture(); + 87| await f.user.click(screen.getByRole('radio', { name: /직접 입력/ })); + | ^ + 88| await f.user.selectOptions(screen.getByLabelText('배경 환경'), 'rain'); + 89| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/6]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다 +TestingLibraryElementError: Unable to find an accessible element with the role "slider" and name "전체 음량" + +Here are the accessible roles: + + main: + + Name "": + 
 + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 + 
 + 
 + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:100:29 + 98| it('음량 조절이 엔진에 전달되고 세션 종료 뒤 늦은 완료는 무시한다', async () => { + 99| const f = fixture(); + 100| fireEvent.change(screen.getByRole('slider', { name: '전체 음량' }), { … + | ^ + 101| expect(f.audio.setVolume).toHaveBeenCalledWith(.2); + 102| await f.user.click(screen.getByRole('button', { name: /소리 시작/ })); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/6]⎯ + + + Test Files 1 failed (1) + Tests 6 failed (6) + Start at 05:01:34 + Duration 1.08s (environment 60%, tests 20%, setup 10%, transform 5%, import 4%, worker 1%) diff --git a/.agent/evidence/07-build-green.log b/.agent/evidence/07-build-green.log new file mode 100644 index 0000000..8168d3d --- /dev/null +++ b/.agent/evidence/07-build-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/build.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 05:09:30 + Duration 438ms (tests 57%, setup 32%, transform 9%, worker 1%, import 1%) diff --git a/.agent/evidence/07-build-red.log b/.agent/evidence/07-build-red.log new file mode 100644 index 0000000..751fe55 --- /dev/null +++ b/.agent/evidence/07-build-red.log @@ -0,0 +1,34 @@ + +> find-word@0.1.0 test +> vitest run tests/build.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/build.test.ts (1 test | 1 failed) 61ms + ❯ production 자산 검증기 (1) + × 101개 음성 포함을 확인하고 누락·변조는 실패시킨다 60ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/build.test.ts > production 자산 검증기 > 101개 음성 포함을 확인하고 누락·변조는 실패시킨다 +AssertionError: expected '0' to be '101' // Object.is equality + +Expected: "101" +Received: "0" + + ❯ tests/build.test.ts:16:31 + 14| try { + 15| cpSync('public/audio', join(temporary, 'audio'), { recursive: tr… + 16| expect(verify().trim()).toBe('101'); + | ^ + 17| writeFileSync(join(temporary, 'audio/intro.wav'), 'broken'); + 18| expect(verify).toThrow(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed (1) + Start at 05:08:45 + Duration 318ms (setup 52%, tests 31%, transform 14%, worker 2%, import 2%) diff --git a/.agent/evidence/08-rule-green.log b/.agent/evidence/08-rule-green.log new file mode 100644 index 0000000..b3821fb --- /dev/null +++ b/.agent/evidence/08-rule-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 4 passed (4) + Start at 05:11:10 + Duration 878ms (environment 78%, setup 14%, transform 5%, tests 1%, worker 1%) diff --git a/.agent/evidence/08-rule-red.log b/.agent/evidence/08-rule-red.log new file mode 100644 index 0000000..eda4588 --- /dev/null +++ b/.agent/evidence/08-rule-red.log @@ -0,0 +1,32 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/content.test.ts (4 tests | 1 failed) 13ms + ❯ 저장된 문제·환경 데이터 (4) + × 오답마다 서로 다른 변형 혼동 난이도를 추적할 수 있다 6ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > 오답마다 서로 다른 변형 혼동 난이도를 추적할 수 있다 +TypeError: actual value must be number or bigint, received "undefined" + ❯ tests/content.test.ts:10:42 + 8| WORDS.forEach(entry => { + 9| const ranks = entry.distractors.map(d => RULE_DIFFICULTY[d.rule]… + 10| ranks.forEach(rank => expect(rank).toBeGreaterThan(0)); + | ^ + 11| expect(new Set(ranks).size).toBe(3); + 12| }); + ❯ tests/content.test.ts:10:13 + ❯ tests/content.test.ts:8:11 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 3 passed (4) + Start at 05:10:58 + Duration 885ms (environment 78%, setup 13%, transform 6%, tests 2%, worker 1%, import 1%) diff --git a/.agent/evidence/09-ime-green.log b/.agent/evidence/09-ime-green.log new file mode 100644 index 0000000..1fd648a --- /dev/null +++ b/.agent/evidence/09-ime-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 7 passed (7) + Start at 05:16:05 + Duration 2.96s (tests 71%, environment 20%, setup 4%, transform 4%, import 1%) diff --git a/.agent/evidence/09-ime-red.log b/.agent/evidence/09-ime-red.log new file mode 100644 index 0000000..ef374dc --- /dev/null +++ b/.agent/evidence/09-ime-red.log @@ -0,0 +1,36 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (7 tests | 1 failed) 2021ms + ❯ 시작부터 결과까지 사용자 흐름 (7) + × 한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다 345ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다 +Error: expect(element).not.toBeInTheDocument() + +expected document not to contain element, found

+ 정답이에요! +

instead + ❯ tests/App.test.tsx:56:46 + 54| fireEvent.change(textbox, { target: { value: createQuestions(42, "… + 55| fireEvent.submit(textbox.closest("form")!); + 56| expect(screen.queryByText("정답이에요!")).not.toBeInTheDocument(); + | ^ + 57| fireEvent.compositionEnd(textbox); + 58| fireEvent.submit(textbox.closest("form")!); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 6 passed (7) + Start at 05:15:12 + Duration 2.96s (tests 71%, environment 20%, transform 4%, setup 3%, import 1%) diff --git a/.agent/evidence/10-ime-reset-green.log b/.agent/evidence/10-ime-reset-green.log new file mode 100644 index 0000000..c02446e --- /dev/null +++ b/.agent/evidence/10-ime-reset-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 한글 조합 도중 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed | 7 skipped (8) + Start at 05:17:57 + Duration 1.38s (environment 44%, tests 38%, setup 8%, transform 7%, import 3%, worker 1%) diff --git a/.agent/evidence/10-ime-reset-red.log b/.agent/evidence/10-ime-reset-red.log new file mode 100644 index 0000000..4d813e0 --- /dev/null +++ b/.agent/evidence/10-ime-reset-red.log @@ -0,0 +1,395 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 한글 조합 도중 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (8 tests | 1 failed | 7 skipped) 488ms + ❯ 시작부터 결과까지 사용자 흐름 (8) + × 한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다 487ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다 +TestingLibraryElementError: Unable to find an element with the text: 정답이에요!. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + 본문으로 건너뛰기 +  +  +  +  +  +  +  +  +  + 

 + 단어를 맞춰라 + 

 +  + v0.1 +  + 
 +  +  + 처음으로 +  +  + 소리에 집중하는 시간 +  +  +  +  +  +  + 나의 소리 탐험 +  +  + 문제  +  + 01 +  +  / 10 +  +  +  +  +  +  + 지금 듣고 있는 공간 + 

 + 

 + 길거리 + 

 + 

 + 지나가는 차와 도시의 웅성임 + 

 +  +  +  +  +  +  +  + S +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 1 + 단계 ·  + + + 6 +  dB +  +  + 음성 / 배경의 상대 음량 + 
 + 소리에 익숙해지기 + 

 +  +  + 점수  +  + 0 +  +  +  + 연속 정답  +  + 0 +  +  +  +  +  +  +  + 마지막 단어 +  +  + 주관식 +  +  +  + 마지막에 나온 단어는 무엇인가요? +  +  + 기억나는 단어 하나를 입력해 주세요. + 

 + 
 +  + 들은 단어 +  +  +  + 정답 확인 +  +  +  +  + ↻ 다시 듣기 +  +  + 다시 들어도 점수는 줄어들지 않아요. +  +  +  +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  + 
 +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:58:19 + 56| await f.user.type(screen.getByRole("textbox", { name: "들은 단어" }), … + 57| await f.user.click(screen.getByRole("button", { name: "정답 확인" })); + 58| expect(screen.getByText("정답이에요!")).toBeInTheDocument(); + | ^ + 59| }); + 60| it("한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다", async () => { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 7 skipped (8) + Start at 05:17:35 + Duration 1.42s (environment 43%, tests 38%, transform 8%, setup 7%, import 3%, worker 1%) diff --git a/.agent/evidence/11-provenance-green.log b/.agent/evidence/11-provenance-green.log new file mode 100644 index 0000000..7e2af89 --- /dev/null +++ b/.agent/evidence/11-provenance-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 2 passed (2) + Start at 05:18:31 + Duration 736ms (tests 79%, setup 17%, transform 2%, import 1%, worker 1%) diff --git a/.agent/evidence/11-provenance-red.log b/.agent/evidence/11-provenance-red.log new file mode 100644 index 0000000..cbbe1d5 --- /dev/null +++ b/.agent/evidence/11-provenance-red.log @@ -0,0 +1,30 @@ + +> find-word@0.1.0 test +> vitest run tests/assets.test.ts -t 도구 버전 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/assets.test.ts (2 tests | 1 failed | 1 skipped) 8ms + ❯ 저장한 한국어 음성 자산 (2) + × 실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다 6ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/assets.test.ts > 저장한 한국어 음성 자산 > 실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다 +TypeError: .toMatch() expects to receive a string, but got undefined + ❯ tests/assets.test.ts:12:43 + 10| it("실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다", () => { + 11| const manifest = JSON.parse(readFileSync("public/audio/manifest.js… + 12| expect(manifest.toolVersions?.espeak).toMatch(/eSpeak NG.*\d+\.\d+… + | ^ + 13| expect(manifest.toolVersions?.ffmpeg).toMatch(/ffmpeg version \d+\… + 14| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 1 skipped (2) + Start at 05:17:59 + Duration 285ms (setup 63%, transform 25%, import 5%, tests 5%, worker 2%) diff --git a/.agent/evidence/12-ime-replay-green.log b/.agent/evidence/12-ime-replay-green.log new file mode 100644 index 0000000..f3fc60f --- /dev/null +++ b/.agent/evidence/12-ime-replay-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 한글 조합 도중 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed | 7 skipped (8) + Start at 05:19:29 + Duration 1.45s (environment 43%, tests 39%, setup 8%, transform 6%, import 3%, worker 1%) diff --git a/.agent/evidence/12-ime-replay-red.log b/.agent/evidence/12-ime-replay-red.log new file mode 100644 index 0000000..4601ffa --- /dev/null +++ b/.agent/evidence/12-ime-replay-red.log @@ -0,0 +1,395 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 한글 조합 도중 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (8 tests | 1 failed | 7 skipped) 542ms + ❯ 시작부터 결과까지 사용자 흐름 (8) + × 한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다 539ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 한글 조합 도중 처음으로 돌아가도 새 세션의 입력은 정상 제출된다 +TestingLibraryElementError: Unable to find an element with the text: 정답이에요!. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + 본문으로 건너뛰기 +  +  +  +  +  +  +  +  +  + 

 + 단어를 맞춰라 + 

 +  + v0.1 +  + 
 +  +  + 처음으로 +  +  + 소리에 집중하는 시간 +  +  +  +  +  +  + 나의 소리 탐험 +  +  + 문제  +  + 01 +  +  / 10 +  +  +  +  +  +  + 지금 듣고 있는 공간 + 

 + 

 + 길거리 + 

 + 

 + 지나가는 차와 도시의 웅성임 + 

 +  +  +  +  +  +  +  + S +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 1 + 단계 ·  + + + 6 +  dB +  +  + 음성 / 배경의 상대 음량 + 
 + 소리에 익숙해지기 + 

 +  +  + 점수  +  + 0 +  +  +  + 연속 정답  +  + 0 +  +  +  +  +  +  +  + 마지막 단어 +  +  + 주관식 +  +  +  + 마지막에 나온 단어는 무엇인가요? +  +  + 기억나는 단어 하나를 입력해 주세요. + 

 + 
 +  + 들은 단어 +  +  +  + 정답 확인 +  +  +  +  + ↻ 다시 듣기 +  +  + 다시 들어도 점수는 줄어들지 않아요. +  +  +  +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  + 
 +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:61:19 + 59| await f.user.type(screen.getByRole("textbox", { name: "들은 단어" }), … + 60| await f.user.click(screen.getByRole("button", { name: "정답 확인" })); + 61| expect(screen.getByText("정답이에요!")).toBeInTheDocument(); + | ^ + 62| }); + 63| it("한글 조합을 확정하는 Enter는 채점하지 않고 다음 제출을 기다린다", async () => { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 7 skipped (8) + Start at 05:18:58 + Duration 1.41s (environment 43%, tests 42%, setup 7%, transform 4%, import 3%, worker 1%) diff --git a/.agent/evidence/13-heading-build-after.log b/.agent/evidence/13-heading-build-after.log new file mode 100644 index 0000000..995fabf --- /dev/null +++ b/.agent/evidence/13-heading-build-after.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-Bq8F62nv.css 14.90 kB │ gzip: 4.30 kB +dist/assets/index-DKxtGloM.js 244.26 kB │ gzip: 76.95 kB + +✓ built in 157ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/13-heading-build-before.log b/.agent/evidence/13-heading-build-before.log new file mode 100644 index 0000000..eb058ef --- /dev/null +++ b/.agent/evidence/13-heading-build-before.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-BEQhGx9x.css 14.87 kB │ gzip: 4.29 kB +dist/assets/index-ZqUFJyNr.js 244.26 kB │ gzip: 76.95 kB + +✓ built in 162ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/13-heading-green.log b/.agent/evidence/13-heading-green.log new file mode 100644 index 0000000..7657bff --- /dev/null +++ b/.agent/evidence/13-heading-green.log @@ -0,0 +1,14 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --project=mobile + +[WebServer] (node:752303) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 1 test using 1 worker + +(node:752323) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [mobile] › tests/browser/ui.spec.ts:6:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (13.6s) + + 1 passed (14.7s) diff --git a/.agent/evidence/13-heading-red.log b/.agent/evidence/13-heading-red.log new file mode 100644 index 0000000..311485a --- /dev/null +++ b/.agent/evidence/13-heading-red.log @@ -0,0 +1,112 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --project=mobile + +[WebServer] (node:752009) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 1 test using 1 worker + +(node:752029) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [mobile] › tests/browser/ui.spec.ts:6:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (13.7s) + + + 1) [mobile] › tests/browser/ui.spec.ts:6:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 ─────────────────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 7 + + Received + 7 + + Array [ + Object { + "title": "단어를 맞춰라", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "소음 속 작은 단어,들리시나요?", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "나의 소리 탐험 준비", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "작은 단어를 발견하는 방법", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "먼저, 귀 기울이기", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "기억한 단어 찾기", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "나의 발견 돌아보기", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + ] + + 17 | ); + 18 | const homeHeadings = await wordBreaks(); + > 19 | expect.soft(homeHeadings).toEqual( + | ^ + 20 | homeHeadings.map((heading) => ({ ...heading, wordBreak: "keep-all" })), + 21 | ); + 22 | await page.getByRole("button", { name: /소리 시작/ }).click(); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:19:29 + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 3 + + Received + 3 + + Array [ + Object { + "title": "단어를 맞춰라", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "길거리", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + Object { + "title": "말을 바로잡아 알려준 단어는 무엇인가요?", + - "wordBreak": "keep-all", + + "wordBreak": "normal", + }, + ] + + 30 | }); + 31 | const gameHeadings = await wordBreaks(); + > 32 | expect.soft(gameHeadings).toEqual( + | ^ + 33 | gameHeadings.map((heading) => ({ ...heading, wordBreak: "keep-all" })), + 34 | ); + 35 | expect( + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:32:29 + + Error Context: test-results/ui-한국어-제목은-단어-중간에서-줄바꿈하지-않는다-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-한국어-제목은-단어-중간에서-줄바꿈하지-않는다-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/ui-한국어-제목은-단어-중간에서-줄바꿈하지-않는다-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 1 failed + [mobile] › tests/browser/ui.spec.ts:6:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 ──────────────────────────── diff --git a/.agent/evidence/14-snr-browser-green.log b/.agent/evidence/14-snr-browser-green.log new file mode 100644 index 0000000..5324883 --- /dev/null +++ b/.agent/evidence/14-snr-browser-green.log @@ -0,0 +1,14 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --project=mobile --grep 난이도 설명 + +[WebServer] (node:752930) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 1 test using 1 worker + +(node:752950) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [mobile] › tests/browser/ui.spec.ts:4:1 › 난이도 설명은 모바일에서도 보인다 (803ms) + + 1 passed (2.0s) diff --git a/.agent/evidence/14-snr-browser-red.log b/.agent/evidence/14-snr-browser-red.log new file mode 100644 index 0000000..c9534cd --- /dev/null +++ b/.agent/evidence/14-snr-browser-red.log @@ -0,0 +1,95 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --project=mobile --grep 난이도 설명 + +[WebServer] (node:752587) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 1 test using 1 worker + +(node:752608) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [mobile] › tests/browser/ui.spec.ts:4:1 › 난이도 설명은 모바일에서도 보인다 (4.0s) + + + 1) [mobile] › tests/browser/ui.spec.ts:4:1 › 난이도 설명은 모바일에서도 보인다 ────────────────────────────────── + + Error: expect(locator).toBeVisible() failed + + Locator: locator('.db-note') + Expected: visible + Received: hidden + Timeout: 1000ms + + Call log: + - Expect "soft toBeVisible" locator('.db-note') with timeout 1000ms + - waiting for locator('.db-note') + 10 × locator resolved to

…

+ - unexpected value "hidden" + + + 7 | // DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로 + 8 | // 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다. + > 9 | await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 }); + | ^ + 10 | await expect.soft(page.getByText("1단계 · SNR +6 dB")).toBeVisible({ timeout: 1000 }); + 11 | await expect.soft(page.locator(".db-note")).toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 }); + 12 | }); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:9:47 + + Error: expect(locator).toBeVisible() failed + + Locator: getByText('1단계 · SNR +6 dB') + Expected: visible + Timeout: 1000ms + Error: element(s) not found + + Call log: + - Expect "soft toBeVisible" getByText('1단계 · SNR +6 dB') with timeout 1000ms + - waiting for getByText('1단계 · SNR +6 dB') + + + 8 | // 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다. + 9 | await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 }); + > 10 | await expect.soft(page.getByText("1단계 · SNR +6 dB")).toBeVisible({ timeout: 1000 }); + | ^ + 11 | await expect.soft(page.locator(".db-note")).toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 }); + 12 | }); + 13 | + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:10:56 + + Error: expect(locator).toContainText(expected) failed + + Locator: locator('.db-note') + Expected substring: "음성이 배경보다 6 dB 커요." + Received string: "음성 / 배경의 상대 음량소리에 익숙해지기" + Timeout: 1000ms + + Call log: + - Expect "soft toContainText" locator('.db-note') with timeout 1000ms + - waiting for locator('.db-note') + 11 × locator resolved to

…

+ - unexpected value "음성 / 배경의 상대 음량소리에 익숙해지기" + + + 9 | await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 }); + 10 | await expect.soft(page.getByText("1단계 · SNR +6 dB")).toBeVisible({ timeout: 1000 }); + > 11 | await expect.soft(page.locator(".db-note")).toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 }); + | ^ + 12 | }); + 13 | + 14 | // jsdom은 줄바꿈을 계산하지 않는다. 실제 Chrome에서 한국어 제목의 + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:11:47 + + Error Context: test-results/ui-난이도-설명은-모바일에서도-보인다-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-난이도-설명은-모바일에서도-보인다-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/ui-난이도-설명은-모바일에서도-보인다-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 1 failed + [mobile] › tests/browser/ui.spec.ts:4:1 › 난이도 설명은 모바일에서도 보인다 ─────────────────────────────────── diff --git a/.agent/evidence/14-snr-build.log b/.agent/evidence/14-snr-build.log new file mode 100644 index 0000000..e08f04b --- /dev/null +++ b/.agent/evidence/14-snr-build.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-Cqbkt0Zp.css 14.92 kB │ gzip: 4.30 kB +dist/assets/index-BlNwW4oG.js 244.36 kB │ gzip: 76.99 kB + +✓ built in 169ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/14-snr-green.log b/.agent/evidence/14-snr-green.log new file mode 100644 index 0000000..70c13b8 --- /dev/null +++ b/.agent/evidence/14-snr-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 다섯 난이도의 SNR + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed | 8 skipped (9) + Start at 05:31:02 + Duration 3.24s (tests 61%, environment 27%, transform 5%, setup 5%, import 2%, worker 1%) diff --git a/.agent/evidence/14-snr-red.log b/.agent/evidence/14-snr-red.log new file mode 100644 index 0000000..4b9ace1 --- /dev/null +++ b/.agent/evidence/14-snr-red.log @@ -0,0 +1,104 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 다섯 난이도의 SNR + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (9 tests | 1 failed | 8 skipped) 1328ms + ❯ 시작부터 결과까지 사용자 흐름 (9) + × 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 1327ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 +Error: expect(received).toBeInTheDocument() + +received value must be an HTMLElement or an SVGElement. +Received has type: Null +Received has value: null + ❯ tests/App.test.tsx:65:73 + 63| await f.finish(); + 64| const [label, description] = levels[Math.floor(index / 2)]!; + 65| expect.soft(screen.queryByText(`${question.level}단계 · ${label}`)… + | ^ + 66| expect.soft(screen.queryByText(description, { exact: false })).t… + 67| await f.user.click(screen.getByRole("button", { name: new RegExp… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/26]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 +Error: expect(received).toBeInTheDocument() + +received value must be an HTMLElement or an SVGElement. +Received has type: Null +Received has value: null + ❯ tests/App.test.tsx:66:70 + 64| const [label, description] = levels[Math.floor(index / 2)]!; + 65| expect.soft(screen.queryByText(`${question.level}단계 · ${label}`)… + 66| expect.soft(screen.queryByText(description, { exact: false })).t… + | ^ + 67| await f.user.click(screen.getByRole("button", { name: new RegExp… + 68| await f.user.click(screen.getByRole("button", { name: index === … + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/26]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 +Error: expect(received).toBeInTheDocument() + +received value must be an HTMLElement or an SVGElement. +Received has type: Null +Received has value: null + ❯ tests/App.test.tsx:71:69 + 69| } + 70| const table = within(screen.getByRole("table", { name: "난이도별 결과" }… + 71| expect.soft(table.queryByRole("columnheader", { name: "SNR" })).to… + | ^ + 72| for (const [label] of levels) { + 73| expect.soft(table.queryByRole("cell", { name: label })).toBeInTh… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/26]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 다섯 난이도의 SNR과 음성·배경 크기 관계를 설명하고 결과에도 표시한다 +Error: expect(received).toBeInTheDocument() + +received value must be an HTMLElement or an SVGElement. +Received has type: Null +Received has value: null + ❯ tests/App.test.tsx:73:63 + 71| expect.soft(table.queryByRole("columnheader", { name: "SNR" })).to… + 72| for (const [label] of levels) { + 73| expect.soft(table.queryByRole("cell", { name: label })).toBeInTh… + | ^ + 74| } + 75| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/26]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 8 skipped (9) + Start at 05:30:12 + Duration 2.39s (tests 63%, environment 27%, setup 5%, transform 2%, import 2%) diff --git a/.agent/evidence/15-volume-browser-green.log b/.agent/evidence/15-volume-browser-green.log new file mode 100644 index 0000000..7fb864c --- /dev/null +++ b/.agent/evidence/15-volume-browser-green.log @@ -0,0 +1,17 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --grep 전체 음량 + +[WebServer] (node:753690) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 2 tests using 2 workers + +(node:753711) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:753710) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.6s) + ✓ 2 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.7s) + + 2 passed (3.0s) diff --git a/.agent/evidence/15-volume-browser-red.log b/.agent/evidence/15-volume-browser-red.log new file mode 100644 index 0000000..a3d0905 --- /dev/null +++ b/.agent/evidence/15-volume-browser-red.log @@ -0,0 +1,147 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --grep 전체 음량 + +[WebServer] (node:753191) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 2 tests using 2 workers + +(node:753211) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:753212) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 2 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.8s) + ✘ 1 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.8s) + + + 1) [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ───────────────────── + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 919.5625 + Received: 1203.578125 + + 12 | expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); + 13 | expect.soft(control.x + control.width).toBeLessThanOrEqual(bounds.x + bounds.width); + > 14 | expect.soft(control.y + control.height).toBeLessThanOrEqual(bounds.y + bounds.height); + | ^ + 15 | }; + 16 | await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + at expectInside (/home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:14:45) + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:16:3 + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 851.5625 + Received: 1203.578125 + + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + 18 | const control = (await page.locator(".volume-row").boundingBox())!; + > 19 | expect.soft(control.y + control.height).toBeLessThanOrEqual((await start.boundingBox())!.y); + | ^ + 20 | await volume.focus(); + 21 | await volume.press("ArrowRight"); + 22 | await expect(volume).toHaveValue("36"); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:19:43 + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 402 + Received: 1188 + + 11 | expect.soft(control.x).toBeGreaterThanOrEqual(bounds.x); + 12 | expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); + > 13 | expect.soft(control.x + control.width).toBeLessThanOrEqual(bounds.x + bounds.width); + | ^ + 14 | expect.soft(control.y + control.height).toBeLessThanOrEqual(bounds.y + bounds.height); + 15 | }; + 16 | await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); + at expectInside (/home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:13:44) + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:24:3 + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 748.3125 + Received: 852.3125 + + 12 | expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); + 13 | expect.soft(control.x + control.width).toBeLessThanOrEqual(bounds.x + bounds.width); + > 14 | expect.soft(control.y + control.height).toBeLessThanOrEqual(bounds.y + bounds.height); + | ^ + 15 | }; + 16 | await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + at expectInside (/home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:14:45) + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:24:3 + + Error Context: test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/trace.zip + Usage: + + npx playwright show-trace test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2) [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ────────────────────── + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 1250.015625 + Received: 1687.84375 + + 12 | expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); + 13 | expect.soft(control.x + control.width).toBeLessThanOrEqual(bounds.x + bounds.width); + > 14 | expect.soft(control.y + control.height).toBeLessThanOrEqual(bounds.y + bounds.height); + | ^ + 15 | }; + 16 | await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + at expectInside (/home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:14:45) + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:16:3 + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 1184.015625 + Received: 1687.84375 + + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + 18 | const control = (await page.locator(".volume-row").boundingBox())!; + > 19 | expect.soft(control.y + control.height).toBeLessThanOrEqual((await start.boundingBox())!.y); + | ^ + 20 | await volume.focus(); + 21 | await volume.press("ArrowRight"); + 22 | await expect(volume).toHaveValue("36"); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:19:43 + + Error: expect(received).toBeLessThanOrEqual(expected) + + Expected: <= 169.03125 + Received: 696.890625 + + 12 | expect.soft(control.y).toBeGreaterThanOrEqual(bounds.y); + 13 | expect.soft(control.x + control.width).toBeLessThanOrEqual(bounds.x + bounds.width); + > 14 | expect.soft(control.y + control.height).toBeLessThanOrEqual(bounds.y + bounds.height); + | ^ + 15 | }; + 16 | await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" })); + 17 | const start = page.getByRole("button", { name: /소리 시작/ }); + at expectInside (/home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:14:45) + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:24:3 + + Error Context: test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2 failed + [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ────────────────────── + [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ─────────────────────── diff --git a/.agent/evidence/15-volume-build.log b/.agent/evidence/15-volume-build.log new file mode 100644 index 0000000..558552a --- /dev/null +++ b/.agent/evidence/15-volume-build.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-_gwQxvGu.css 15.23 kB │ gzip: 4.38 kB +dist/assets/index-DNw8lyKU.js 244.40 kB │ gzip: 77.01 kB + +✓ built in 157ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/15-volume-green.log b/.agent/evidence/15-volume-green.log new file mode 100644 index 0000000..36bda27 --- /dev/null +++ b/.agent/evidence/15-volume-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 전체 음량을 시작 CTA + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed | 9 skipped (10) + Start at 05:32:46 + Duration 2.00s (environment 48%, tests 30%, transform 8%, setup 8%, import 5%, worker 1%) diff --git a/.agent/evidence/15-volume-red.log b/.agent/evidence/15-volume-red.log new file mode 100644 index 0000000..c14834e --- /dev/null +++ b/.agent/evidence/15-volume-red.log @@ -0,0 +1,77 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx -t 전체 음량을 시작 CTA + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/App.test.tsx (10 tests | 1 failed | 9 skipped) 808ms + ❯ 시작부터 결과까지 사용자 흐름 (10) + × 전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다 805ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다 +Error: expect(element).toContainElement(element) + +
does not contain: + + ❯ tests/App.test.tsx:53:24 + 51| const volume = screen.getByRole("slider", { name: "전체 음량" }); + 52| const start = screen.getByRole("button", { name: /소리 시작/ }); + 53| expect.soft(setup).toContainElement(volume); + | ^ + 54| // CSS 배치만 바꾸지 않고 키보드·스크린리더도 음량→시작 순서로 읽어야 한다. + 55| expect.soft(volume.compareDocumentPosition(start) & Node.DOCUMENT_… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다 +AssertionError: expected +0 not to be +0 // Object.is equality + ❯ tests/App.test.tsx:55:95 + 53| expect.soft(setup).toContainElement(volume); + 54| // CSS 배치만 바꾸지 않고 키보드·스크린리더도 음량→시작 순서로 읽어야 한다. + 55| expect.soft(volume.compareDocumentPosition(start) & Node.DOCUMENT_… + | ^ + 56| fireEvent.change(volume, { target: { value: "20" } }); + 57| await f.user.click(start); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 전체 음량을 시작 CTA 앞 설정 카드와 게임 환경 패널에 배치하고 값을 유지한다 +Error: expect(element).toContainElement(element) + +
 +  +  +  + 01 +  + 
 +  + 나의 소리 탐험 준비 +  + 

 + 편한 방식으로 시작하세요. 난이도는 조금씩 높아져요. + 

 + 
 +  + 약 3–5분 +  +  +  +  +  + 어떻게 답할까요? +  +  +  +  +  + ◈ +  +  + 골고루 풀기 +  +  + 선택 + 직접 입력 +  +  +  +  +  + ☷ +  +  + 보기에서 선택 +  +  + 4개 단어 중 하나 +  +  +  +  +  + ✎ +  +  + 직접 입력 +  +  + 기억한 단어 쓰기 +  +  +  +  +  +  + 배경 환경 +  +  +  + 여덟 가지 일상 속으로 +  +  + 길거리 +  +  + 쇼핑몰 +  +  + 극장 +  +  + 백화점 +  +  + 시장 +  +  + 지하철 +  +  + 카페 +  +  + 비 오는 거리 +  +  + 

 + 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요. + 

 +  +  + 길거리 +  +  + 카페 +  +  + 지하철 +  +  + +5개 환경 +  +  +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  +  + 

 +  + ♬ +  +  편안한 작은 음량으로 시작해 보세요. + 

 +  + 소리 시작  +  + → +  +  +  +  +  +  + 작은 단어를 발견하는 방법 +  +  + 
 +  + 01 +  + 

 + 먼저, 귀 기울이기 + 

 + 

 + 에스의 안내와 소음 속 문장을 들어요. + 
 + 처음에는 또렷하게, 점점 더 작게. + 

 + 
 + 
 +  + 02 +  + 

 + 기억한 단어 찾기 + 

 + 

 + 소리가 끝나면 질문이 나타나요. + 
 + 보기에서 고르거나 직접 입력해요. + 

 + 
 + 
 +  + 03 +  + 

 + 나의 발견 돌아보기 + 

 + 

 + 열 문제 뒤 나의 결과를 확인해요. + 
 + 놓쳤다면 언제든 다시 들어도 좋아요. + 

 + 
 +  +  + 
 +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:50:19 + 48| it("시작 안내는 장식 번호 대신 8가지 환경을 표시한다", () => { + 49| fixture(); + 50| expect(screen.getByText("8가지 환경", { exact: true })).toBeInTheDocum… + | ^ + 51| expect(screen.queryByText("01 / 08")).not.toBeInTheDocument(); + 52| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 시작 안내의 현재 페이지 링크는 아래 화살표를 표시한다 +Error: expect(element).toHaveTextContent() + +Expected element to have text content: + 플레이 방법 ↓ +Received: + 플레이 방법 ↗ + ❯ tests/App.test.tsx:57:18 + 55| const link = screen.getByRole("link", { name: "플레이 방법" }); + 56| expect(link).toHaveAttribute("href", "#howto"); + 57| expect(link).toHaveTextContent("플레이 방법 ↓"); + | ^ + 58| expect(within(link).getByText("↓")).toHaveAttribute("aria-hidden",… + 59| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 시작 안내의 CTA는 게임 시작과 소리 재생을 함께 알린다 +TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "게임 시작 · 소리 듣기" + +Here are the accessible roles: + + link: + + Name "본문으로 건너뛰기": +  + + Name "플레이 방법": +  + + -------------------------------------------------- + banner: + + Name "": +  + + -------------------------------------------------- + heading: + + Name "단어를 맞춰라": + 

 + + Name "소음 속 작은 단어,들리시나요?": +  + + Name "나의 소리 탐험 준비": +  + + Name "작은 단어를 발견하는 방법": +  + + Name "먼저, 귀 기울이기": + 

 + + Name "기억한 단어 찾기": + 

 + + Name "나의 발견 돌아보기": + 

 + + -------------------------------------------------- + main: + + Name "": +  + + -------------------------------------------------- + region: + + Name "소음 속 작은 단어,들리시나요?": +  + + Name "나의 소리 탐험 준비": +  + + Name "작은 단어를 발견하는 방법": +  + + -------------------------------------------------- + paragraph: + + Name "": +  + + Name "": +  + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + Name "": + 

 + + -------------------------------------------------- + emphasis: + + Name "": +  + + -------------------------------------------------- + group: + + Name "어떻게 답할까요?": +  + + -------------------------------------------------- + radio: + + Name "골고루 풀기": +  + + Name "보기에서 선택": +  + + Name "직접 입력": +  + + -------------------------------------------------- + strong: + + Name "": +  + + Name "": +  + + Name "": +  + + -------------------------------------------------- + combobox: + + Name "배경 환경": +  + + -------------------------------------------------- + option: + + Name "여덟 가지 일상 속으로": +  + + Name "길거리": +  + + Name "쇼핑몰": +  + + Name "극장": +  + + Name "백화점": +  + + Name "시장": +  + + Name "지하철": +  + + Name "카페": +  + + Name "비 오는 거리": +  + + -------------------------------------------------- + slider: + + Name "전체 음량": +  + + -------------------------------------------------- + status: + + Name "": +  + + -------------------------------------------------- + button: + + Name "소리 시작": +  + + -------------------------------------------------- + article: + + Name "": + 

 + + Name "": + 
 + + Name "": + 
 + + -------------------------------------------------- + contentinfo: + + Name "": +  + + -------------------------------------------------- + +Ignored nodes: comments, script, style + + 
 +  + 본문으로 건너뛰기 +  +  +  +  +  +  +  +  +  + 

 + 단어를 맞춰라 + 

 +  + v0.1 +  + 
 +  +  + 플레이 방법  +  + ↗ +  +  +  + 소리에 집중하는 시간 +  +  +  +  +  +  +  +  귀를 기울이면, 발견하는 즐거움 + 

 +  + 소음 속 작은 단어, + 
 +  + 들리시나요? +  + 

 +  + 도시의 소리 사이에 에스가 숨겨 둔 단어. + 
 + 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. + 

 +  +  + 10개의 질문 +  +  + 5단계 도전 +  +  + 나만의 속도로 +  +  +  +  +  +  +  +  에스의 사운드 룸 +  +  + 01 / 08 +  +  +  +  +  + ♪ +  +  + ✳ +  +  + 행운의 단어를 찾아보거라! +  +  +  +  +  +  +  +  + S +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 소음 사이, 숨겨진 한마디 +  +  +  +  +  +  +  + 01 +  + 
 +  + 나의 소리 탐험 준비 + 

 + 

 + 편한 방식으로 시작하세요. 난이도는 조금씩 높아져요. + 

 +  +  + 약 3–5분 +  +  +  +  +  + 어떻게 답할까요? +  +  +  +  +  + ◈ +  +  + 골고루 풀기 +  +  + 선택 + 직접 입력 +  +  +  +  +  + ☷ +  +  + 보기에서 선택 +  +  + 4개 단어 중 하나 +  +  +  +  +  + ✎ +  +  + 직접 입력 +  +  + 기억한 단어 쓰기 +  +  +  +  +  +  + 배경 환경 +  +  +  + 여덟 가지 일상 속으로 +  +  + 길거리 +  +  + 쇼핑몰 +  +  + 극장 +  +  + 백화점 +  +  + 시장 +  +  + 지하철 +  +  + 카페 +  +  + 비 오는 거리 +  +  + 

 + 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요. + 

 +  +  + 길거리 +  +  + 카페 +  +  + 지하철 +  +  + +5개 환경 +  +  +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  +  + 

 +  + ♬ +  +  편안한 작은 음량으로 시작해 보세요. + 

 +  + 소리 시작  +  + → +  +  +  +  +  +  + 작은 단어를 발견하는 방법 +  +  + 
 +  + 01 +  + 

 + 먼저, 귀 기울이기 + 

 + 

 + 에스의 안내와 소음 속 문장을 들어요. + 
 + 처음에는 또렷하게, 점점 더 작게. + 

 + 
 + 
 +  + 02 +  + 

 + 기억한 단어 찾기 + 

 + 

 + 소리가 끝나면 질문이 나타나요. + 
 + 보기에서 고르거나 직접 입력해요. + 

 + 
 + 
 +  + 03 +  + 

 + 나의 발견 돌아보기 + 

 + 

 + 열 문제 뒤 나의 결과를 확인해요. + 
 + 놓쳤다면 언제든 다시 들어도 좋아요. + 

 + 
 +  +  + 
 +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:62:26 + 60| it("시작 안내의 CTA는 게임 시작과 소리 재생을 함께 알린다", async () => { + 61| const f = fixture(); + 62| const start = screen.getByRole("button", { name: "게임 시작 · 소리 듣기", … + | ^ + 63| expect(start).toHaveTextContent("게임 시작 · 소리 듣기"); + 64| expect(f.audio.play).not.toHaveBeenCalled(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + + Test Files 1 failed (1) + Tests 3 failed | 10 skipped (13) + Start at 05:33:14 + Duration 1.36s (environment 46%, tests 37%, setup 9%, transform 4%, import 3%, worker 1%) diff --git a/.agent/evidence/17-spacing-green.log b/.agent/evidence/17-spacing-green.log new file mode 100644 index 0000000..ba570e8 --- /dev/null +++ b/.agent/evidence/17-spacing-green.log @@ -0,0 +1,17 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --grep 전체 음량 + +[WebServer] (node:756185) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 2 tests using 2 workers + +(node:756205) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:756206) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.7s) + ✓ 1 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.8s) + + 2 passed (3.8s) diff --git a/.agent/evidence/17-spacing-red.log b/.agent/evidence/17-spacing-red.log new file mode 100644 index 0000000..8b18570 --- /dev/null +++ b/.agent/evidence/17-spacing-red.log @@ -0,0 +1,72 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/ui.spec.ts --grep 전체 음량 + +[WebServer] (node:755608) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 2 tests using 2 workers + +(node:755630) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:755629) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.8s) + ✘ 2 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.9s) + + + 1) [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ───────────────────── + + Error: expect(received).toBeGreaterThanOrEqual(expected) + + Expected: >= 24 + Received: 0 + + 44 | const cards = (await page.locator(".game-columns").boundingBox())!; + 45 | const footer = (await page.getByRole("contentinfo").boundingBox())!; + > 46 | expect(footer.y - (cards.y + cards.height)).toBeGreaterThanOrEqual(24); + | ^ + 47 | expect( + 48 | await page.evaluate( + 49 | () => document.documentElement.scrollWidth <= innerWidth, + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:46:47 + + Error Context: test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/trace.zip + Usage: + + npx playwright show-trace test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-desktop/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2) [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ────────────────────── + + Error: expect(received).toBeGreaterThanOrEqual(expected) + + Expected: >= 24 + Received: 0 + + 44 | const cards = (await page.locator(".game-columns").boundingBox())!; + 45 | const footer = (await page.getByRole("contentinfo").boundingBox())!; + > 46 | expect(footer.y - (cards.y + cards.height)).toBeGreaterThanOrEqual(24); + | ^ + 47 | expect( + 48 | await page.evaluate( + 49 | () => document.documentElement.scrollWidth <= innerWidth, + at /home/hermes/worktrees/find-word-v0.1/tests/browser/ui.spec.ts:46:47 + + Error Context: test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/ui-전체-음량은-설정-흐름-안에-들어가며-키보드로-조절된다-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2 failed + [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ────────────────────── + [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 ─────────────────────── diff --git a/.agent/evidence/18-literal-green.log b/.agent/evidence/18-literal-green.log new file mode 100644 index 0000000..7047c83 --- /dev/null +++ b/.agent/evidence/18-literal-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx tests/release.test.ts -t 브랜드 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 2 passed (2) + Tests 2 passed | 12 skipped (14) + Start at 05:50:16 + Duration 1.25s (environment 49%, setup 21%, tests 18%, transform 7%, import 3%, worker 1%) diff --git a/.agent/evidence/18-literal-red.log b/.agent/evidence/18-literal-red.log new file mode 100644 index 0000000..6966806 --- /dev/null +++ b/.agent/evidence/18-literal-red.log @@ -0,0 +1,1488 @@ + +> find-word@0.1.0 test +> vitest run tests/App.test.tsx tests/release.test.ts -t 브랜드 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/release.test.ts (1 test | 1 failed) 45ms + ❯ 릴리스 문서 계약 (1) + × 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 43ms + ❯ tests/App.test.tsx (13 tests | 1 failed | 12 skipped) 225ms + ❯ 시작부터 결과까지 사용자 흐름 (13) + × 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 224ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 +TestingLibraryElementError: Unable to find an element with the text: Designed by OZ at Heat-T Medical Inc.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + 본문으로 건너뛰기 +  +  +  +  +  +  +  +  +  + 

 + 단어를 맞춰라 + 

 +  + v0.1 +  + 
 +  +  + 플레이 방법  +  + ↓ +  +  +  + 소리에 집중하는 시간 +  +  +  +  +  +  +  +  귀를 기울이면, 발견하는 즐거움 + 

 +  + 소음 속 작은 단어, + 
 +  + 들리시나요? +  +  +  + 도시의 소리 사이에 에스가 숨겨 둔 단어. + 
 + 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. + 

 +  +  + 10개의 질문 +  +  + 5단계 도전 +  +  + 나만의 속도로 +  +  +  +  +  +  +  +  에스의 사운드 룸 +  +  + 8 + 가지 환경 +  +  +  +  +  + ♪ + 
 +  + ✳ +  +  + 행운의 단어를 찾아보거라! +  +  +  +  +  +  +  +  + S +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 소음 사이, 숨겨진 한마디 +  +  +  +  +  +  +  + 01 +  + 
 +  + 나의 소리 탐험 준비 +  + 

 + 편한 방식으로 시작하세요. 난이도는 조금씩 높아져요. + 

 + 
 +  + 약 3–5분 +  +  +  +  +  + 어떻게 답할까요? +  +  +  +  +  + ◈ +  +  + 골고루 풀기 +  +  + 선택 + 직접 입력 +  +  +  +  +  + ☷ +  +  + 보기에서 선택 +  +  + 4개 단어 중 하나 +  +  +  +  +  + ✎ +  +  + 직접 입력 +  +  + 기억한 단어 쓰기 +  +  +  +  +  +  + 배경 환경 +  +  +  + 여덟 가지 일상 속으로 +  +  + 길거리 +  +  + 쇼핑몰 +  +  + 극장 +  +  + 백화점 +  +  + 시장 +  +  + 지하철 +  +  + 카페 +  +  + 비 오는 거리 +  +  + 

 + 길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요. + 

 +  +  + 길거리 +  +  + 카페 +  +  + 지하철 +  +  + +5개 환경 +  +  +  +  +  +  +  + ◖)) +  +  전체 음량 +  +  +  + 35 + % +  +  +  + 

 +  + ♬ +  +  편안한 작은 음량으로 시작해 보세요. + 

 +  + 게임 시작 · 소리 듣기  +  + → +  +  +  +  +  +  + 작은 단어를 발견하는 방법 +  +  + 
 +  + 01 +  + 

 + 먼저, 귀 기울이기 + 

 + 

 + 에스의 안내와 소음 속 문장을 들어요. + 
 + 처음에는 또렷하게, 점점 더 작게. + 

 + 
 + 
 +  + 02 +  + 

 + 기억한 단어 찾기 + 

 + 

 + 소리가 끝나면 질문이 나타나요. + 
 + 보기에서 고르거나 직접 입력해요. + 

 + 
 + 
 +  + 03 +  + 

 + 나의 발견 돌아보기 + 

 + 

 + 열 문제 뒤 나의 결과를 확인해요. + 
 + 놓쳤다면 언제든 다시 들어도 좋아요. + 

 + 
 +  +  + 
 +  + 

 + Designed by OZ at HearT-M Medical Inc. + 

 + 

 + 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다. + 

 +  + 개인정보 수집 없이, 오직 플레이만. +  +  +  + + ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ tests/App.test.tsx:215:14 + 213| ).toBeInTheDocument(); + 214| expect( + 215| screen.getByText("Designed by OZ at Heat-T Medical Inc.", { exac… + | ^ + 216| ).toBeInTheDocument(); + 217| expect(screen.getByText("v0.1")).toBeInTheDocument(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain 'Designed by OZ at Heat-T Medical Inc.' + +- Expected ++ Received + +- Designed by OZ at Heat-T Medical Inc. ++ # 단어를 맞춰라 · Find-Word v0.1 ++ ++ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. ++ ++ **Designed by OZ at HearT-M Medical Inc.** ++ ++ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, ++ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 ++ 의료적 진단·치료나 효과를 주장하지 않습니다. ++ ++ ## 실행 ++ ++ Node.js **22.12 이상**의 지원되는 LTS 버전을 권장합니다. `package-lock.json`을 ++ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. ++ ++ ```bash ++ npm ci ++ npm run dev ++ ``` ++ ++ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 ++ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. ++ ++ ## 플레이 ++ ++ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. ++ - 8개 환경을 순환하거나 하나를 지정합니다. ++ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. ++ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**. ++ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. ++ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다. ++ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다. ++ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. ++ ++ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 ++ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 ++ [WORDS.md](docs/WORDS.md)에 설명했습니다. ++ ++ ## 검증과 정적 빌드 ++ ++ ```bash ++ npm run lint ++ npm test ++ npm run build ++ npm run preview ++ ``` ++ ++ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 ++ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로 ++ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. ++ ++ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: ++ ++ ```bash ++ # Chromium이 없는 개발 환경에서 한 번만 실행합니다. ++ npx playwright install chromium ++ npm run test:browser ++ # 이미 설치된 Chrome을 사용할 때의 대안입니다. ++ CHROME_PATH=/usr/bin/google-chrome npm run test:browser ++ ``` ++ ++ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. ++ 실제 음성 재생이 끝나기를 기다려 약 1–2분이 걸립니다. 로컬 4173 포트를 ++ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다. ++ ++ ## 음성과 문서 ++ ++ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은 ++ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다. ++ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 ++ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. ++ ++ - [설계와 상태 전이](docs/ARCHITECTURE.md) ++ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) ++ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md) ++ ++ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. ++ + + ❯ tests/release.test.ts:11:53 + 9| const credit = "Designed by OZ at Heat-T Medical Inc."; + 10| for (const path of ["README.md", ".agent/TASK.md", ".agent/ACCEPTA… + 11| expect.soft(readFileSync(path, "utf8"), path).toContain(credit); + | ^ + 12| } + 13| for (const path of ["README.md", ...["docs", ".agent"].flatMap((di… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/TASK.md: expected '# TASK — Find-Word v0.1\n\n## 역할\n\n당…' to contain 'Designed by OZ at Heat-T Medical Inc.' + +- Expected ++ Received + +- Designed by OZ at Heat-T Medical Inc. ++ # TASK — Find-Word v0.1 ++ ++ ## 역할 ++ ++ 당신은 구현 담당 Codex다. 이 저장소에서만 작업하고 프로덕션 서버·DNS·인증정보에는 접근하지 않는다. Neo가 코드 리뷰·배포를 별도로 수행한다. ++ ++ ## 제품 ++ ++ - 서비스명: **단어를 맞춰라** ++ - 표기: **Designed by OZ at HearT-M Medical Inc.** ++ - 버전: **0.1** ++ - 배포 예정 도메인: `findword.heart-m.com` ++ - 언어: 사용자 UI와 상세 소스 주석은 한국어 ++ ++ ## 목표 ++ ++ 여러 환경 소음 속에 작은 음량으로 섞인 특정 한국어 단어나 문장을 듣고 질문에 답하는 웹 게임을 만든다. 쉬운 단계부터 소음 대비 키 문장의 dB 차이를 늘려 난이도를 높인다. 객관식과 주관식을 모두 지원한다. ++ ++ ## 핵심 시나리오 ++ ++ 1. S 캐릭터가 “으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.”라고 안내한다. ++ 2. 웃음과 안내 음성이 배경 소음 속으로 점차 묻힌다. ++ 3. “오늘의 내 행운은 ‘사과’에 있다.” 같은 키 문장을 재생한다. ++ 4. 재생이 끝난 뒤 질문과 네 개의 선택지를 표시한다. ++ 5. 선택지는 정답 1개와 사전에 존재하는 유사어 오답 3개로 구성한다. ++ 6. 오답은 자음·모음 변형 규칙의 서로 다른 난이도를 반영하고, 데이터에 변형 종류를 명시한다. ++ ++ ## 기능 요구사항 ++ ++ - 반응형 SPA, 데스크톱·모바일 키보드/터치 지원 ++ - 시작 화면, 게임 화면, 결과 화면, 다시 듣기, 다음 문제, 전체 진행률 ++ - 사용자가 브라우저 정책상 필요한 “소리 시작” 조작을 직접 누르도록 설계 ++ - Web Audio API로 보이스와 배경을 믹싱하고 목표 음성 gain을 dB 기준으로 적용 ++ - 최소 5단계 난이도. 단계가 높아질수록 목표 문장과 배경의 상대 dB가 낮아짐 ++ - 최소 10개 문제 유형. 객관식·주관식을 포함하고 데이터 조합으로 실제 문제가 더 늘어남 ++ - 최소 8개 배경 환경: 길거리, 쇼핑몰, 극장, 백화점, 시장, 지하철, 카페, 비 오는 거리 ++ - 기본 패턴 문장, 시험 문장, 질문, 답, 오답 후보, 배경 프리셋을 저장된 데이터로 분리 ++ - 사전 단어만 사용하는 4지선다 데이터와 각 오답의 변형 규칙 메타데이터 ++ - 점수, 정답률, 연속 정답, 난이도별 결과 ++ - 접근성: aria, 명확한 focus, 키보드 조작, reduced motion, 색 대비 ++ - 오디오가 없거나 로드 실패해도 사용자에게 명확한 오류와 재시도 제공 ++ - 개인 데이터 수집·로그인·추적기 없음 ++ - 버전 0.1을 UI와 package metadata에 표시 ++ ++ ## 오디오 요구사항 ++ ++ - 라이선스 불명확한 외부 자산을 사용하지 않는다. ++ - 직접 생성한 배경 소음·효과음을 저장하거나, 브라우저에서 결정론적으로 합성하는 프리셋을 사용한다. ++ - 한국어 목표 음성은 가능하면 직접 생성한 오디오 파일로 저장한다. 네트워크 TTS를 사용했다면 생성 스크립트·출처·생성 절차를 문서화하고 앱은 런타임에 외부 TTS를 호출하지 않는다. ++ - 모든 자산의 provenance를 `docs/AUDIO_PROVENANCE.md`에 기록한다. ++ - 오디오 파일을 커밋할 경우 크기를 합리적으로 제한하고 build 결과에 포함되는지 테스트한다. ++ ++ ## 개발 규칙 ++ ++ - strict TDD: 각 동작은 실패하는 테스트를 먼저 작성하고 실제 RED→GREEN 근거를 `.agent/RESULT.md`에 기록한다. ++ - TypeScript strict mode를 사용한다. ++ - 모든 직접 작성 소스 코드에 상세한 한국어 주석을 단다. 자명한 문법 줄마다 반복 주석을 붙이지 말고 알고리즘, 상태, 오디오, 난이도, 접근성, 보안 의도를 설명한다. ++ - generated lockfile과 기계 생성 바이너리는 주석 대상이 아니다. ++ - 비밀값, 서버 자격증명, API 키를 저장하지 않는다. ++ - 서버 배포는 하지 않는다. ++ - Git 커밋은 하지 않는다. Neo가 검증 후 커밋한다. ++ ++ ## 권장 기술 ++ ++ - Vite + React + TypeScript ++ - Vitest + Testing Library ++ - 순수 CSS 또는 가벼운 CSS 구조 ++ - 정적 빌드(`dist/`)로 Nginx 배포 가능 ++ ++ ## 산출물 ++ ++ - 동작하는 v0.1 소스 ++ - 최소 10개 문제 유형과 조합 가능한 데이터 ++ - 오디오 생성·재생 구조 ++ - unit/component 테스트 ++ - `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, `docs/DEPLOYMENT.md` ++ - `.agent/RESULT.md`: 변경 파일, RED/GREEN 명령과 실제 결과, 전체 검증 결과, 미해결 위험 ++ + + ❯ tests/release.test.ts:11:53 + 9| const credit = "Designed by OZ at Heat-T Medical Inc."; + 10| for (const path of ["README.md", ".agent/TASK.md", ".agent/ACCEPTA… + 11| expect.soft(readFileSync(path, "utf8"), path).toContain(credit); + | ^ + 12| } + 13| for (const path of ["README.md", ...["docs", ".agent"].flatMap((di… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/ACCEPTANCE.md: expected '# ACCEPTANCE — Find-Word v0.1\n\n## 제…' to contain 'Designed by OZ at Heat-T Medical Inc.' + +- Expected ++ Received + +- Designed by OZ at Heat-T Medical Inc. ++ # ACCEPTANCE — Find-Word v0.1 ++ ++ ## 제품 ++ ++ - [ ] H1 또는 브랜드 영역에 `단어를 맞춰라` 표시 ++ - [ ] `Designed by OZ at HearT-M Medical Inc.` 표시 ++ - [ ] v0.1 표시 ++ - [ ] 게임 시작부터 결과·재시작까지 완결된 흐름 ++ - [ ] 객관식과 주관식 모두 실제 플레이 가능 ++ - [ ] 최소 10개 문제 유형이 코드·데이터·테스트로 확인됨 ++ - [ ] 최소 8개 배경 환경 프리셋 ++ - [ ] 최소 5개 dB 기반 난이도 ++ - [ ] 문제 조합이 고정 10문제를 넘어 확장 가능 ++ - [ ] 사전 단어 기반 오답과 변형 규칙 metadata ++ ++ ## 오디오 ++ ++ - [ ] Web Audio API로 배경과 목표 음성 gain을 분리 ++ - [ ] 난이도별 상대 dB가 수학적으로 검증됨 ++ - [ ] 첫 재생은 사용자 gesture 이후 실행 ++ - [ ] 재생 중 중복 시작·다음 문제 전환 race 방지 ++ - [ ] 오디오 실패 UI와 재시도 ++ - [ ] provenance 문서와 생성 절차 ++ - [ ] 런타임 외부 TTS/API 호출 없음 ++ ++ ## 품질 ++ ++ - [ ] strict TypeScript ++ - [ ] 직접 작성한 소스에 상세한 한국어 주석 ++ - [ ] unit/component 테스트에서 문제 유형 수, 선택지 무결성, 정답 판정, 주관식 정규화, 난이도 dB, 점수, 오디오 상태 검증 ++ - [ ] lint 통과 ++ - [ ] test 통과 ++ - [ ] production build 통과 ++ - [ ] 접근성 이름, 키보드 focus, reduced motion ++ - [ ] 360px 모바일과 1280px 데스크톱 레이아웃 ++ - [ ] secret scan 무검출 ++ ++ ## 문서 ++ ++ - [ ] README에 개발·테스트·빌드 방법 ++ - [ ] ARCHITECTURE에 데이터·상태·오디오 설계 ++ - [ ] AUDIO_PROVENANCE에 생성·라이선스·재생 방식 ++ - [ ] DEPLOYMENT에 Nginx static 배포와 SPA fallback ++ - [ ] `.agent/RESULT.md`에 실제 RED/GREEN 및 최종 검증 기록 ++ + + ❯ tests/release.test.ts:11:53 + 9| const credit = "Designed by OZ at Heat-T Medical Inc."; + 10| for (const path of ["README.md", ".agent/TASK.md", ".agent/ACCEPTA… + 11| expect.soft(readFileSync(path, "utf8"), path).toContain(credit); + | ^ + 12| } + 13| for (const path of ["README.md", ...["docs", ".agent"].flatMap((di… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/CONTEXT.md: expected '# CONTEXT — Find-Word v0.1\n\n## 저장소와…' to contain 'Designed by OZ at Heat-T Medical Inc.' + +- Expected ++ Received + +- Designed by OZ at Heat-T Medical Inc. ++ # CONTEXT — Find-Word v0.1 ++ ++ ## 저장소와 책임 경계 ++ ++ - canonical repository: `https://source.ibit.kr/OZ-Group/Find-Word` ++ - 작업 브랜치: `feat/v0.1` ++ - 현재 worktree: `/home/hermes/worktrees/find-word-v0.1` ++ - Codex는 이 worktree만 수정한다. ++ - Neo가 최종 diff 검토, 테스트, source push, Heart 서버 배포, Nginx/TLS/DNS 검증을 담당한다. ++ ++ ## 배포 예정 환경 ++ ++ - Ubuntu 24.04 ++ - Nginx 1.24 ++ - Node.js 22는 설치되어 있으나 프로덕션은 정적 `dist/`를 Nginx가 직접 제공하는 구성을 선호한다. ++ - 예정 도메인 `findword.heart-m.com`은 현재 Heart 서버 IP로 DNS 해석된다. ++ - 배포 파일·서비스·포트는 아직 존재하지 않는다. ++ ++ ## 제품 해석 ++ ++ - “4개의 단어”를 우선한다. 정답 1개와 변형 규칙이 서로 다른 사전 단어 오답 3개를 표시한다. ++ - 사용자 문구의 `Heat-T Medical Inc.`는 Heart 계열 명칭 문맥과 요청 표제를 함께 고려해 UI에는 정확히 `Designed by OZ at HearT-M Medical Inc.`로 표기한다. ++ - 전체 판매/의료 판단 서비스가 아니라 청각적 주의·식별 게임이다. 의료 효과·치료·진단을 주장하지 않는다. ++ - 오디오 난이도는 디지털 gain의 상대 dB로 계산하고 UI에 난이도와 상대 dB를 표시한다. ++ ++ ## 보안·라이선스 ++ ++ - 외부 CDN·분석·런타임 API 의존을 두지 않는다. ++ - 외부 음원을 다운로드할 경우 명시적 라이선스와 원본 URL이 필요하다. 불확실하면 직접 합성한다. ++ - 앱은 사용자 입력을 HTML로 삽입하지 않는다. ++ - 결과는 localStorage에 선택적으로 저장할 수 있으나 개인정보는 저장하지 않는다. ++ + + ❯ tests/release.test.ts:11:53 + 9| const credit = "Designed by OZ at Heat-T Medical Inc."; + 10| for (const path of ["README.md", ".agent/TASK.md", ".agent/ACCEPTA… + 11| expect.soft(readFileSync(path, "utf8"), path).toContain(credit); + | ^ + 12| } + 13| for (const path of ["README.md", ...["docs", ".agent"].flatMap((di… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' not to contain 'HearT-M' + +- Expected ++ Received + +- HearT-M ++ # 단어를 맞춰라 · Find-Word v0.1 ++ ++ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. ++ ++ **Designed by OZ at HearT-M Medical Inc.** ++ ++ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, ++ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 ++ 의료적 진단·치료나 효과를 주장하지 않습니다. ++ ++ ## 실행 ++ ++ Node.js **22.12 이상**의 지원되는 LTS 버전을 권장합니다. `package-lock.json`을 ++ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. ++ ++ ```bash ++ npm ci ++ npm run dev ++ ``` ++ ++ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 ++ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. ++ ++ ## 플레이 ++ ++ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. ++ - 8개 환경을 순환하거나 하나를 지정합니다. ++ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. ++ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**. ++ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. ++ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다. ++ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다. ++ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. ++ ++ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 ++ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 ++ [WORDS.md](docs/WORDS.md)에 설명했습니다. ++ ++ ## 검증과 정적 빌드 ++ ++ ```bash ++ npm run lint ++ npm test ++ npm run build ++ npm run preview ++ ``` ++ ++ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 ++ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로 ++ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. ++ ++ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: ++ ++ ```bash ++ # Chromium이 없는 개발 환경에서 한 번만 실행합니다. ++ npx playwright install chromium ++ npm run test:browser ++ # 이미 설치된 Chrome을 사용할 때의 대안입니다. ++ CHROME_PATH=/usr/bin/google-chrome npm run test:browser ++ ``` ++ ++ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. ++ 실제 음성 재생이 끝나기를 기다려 약 1–2분이 걸립니다. 로컬 4173 포트를 ++ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다. ++ ++ ## 음성과 문서 ++ ++ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은 ++ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다. ++ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 ++ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. ++ ++ - [설계와 상태 전이](docs/ARCHITECTURE.md) ++ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) ++ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md) ++ ++ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. ++ + + ❯ tests/release.test.ts:17:57 + 15| )]) { + 16| // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + 17| expect.soft(readFileSync(path, "utf8"), path).not.toContain(["He… + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/ACCEPTANCE.md: expected '# ACCEPTANCE — Find-Word v0.1\n\n## 제…' not to contain 'HearT-M' + +- Expected ++ Received + +- HearT-M ++ # ACCEPTANCE — Find-Word v0.1 ++ ++ ## 제품 ++ ++ - [ ] H1 또는 브랜드 영역에 `단어를 맞춰라` 표시 ++ - [ ] `Designed by OZ at HearT-M Medical Inc.` 표시 ++ - [ ] v0.1 표시 ++ - [ ] 게임 시작부터 결과·재시작까지 완결된 흐름 ++ - [ ] 객관식과 주관식 모두 실제 플레이 가능 ++ - [ ] 최소 10개 문제 유형이 코드·데이터·테스트로 확인됨 ++ - [ ] 최소 8개 배경 환경 프리셋 ++ - [ ] 최소 5개 dB 기반 난이도 ++ - [ ] 문제 조합이 고정 10문제를 넘어 확장 가능 ++ - [ ] 사전 단어 기반 오답과 변형 규칙 metadata ++ ++ ## 오디오 ++ ++ - [ ] Web Audio API로 배경과 목표 음성 gain을 분리 ++ - [ ] 난이도별 상대 dB가 수학적으로 검증됨 ++ - [ ] 첫 재생은 사용자 gesture 이후 실행 ++ - [ ] 재생 중 중복 시작·다음 문제 전환 race 방지 ++ - [ ] 오디오 실패 UI와 재시도 ++ - [ ] provenance 문서와 생성 절차 ++ - [ ] 런타임 외부 TTS/API 호출 없음 ++ ++ ## 품질 ++ ++ - [ ] strict TypeScript ++ - [ ] 직접 작성한 소스에 상세한 한국어 주석 ++ - [ ] unit/component 테스트에서 문제 유형 수, 선택지 무결성, 정답 판정, 주관식 정규화, 난이도 dB, 점수, 오디오 상태 검증 ++ - [ ] lint 통과 ++ - [ ] test 통과 ++ - [ ] production build 통과 ++ - [ ] 접근성 이름, 키보드 focus, reduced motion ++ - [ ] 360px 모바일과 1280px 데스크톱 레이아웃 ++ - [ ] secret scan 무검출 ++ ++ ## 문서 ++ ++ - [ ] README에 개발·테스트·빌드 방법 ++ - [ ] ARCHITECTURE에 데이터·상태·오디오 설계 ++ - [ ] AUDIO_PROVENANCE에 생성·라이선스·재생 방식 ++ - [ ] DEPLOYMENT에 Nginx static 배포와 SPA fallback ++ - [ ] `.agent/RESULT.md`에 실제 RED/GREEN 및 최종 검증 기록 ++ + + ❯ tests/release.test.ts:17:57 + 15| )]) { + 16| // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + 17| expect.soft(readFileSync(path, "utf8"), path).not.toContain(["He… + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/CONTEXT.md: expected '# CONTEXT — Find-Word v0.1\n\n## 저장소와…' not to contain 'HearT-M' + +- Expected ++ Received + +- HearT-M ++ # CONTEXT — Find-Word v0.1 ++ ++ ## 저장소와 책임 경계 ++ ++ - canonical repository: `https://source.ibit.kr/OZ-Group/Find-Word` ++ - 작업 브랜치: `feat/v0.1` ++ - 현재 worktree: `/home/hermes/worktrees/find-word-v0.1` ++ - Codex는 이 worktree만 수정한다. ++ - Neo가 최종 diff 검토, 테스트, source push, Heart 서버 배포, Nginx/TLS/DNS 검증을 담당한다. ++ ++ ## 배포 예정 환경 ++ ++ - Ubuntu 24.04 ++ - Nginx 1.24 ++ - Node.js 22는 설치되어 있으나 프로덕션은 정적 `dist/`를 Nginx가 직접 제공하는 구성을 선호한다. ++ - 예정 도메인 `findword.heart-m.com`은 현재 Heart 서버 IP로 DNS 해석된다. ++ - 배포 파일·서비스·포트는 아직 존재하지 않는다. ++ ++ ## 제품 해석 ++ ++ - “4개의 단어”를 우선한다. 정답 1개와 변형 규칙이 서로 다른 사전 단어 오답 3개를 표시한다. ++ - 사용자 문구의 `Heat-T Medical Inc.`는 Heart 계열 명칭 문맥과 요청 표제를 함께 고려해 UI에는 정확히 `Designed by OZ at HearT-M Medical Inc.`로 표기한다. ++ - 전체 판매/의료 판단 서비스가 아니라 청각적 주의·식별 게임이다. 의료 효과·치료·진단을 주장하지 않는다. ++ - 오디오 난이도는 디지털 gain의 상대 dB로 계산하고 UI에 난이도와 상대 dB를 표시한다. ++ ++ ## 보안·라이선스 ++ ++ - 외부 CDN·분석·런타임 API 의존을 두지 않는다. ++ - 외부 음원을 다운로드할 경우 명시적 라이선스와 원본 URL이 필요하다. 불확실하면 직접 합성한다. ++ - 앱은 사용자 입력을 HTML로 삽입하지 않는다. ++ - 결과는 localStorage에 선택적으로 저장할 수 있으나 개인정보는 저장하지 않는다. ++ + + ❯ tests/release.test.ts:17:57 + 15| )]) { + 16| // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + 17| expect.soft(readFileSync(path, "utf8"), path).not.toContain(["He… + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/RESULT.md: expected '# Find-Word v0.1 작업 결과\n\n작성일: 2026-1…' not to contain 'HearT-M' + +- Expected ++ Received + +- HearT-M ++ # Find-Word v0.1 작업 결과 ++ ++ 작성일: 2026-10-14 UTC. 작업 위치: `/home/hermes/worktrees/find-word-v0.1`. ++ 입력 문서 `.agent/CONTEXT.md`, `TASK.md`, `ACCEPTANCE.md`를 모두 읽고 적용했다. ++ 기존 입력 문서는 수정하지 않았다. ++ ++ ## 구현 결과 ++ ++ - `단어를 맞춰라`, `Designed by OZ at HearT-M Medical Inc.`, `v0.1` 표시. ++ - 반응형 시작·게임·결과 화면, 10문제 완주, 재시작, 다시 듣기, 중단, 진행률. ++ - 객관식·주관식·혼합 모드, 10유형 × 10단어 = 100개 저장 문장. ++ - 4지선다의 고유한 정답 1개·사전 단어 오답 3개, 변형 규칙과 예상 혼동 난이도. ++ - 8개 합성 배경 환경, +6/+3/0/−3/−6 dB의 5단계 상대 음량. ++ - 사용자 gesture에서 AudioContext 시작, 음성·배경 gain 분리, 안내 fade, ++ 재생 중 경합 방지, 취소된 로드의 늦은 완료 무시, 오류와 재시도. ++ - 점수·정답률·연속 정답·단계별 결과, 한글 NFC/공백/문장부호 정규화, ++ 한글 조합 중 Enter·재시작·재청취의 입력 상태 보호. ++ - 키보드/터치, 질문·피드백·결과 focus, ARIA, reduced motion, 색 대비. ++ - TypeScript strict, 직접 작성 소스 22개 파일의 한국어 설계 설명. ++ - 직접 합성 WAV 101개(10,864,582바이트), 결정론적 배경 합성, ++ 생성 도구 실측 버전·원문·파일 해시 manifest. ++ - 런타임 외부 CDN/TTS/API/분석 요청 없음. 개인정보 저장·로그인·추적 없음. ++ ++ 서버·DNS·인증정보에 접근하지 않았으며 **배포, Git commit, Git push를 실행하지 않았다.** ++ Playwright의 localhost preview는 개발 검증용으로만 실행했다. ++ ++ ## 실제 strict TDD 기록 ++ ++ 먼저 테스트와 타입 계약/비동작 stub을 작성하고 RED 명령의 종료 코드 **1**을 ++ 확인한 뒤 최소 동작을 구현했다. 각 GREEN은 종료 코드 **0**이다. 아래 원문 ++ 로그는 `evidence/`에 보관했다. 테스트를 건너뛰거나 실패 조건을 지워 GREEN으로 ++ 만들지 않았다. 초기 stub의 throw는 구현 전 상태를 표현한다. ++ ++ | 순서·핵심 동작 | 실제 RED 명령·결과 | 최소 구현 뒤 GREEN 명령·결과 | 원문 로그 | ++ | --- | --- | --- | --- | ++ | 01 데이터: 10유형·단어 무결성·8환경·5단계 | `npm test -- tests/content.test.ts`: **3 failed**, 빈 목록의 개수/환경 불일치 | 같은 명령: **3 passed** | [RED](evidence/01-content-red.log), [GREEN](evidence/01-content-green.log) | ++ | 02 출제·판정·게임 전이·점수 | `npm test -- tests/game.test.ts`: **6 failed**, 출제 미구현·정규화 불일치 | 같은 명령: **6 passed** | [RED](evidence/02-game-red.log), [GREEN](evidence/02-game-green.log) | ++ | 03 dB·RMS·결정론적 환경 합성 | `npm test -- tests/dsp.test.ts`: **3 failed**, gain=1·잘못된 RMS·무음 | 같은 명령: **3 passed** | [RED](evidence/03-dsp-red.log), [GREEN](evidence/03-dsp-green.log) | ++ | 04 오디오 gesture·gain·fade·중복 시작·취소·실패·음량 | `npm test -- tests/audio.test.ts`: **5 failed**, 오디오 미구현 | 같은 명령: **5 passed** | [RED](evidence/04-audio-red.log), [GREEN](evidence/04-audio-green.log) | ++ | 05 실제 저장 음성·원문·해시·포맷·크기 | `npm test -- tests/assets.test.ts`: **1 failed**, manifest 없음(ENOENT) | `npm run audio:generate` 후 `npm test -- tests/App.test.tsx tests/assets.test.ts`: **2 files / 7 passed** (음성 1 + UI 6) | [RED](evidence/05-assets-red.log), [GREEN](evidence/06-ui-05-assets-green.log) | ++ | 06 시작→재생→답변→결과→재시작·오류·포커스·보안 출력 | `npm test -- tests/App.test.tsx`: **6 failed**, 빈 화면에서 브랜드/시작/입력 역할 없음 | 위 결합 명령: **7 passed** 중 UI **6 passed** | [RED](evidence/06-ui-red.log), [GREEN](evidence/06-ui-05-assets-green.log) | ++ | 07 production 음성 검증기 | `npm test -- tests/build.test.ts`: **1 failed**, 자산 수 0 ≠ 101 | 같은 명령: **1 passed**, 정상 통과 및 파일 변조·누락 시 실제 실패 검증 | [RED](evidence/07-build-red.log), [GREEN](evidence/07-build-green.log) | ++ | 08 오답 변형별 혼동 난이도 | `npm test -- tests/content.test.ts`: **1 failed / 3 passed**, 순위 undefined | 같은 명령: **4 passed** | [RED](evidence/08-rule-red.log), [GREEN](evidence/08-rule-green.log) | ++ | 09 한글 조합 중 제출 방지 | `npm test -- tests/App.test.tsx`: **1 failed / 6 passed**, 조합 중 정답 피드백이 나타남 | 같은 명령: **7 passed** | [RED](evidence/09-ime-red.log), [GREEN](evidence/09-ime-green.log) | ++ | 10 한글 조합 도중 세션 재시작 | `npm test -- tests/App.test.tsx -t '한글 조합 도중'`: **1 failed / 7 skipped**, 새 세션 제출이 막힘 | 같은 명령: **1 passed / 7 skipped** | [RED](evidence/10-ime-reset-red.log), [GREEN](evidence/10-ime-reset-green.log) | ++ | 11 실측 음성 도구 버전 provenance | `npm test -- tests/assets.test.ts -t '도구 버전'`: **1 failed / 1 skipped**, 버전 정보 undefined | 생성기 수정·재생성 후 `npm test -- tests/assets.test.ts`: **2 passed** | [RED](evidence/11-provenance-red.log), [GREEN](evidence/11-provenance-green.log) | ++ | 12 한글 조합 도중 다시 듣기 | 위 10의 테스트에 재청취 전환을 추가한 동일 명령: **1 failed / 7 skipped**, 제출이 막힘 | 같은 명령: **1 passed / 7 skipped** | [RED](evidence/12-ime-replay-red.log), [GREEN](evidence/12-ime-replay-green.log) | ++ ++ `-t`의 skipped는 해당 회귀만 고른 실행 결과이며 최종 전체 실행에는 **skip 없음**. ++ ++ 중간 실패도 남겼다. ++ ++ - DSP의 첫 구현에서 음수 0(`-0`)이 경계 샘플로 남아 **1 failed / 2 passed**였다. ++ 경계값을 명시적으로 0으로 써 수정했다. [중간 로그](evidence/03-dsp-intermediate.log) ++ - UI 첫 구현에서 라디오의 보조 설명에 ‘직접 입력’이 중복되어 **1 failed / 6 passed**였다. ++ 각 라디오의 접근성 이름을 명확히 했다. [중간 로그](evidence/06-ui-intermediate.log) ++ - 오디오 최초 RED의 미구현 throw로 미처리 rejection 3개도 보고되었다. ++ 실제 엔진의 Promise 종료·정리 구현 뒤 GREEN과 최종 실행에서는 0개다. ++ - 첫 TypeScript 검사에서 TypedArray의 ArrayBuffer 타입과 테스트 mock의 순환 ++ 추론 오류를 발견했다. 반환 타입·mock 타입을 명시하여 strict 검사를 통과시켰다. ++ ++ ## 브라우저 통합 검증과 접근성 수정 ++ ++ 실제 Chrome, production `dist/`, 실제 WAV/Web Audio로 검증했다. 모의 재생으로 ++ 시간을 단축하지 않고 `ended`까지 기다렸다. 모든 접근성 검사는 axe 규칙을 ++ 비활성화하지 않고 실행했다. ++ ++ 명령: `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` ++ ++ | 실행 | 실제 결과 | 발견·조치 | ++ | --- | --- | --- | ++ | [첫 실행](evidence/browser-first.log) | 2 passed / 2 failed | 시작 화면 글자 대비 3.75, 4.07 < 4.5. 두 색을 어둡게 조정 | ++ | [두 번째](evidence/browser-second.log) | 2 passed / 2 failed | 게임 화면 글자 대비 4.08, 4.41 < 4.5. 두 색을 조정 | ++ | [세 번째](evidence/browser-third.log) | 2 passed / 2 failed | 두 크기 모두 10문제 완주·100% 도달. 결과 설명 대비 4.38 < 4.5 수정 | ++ | [수정 후](evidence/browser-final.log) | **4 passed (1.2m)** | 시작·게임·결과 axe 위반 0, 두 크기 완주·재시작·404 후 재시도 통과 | ++ ++ 최종 빌드에서도 **4 passed (1.2m), 종료 코드 0**을 확인했다. reduced motion의 ++ 실제 계산된 `animation-name: none`까지 검증했다. ++ [최종 브라우저 로그](evidence/final-browser.log)에 실제 결과를 보관했다. ++ ++ 스크린샷: ++ ++ - [1280px 시작](evidence/desktop-home.png), [게임](evidence/desktop-game.png), [결과](evidence/desktop-results.png) ++ - [360px 시작](evidence/mobile-home.png), [게임](evidence/mobile-game.png), [결과](evidence/mobile-results.png) ++ ++ ## 최종 검증 ++ ++ 환경: Node.js **26.7.0**, npm **11.19.0**, Vite **8.3.4**, Vitest **5.0.4**. ++ 권장 최소 Node는 22.12이며 실제 Node 22에서의 별도 실행은 이번 작업에서 하지 않았다. ++ ++ | 명령/검증 | 실제 결과 | ++ | --- | --- | ++ | `npm run lint` | **PASS**, ESLint + `tsc --noEmit` 오류 0 · [로그](evidence/final-lint.log) | ++ | `npm test` | **7 files / 29 tests passed**, skip 0 · [로그](evidence/final-test.log) | ++ | `npm run build` | **PASS**, `dist/` 생성 및 음성 101개 원본 SHA-256 대조 · [로그](evidence/final-build.log) | ++ | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` | **4 passed (1.2m)**, 360px/1280px 완주·오류 복구·접근성 위반 0·외부 요청 0·JS 예외 0 · [로그](evidence/final-browser.log) | ++ | `npm run audio:generate` 후 이전 manifest `files` 비교 | **101개 원문·바이트 수·SHA-256 전부 동일** · [로그](evidence/audio-reproduce.log) | ++ | `npm audit --audit-level=high` | **found 0 vulnerabilities** · [로그](evidence/final-audit.log) | ++ | 비밀값 정적 패턴 검사 | **34개 텍스트 파일, 0 matches** · [로그](evidence/final-security.log) | ++ | 직접 작성 소스의 한글 설명 | **22개 파일, 누락 0**; Python 생성기 AST 파싱 PASS | ++ | `git diff --check` | 종료 0. 초기 저장소가 비어 새 파일들은 아직 untracked 상태 | ++ ++ production 주요 번들: JS **244.26 kB**(gzip 76.95 kB), CSS **14.87 kB**(gzip 4.29 kB), ++ HTML **0.89 kB**. WAV는 별도로 10.86 MB이고 문항 단위로 로드한다. ++ ++ 비밀값 검사는 소스·테스트·스크립트·문서·루트 metadata·입력 `.agent/*.md`에 대해 ++ AWS access-key 형식, GitHub/OpenAI 토큰 형식, private-key 블록, 긴 password/secret/API-key ++ 문자열 할당을 Python 정규식으로 검사했다. 일치 값은 출력하지 않는다. ++ 의존성 디렉터리와 PCM 바이너리는 대상에서 제외했다. 이는 특정 형식의 정적 검사다. ++ ++ ## 변경 파일 ++ ++ 초기 저장소에는 입력 문서만 존재했다. 다음 파일을 새로 작성했다. 파일 전체 ++ 목록은 [changed-files.txt](evidence/changed-files.txt)에 남긴다(입력 문서는 보존 표시). ++ ++ - 앱: `src/App.tsx`, `src/main.tsx`, `src/styles.css`, `src/content.ts`, `src/game.ts`, ++ `src/audio/dsp.ts`, `src/audio/engine.ts`, `index.html`. ++ - 설정: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts`, ++ `eslint.config.js`, `playwright.config.ts`, `.gitignore`. ++ - 생성/검증: `scripts/generate_audio.py`, `scripts/check-build.mjs`. ++ - 테스트: `tests/setup.ts`, `tests/content.test.ts`, `tests/game.test.ts`, ++ `tests/dsp.test.ts`, `tests/audio.test.ts`, `tests/assets.test.ts`, ++ `tests/App.test.tsx`, `tests/build.test.ts`, `tests/browser/game.spec.ts`. ++ - 자산: `public/audio/intro.wav`, 100개의 `-.wav`, ++ `public/audio/manifest.json`. ++ - 문서: `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, ++ `docs/DEPLOYMENT.md`, `docs/WORDS.md`, `.agent/RESULT.md` 및 `evidence/` 로그·화면. ++ - 생성된 `dist/`, `node_modules/`, `.cache/`와 Playwright 임시 보고서는 Git 제외. ++ ++ ## 남은 위험·인수 사항 ++ ++ 1. 한국어 포먼트 합성은 자연스러운 사람의 발화보다 기계적이다. 자동 검증은 ++ 오디오의 존재·디코딩·전력·흐름을 확인했고, 실제 사용자의 발음 이해도와 청감 ++ 난이도를 평가하지 않았다. Neo의 직접 청취 및 사용자 평가가 필요하다. ++ 2. 환경음은 장소를 추상화한 합성 잡음이다. 실제 시장·카페의 사람 대화 녹음이 ++ 아니다. 상대 dB와 오답 혼동 순위는 의료·청력 측정치가 아니다. ++ 3. 모바일은 Chrome의 360px 터치 에뮬레이션으로 검증했다. 실물 iOS Safari, ++ Android 장치, Bluetooth 출력, 보조공학 장치의 전체 조합은 미검증이다. ++ 4. 사용자 기록은 메모리에만 있어 새로고침 시 사라진다. 오프라인 PWA는 아니며 ++ 음성의 첫 로드에는 정적 호스트 연결이 필요하다. 전체 오디오 용량은 약 10.9MB다. ++ 5. 정답은 정적 번들에 포함된다. 개발자 도구로 답을 알아낼 수 있어 보안 시험이나 ++ 순위 경쟁 서비스 용도가 아니다. ++ 6. 서버 배포·Nginx/TLS/DNS는 요청 범위 밖이며 실행하지 않았다. Neo가 별도 ++ 검토·커밋·배포하고 실제 도메인에서 MIME·캐시·오디오 404·HTTPS를 확인해야 한다. ++ ++ ## Neo 스크린샷 검수 후 출시 전 UI 수정 — 2026-10-14 UTC ++ ++ 앞의 기록과 기존 스크린샷은 보존하고 이번 작업의 RED/GREEN을 이어서 기록한다. ++ 기록 위치는 `.agent/TASK.md`와 `.agent/ACCEPTANCE.md`의 `.agent/RESULT.md` 지정을 따랐다. ++ ++ ### 변경 결과 ++ ++ - 한국어 `h1`·`h2`·`h3`에 `word-break: keep-all`을 적용했다. 실제 Chrome의 ++ 360px computed style이 `normal`인 RED부터 확인했고, 열 가지 질문 모두 ++ 360px/1280px에서 `keep-all`과 제목 내부 가로 넘침 여부를 검사한다. ++ - 게임과 결과 난이도를 `SNR +6 dB`, `SNR +3 dB`, `SNR 0 dB`, `SNR -3 dB`, ++ `SNR -6 dB`로 표시한다. 게임에서는 “음성이 배경보다 6 dB 커요.”, ++ “음성과 배경의 크기가 같아요.”, “음성이 배경보다 3 dB 작아요.”처럼 ++ 크기 관계를 풀어 설명한다. 모바일에서도 설명을 숨기지 않는다. ++ - 전체 음량은 시작 화면의 설정 카드 안에서 CTA보다 앞에, 게임에서는 환경 ++ 패널 안에 배치했다. `전체 음량` 접근성 이름, label 연결, 엔진 전달, ++ 화면 전환 시 값 유지와 결과 화면의 기존 음량 조절을 보존했다. ++ - 사운드 룸의 `01 / 08`은 실제 환경 수를 이용한 `8가지 환경`으로 바꿨다. ++ `플레이 방법`의 `#howto` 앵커는 유지하고 장식 화살표를 `↓`로 바꿨다. ++ CTA의 표시와 접근성 이름은 `게임 시작 · 소리 듣기`로 통일했다. ++ - 기존 색상·캐릭터·게임 흐름을 유지했다. README의 CTA와 SNR 설명도 맞췄다. ++ 출제·채점·음원·오디오 gain 구현은 수정하지 않았다. ++ ++ ### 추가 strict TDD 기록 ++ ++ 각 RED는 해당 구현을 바꾸기 **전에** 실행해 종료 코드 **1**을 확인했다. ++ 각 GREEN은 같은 테스트 계약으로 종료 코드 **0**을 확인했다. 브라우저는 ++ 각 단계의 소스를 먼저 빌드한 뒤 localhost production preview로 검사했다. ++ ++ | 순서·계약 | 실제 RED | 구현 후 GREEN | 증거 | ++ | --- | --- | --- | --- | ++ | 13 한국어 제목의 어절 보존 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --project=mobile`: **1 failed**. 시작·질문 heading의 computed `wordBreak`가 `normal` | 같은 명령: **1 passed**, `keep-all` 및 가로 넘침 없음 | [RED](evidence/13-heading-red.log), [GREEN](evidence/13-heading-green.log), [변경 전 빌드](evidence/13-heading-build-before.log), [변경 후 빌드](evidence/13-heading-build-after.log) | ++ | 14 다섯 SNR·크기 설명·결과 표시 | `npm test -- tests/App.test.tsx -t '다섯 난이도의 SNR'`: **1 failed / 8 skipped**. 열 문제의 SNR·크기 설명과 결과 표기 누락 | 같은 명령: **1 passed / 8 skipped** | [RED](evidence/14-snr-red.log), [GREEN](evidence/14-snr-green.log) | ++ | 14 모바일 난이도 설명 가시성 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --project=mobile --grep '난이도 설명'`: **1 failed**. `.db-note`가 hidden이고 새 설명·배지 없음 | 같은 명령: **1 passed** | [RED](evidence/14-snr-browser-red.log), [GREEN](evidence/14-snr-browser-green.log), [빌드](evidence/14-snr-build.log) | ++ | 15 음량의 소속·문서 순서·이름·값·엔진 | `npm test -- tests/App.test.tsx -t '전체 음량을 시작 CTA'`: **1 failed / 9 skipped**. 설정 카드·환경 패널 바깥이고 CTA 뒤에 있음 | 같은 명령: **1 passed / 9 skipped**. 이름·단일 컨트롤·20→60% 전환 및 엔진 전달 확인 | [RED](evidence/15-volume-red.log), [GREEN](evidence/15-volume-green.log) | ++ | 15 실제 음량 위치·키보드 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --grep '전체 음량'`: **2 failed**. 두 화면 크기에서 컨트롤이 카드 경계와 CTA 아래로 벗어남 | 같은 명령: **2 passed**, 카드 내부·CTA 이전 배치와 방향키·값 유지 확인 | [RED](evidence/15-volume-browser-red.log), [GREEN](evidence/15-volume-browser-green.log), [빌드](evidence/15-volume-build.log) | ++ | 16 환경 수·앵커 화살표·명확한 CTA | `npm test -- tests/App.test.tsx -t '시작 안내'`: **3 failed / 10 skipped**. `8가지 환경`·`↓`·새 CTA 없음 | 같은 명령: **3 passed / 10 skipped**, CTA 클릭에서 재생 1회 확인 | [RED](evidence/16-copy-red.log), [GREEN](evidence/16-copy-green.log) | ++ | 17 음량 이동 후 게임 카드·바닥글 여백 | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser -- tests/browser/ui.spec.ts --grep '전체 음량'`: **2 failed**. 스크린샷 검토에서 발견한 두 화면 크기의 카드·바닥글 간격 **0px < 24px** | 게임 하단에 32px 여백 적용 후 같은 명령: **2 passed** | [RED](evidence/17-spacing-red.log), [GREEN](evidence/17-spacing-green.log) | ++ ++ 필터 실행의 skipped는 해당 회귀만 선택한 결과다. 기존 테스트의 시작 버튼 ++ 선택자도 새 접근성 이름에 맞췄으며, 이전 이름까지 허용하는 정규식으로 ++ 검증을 완화하지 않았다. 최종 브라우저 테스트에는 음량에서 Tab을 누르면 ++ 시작 CTA로 이동하는 검사와 열 가지 질문의 computed style 검사도 포함한다. ++ ++ 전체 검증의 첫 타입 검사에서 새 Testing Library `getByRole` 호출에 지원되지 ++ 않는 `exact` 옵션을 발견했다. 문자열 `name` 자체가 정확 일치를 하므로 불필요한 ++ 옵션만 제거했다. [중간 lint](evidence/neo-intermediate-lint.log)와 ++ [중간 build](evidence/neo-intermediate-build.log)를 보존했다. ++ ++ 여백 회귀는 첫 전체 브라우저 실행 **10 passed (1.5m)** 뒤 스크린샷 검토에서 ++ 추가로 발견했다. [당시 로그](evidence/neo-before-spacing-browser.log)를 보존하고 ++ 17의 측정 계약을 먼저 실패시킨 뒤 수정했다. 전체 캡처 전에는 스크롤을 ++ 맨 위로 맞춰 화면 밖 fixed 건너뛰기 링크가 Chrome의 full-page 이미지에 ++ 들어오는 캡처 아티팩트를 방지했다. 앱의 초점 처리와 링크는 바꾸지 않았다. ++ ++ ### 이번 수정의 최종 전체 검증 ++ ++ 17의 여백 수정까지 포함한 최종 소스로 아래 명령을 모두 실행했다. 종료 코드는 ++ 전부 **0**이며 unit/component와 브라우저 전체 실행 모두 **skip 없음**이다. ++ ++ | 명령 | 최종 결과 | 로그 | ++ | --- | --- | --- | ++ | `npm run lint` | **PASS**, ESLint와 TypeScript 오류 0 | [lint](evidence/neo-final-lint.log) | ++ | `npm test` | **7 files / 34 tests passed** | [test](evidence/neo-final-test.log) | ++ | `npm run build` | **PASS**, production 빌드·음성 101개 무결성 확인 | [build](evidence/neo-final-build.log) | ++ | `CHROME_PATH=/usr/bin/google-chrome npm run test:browser` | **10 passed (1.4m)**, 실제 Chrome 360px/1280px | [browser](evidence/neo-final-browser.log) | ++ ++ 브라우저에서는 실제 WAV 재생을 기다려 혼합 모드 열 문제를 완주하고, ++ 재청취·100% 결과·재시작·404 후 재시도·초점 복귀를 검증했다. 두 화면 크기에서 ++ 시작·게임·결과 axe 위반 **0**, 외부 요청 **0**, JavaScript 예외 **0**이며, ++ reduced motion과 새 줄바꿈·SNR 설명·음량 위치·키보드 계약도 모두 통과했다. ++ 스크린샷을 직접 열어 시작 CTA 앞 음량, 게임 환경 패널의 SNR·음량, 어절을 ++ 보존한 질문, 게임 카드와 바닥글의 여백, 결과 표기를 확인했다. ++ ++ 최종 스크린샷(기존 파일과 구별되는 `neo-` 접두사): ++ ++ - 1280px: [시작](evidence/neo-desktop-home.png), [게임](evidence/neo-desktop-game.png), [결과](evidence/neo-desktop-results.png), [고정 질문](evidence/neo-desktop-heading.png) ++ - 360px: [시작](evidence/neo-mobile-home.png), [게임](evidence/neo-mobile-game.png), [결과](evidence/neo-mobile-results.png), [고정 질문](evidence/neo-mobile-heading.png) ++ ++ 이번 변경 파일은 `src/App.tsx`, `src/styles.css`, `tests/App.test.tsx`, ++ `tests/browser/game.spec.ts`, 신규 `tests/browser/ui.spec.ts`, `README.md`, ++ 이 `.agent/RESULT.md` 및 추가 evidence 로그·스크린샷이다. 의존성과 음원은 ++ 변경하지 않았다. `git diff --check`는 종료 0이며 저장소의 기존 untracked 상태는 ++ 유지했다. 변경 텍스트 7개 파일의 후행 공백 0과 RESULT의 evidence 링크 존재도 ++ 별도로 확인했다. ++ ++ 요청된 UI 수정에서 남은 실패는 없다. 실물 모바일·Safari 및 청감 평가의 기존 ++ 검증 범위 한계는 그대로다. 이번에도 로컬 preview 검증만 수행했으며, ++ **서버 배포, Git commit, Git push는 실행하지 않았다.** ++ + + ❯ tests/release.test.ts:17:57 + 15| )]) { + 16| // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + 17| expect.soft(readFileSync(path, "utf8"), path).not.toContain(["He… + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/10]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 브랜드 문구를 사용자 원문 그대로 문서에도 보존한다 +AssertionError: .agent/TASK.md: expected '# TASK — Find-Word v0.1\n\n## 역할\n\n당…' not to contain 'HearT-M' + +- Expected ++ Received + +- HearT-M ++ # TASK — Find-Word v0.1 ++ ++ ## 역할 ++ ++ 당신은 구현 담당 Codex다. 이 저장소에서만 작업하고 프로덕션 서버·DNS·인증정보에는 접근하지 않는다. Neo가 코드 리뷰·배포를 별도로 수행한다. ++ ++ ## 제품 ++ ++ - 서비스명: **단어를 맞춰라** ++ - 표기: **Designed by OZ at HearT-M Medical Inc.** ++ - 버전: **0.1** ++ - 배포 예정 도메인: `findword.heart-m.com` ++ - 언어: 사용자 UI와 상세 소스 주석은 한국어 ++ ++ ## 목표 ++ ++ 여러 환경 소음 속에 작은 음량으로 섞인 특정 한국어 단어나 문장을 듣고 질문에 답하는 웹 게임을 만든다. 쉬운 단계부터 소음 대비 키 문장의 dB 차이를 늘려 난이도를 높인다. 객관식과 주관식을 모두 지원한다. ++ ++ ## 핵심 시나리오 ++ ++ 1. S 캐릭터가 “으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.”라고 안내한다. ++ 2. 웃음과 안내 음성이 배경 소음 속으로 점차 묻힌다. ++ 3. “오늘의 내 행운은 ‘사과’에 있다.” 같은 키 문장을 재생한다. ++ 4. 재생이 끝난 뒤 질문과 네 개의 선택지를 표시한다. ++ 5. 선택지는 정답 1개와 사전에 존재하는 유사어 오답 3개로 구성한다. ++ 6. 오답은 자음·모음 변형 규칙의 서로 다른 난이도를 반영하고, 데이터에 변형 종류를 명시한다. ++ ++ ## 기능 요구사항 ++ ++ - 반응형 SPA, 데스크톱·모바일 키보드/터치 지원 ++ - 시작 화면, 게임 화면, 결과 화면, 다시 듣기, 다음 문제, 전체 진행률 ++ - 사용자가 브라우저 정책상 필요한 “소리 시작” 조작을 직접 누르도록 설계 ++ - Web Audio API로 보이스와 배경을 믹싱하고 목표 음성 gain을 dB 기준으로 적용 ++ - 최소 5단계 난이도. 단계가 높아질수록 목표 문장과 배경의 상대 dB가 낮아짐 ++ - 최소 10개 문제 유형. 객관식·주관식을 포함하고 데이터 조합으로 실제 문제가 더 늘어남 ++ - 최소 8개 배경 환경: 길거리, 쇼핑몰, 극장, 백화점, 시장, 지하철, 카페, 비 오는 거리 ++ - 기본 패턴 문장, 시험 문장, 질문, 답, 오답 후보, 배경 프리셋을 저장된 데이터로 분리 ++ - 사전 단어만 사용하는 4지선다 데이터와 각 오답의 변형 규칙 메타데이터 ++ - 점수, 정답률, 연속 정답, 난이도별 결과 ++ - 접근성: aria, 명확한 focus, 키보드 조작, reduced motion, 색 대비 ++ - 오디오가 없거나 로드 실패해도 사용자에게 명확한 오류와 재시도 제공 ++ - 개인 데이터 수집·로그인·추적기 없음 ++ - 버전 0.1을 UI와 package metadata에 표시 ++ ++ ## 오디오 요구사항 ++ ++ - 라이선스 불명확한 외부 자산을 사용하지 않는다. ++ - 직접 생성한 배경 소음·효과음을 저장하거나, 브라우저에서 결정론적으로 합성하는 프리셋을 사용한다. ++ - 한국어 목표 음성은 가능하면 직접 생성한 오디오 파일로 저장한다. 네트워크 TTS를 사용했다면 생성 스크립트·출처·생성 절차를 문서화하고 앱은 런타임에 외부 TTS를 호출하지 않는다. ++ - 모든 자산의 provenance를 `docs/AUDIO_PROVENANCE.md`에 기록한다. ++ - 오디오 파일을 커밋할 경우 크기를 합리적으로 제한하고 build 결과에 포함되는지 테스트한다. ++ ++ ## 개발 규칙 ++ ++ - strict TDD: 각 동작은 실패하는 테스트를 먼저 작성하고 실제 RED→GREEN 근거를 `.agent/RESULT.md`에 기록한다. ++ - TypeScript strict mode를 사용한다. ++ - 모든 직접 작성 소스 코드에 상세한 한국어 주석을 단다. 자명한 문법 줄마다 반복 주석을 붙이지 말고 알고리즘, 상태, 오디오, 난이도, 접근성, 보안 의도를 설명한다. ++ - generated lockfile과 기계 생성 바이너리는 주석 대상이 아니다. ++ - 비밀값, 서버 자격증명, API 키를 저장하지 않는다. ++ - 서버 배포는 하지 않는다. ++ - Git 커밋은 하지 않는다. Neo가 검증 후 커밋한다. ++ ++ ## 권장 기술 ++ ++ - Vite + React + TypeScript ++ - Vitest + Testing Library ++ - 순수 CSS 또는 가벼운 CSS 구조 ++ - 정적 빌드(`dist/`)로 Nginx 배포 가능 ++ ++ ## 산출물 ++ ++ - 동작하는 v0.1 소스 ++ - 최소 10개 문제 유형과 조합 가능한 데이터 ++ - 오디오 생성·재생 구조 ++ - unit/component 테스트 ++ - `README.md`, `docs/ARCHITECTURE.md`, `docs/AUDIO_PROVENANCE.md`, `docs/DEPLOYMENT.md` ++ - `.agent/RESULT.md`: 변경 파일, RED/GREEN 명령과 실제 결과, 전체 검증 결과, 미해결 위험 ++ + + ❯ tests/release.test.ts:17:57 + 15| )]) { + 16| // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + 17| expect.soft(readFileSync(path, "utf8"), path).not.toContain(["He… + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/10]⎯ + + + Test Files 2 failed (2) + Tests 2 failed | 12 skipped (14) + Start at 05:50:05 + Duration 1.18s (environment 50%, tests 22%, setup 17%, transform 6%, import 4%, worker 1%) diff --git a/.agent/evidence/19-notices-green.log b/.agent/evidence/19-notices-green.log new file mode 100644 index 0000000..69f4285 --- /dev/null +++ b/.agent/evidence/19-notices-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/release.test.ts tests/build.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 2 passed (2) + Tests 6 passed (6) + Start at 05:51:40 + Duration 733ms (tests 62%, setup 34%, transform 2%, worker 2%, import 1%) diff --git a/.agent/evidence/19-notices-red.log b/.agent/evidence/19-notices-red.log new file mode 100644 index 0000000..c30222d --- /dev/null +++ b/.agent/evidence/19-notices-red.log @@ -0,0 +1,146 @@ + +> find-word@0.1.0 test +> vitest run tests/release.test.ts tests/build.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/release.test.ts (3 tests | 2 failed) 23ms + ❯ 릴리스 문서 계약 (3) + × 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 18ms + × 잠금 파일의 모든 런타임 의존성 notice 전문을 배포용 사본에 보존한다 1ms + ❯ tests/build.test.ts (3 tests | 2 failed) 582ms + ❯ production 자산 검증기 (3) + × dist의 런타임 notice 누락을 실패시킨다 192ms + × dist의 런타임 notice 축약을 실패시킨다 192ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/build.test.ts > production 자산 검증기 > dist의 런타임 notice 누락을 실패시킨다 + FAIL tests/build.test.ts > production 자산 검증기 > dist의 런타임 notice 축약을 실패시킨다 +AssertionError: expected [Function verify] to throw an error + +- Expected: +null + ++ Received: +undefined + + ❯ tests/build.test.ts:47:22 + 45| if (damage === "누락") rmSync(notice, { force: true }); + 46| else writeFileSync(notice, "React / ReactDOM / scheduler — MIT L… + 47| expect(verify).toThrow(/notice/i); + | ^ + 48| } finally { + 49| rmSync(temporary, { recursive: true, force: true }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 +AssertionError: expected undefined to be 'UNLICENSED' // Object.is equality + +- Expected: +"UNLICENSED" + ++ Received: +undefined + + ❯ tests/release.test.ts:12:35 + 10| const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + 11| expect.soft(metadata.private).toBe(true); + 12| expect.soft(metadata.license).toBe("UNLICENSED"); + | ^ + 13| expect.soft(lock.packages[""].license).toBe("UNLICENSED"); + 14| expect.soft(metadata.repository).toEqual({ + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 +AssertionError: expected undefined to be 'UNLICENSED' // Object.is equality + +- Expected: +"UNLICENSED" + ++ Received: +undefined + + ❯ tests/release.test.ts:13:44 + 11| expect.soft(metadata.private).toBe(true); + 12| expect.soft(metadata.license).toBe("UNLICENSED"); + 13| expect.soft(lock.packages[""].license).toBe("UNLICENSED"); + | ^ + 14| expect.soft(metadata.repository).toEqual({ + 15| type: "git", + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 +AssertionError: expected undefined to deeply equal { type: 'git', …(1) } + +- Expected: +{ + "type": "git", + "url": "https://source.ibit.kr/OZ-Group/Find-Word.git", +} + ++ Received: +undefined + + ❯ tests/release.test.ts:14:38 + 12| expect.soft(metadata.license).toBe("UNLICENSED"); + 13| expect.soft(lock.packages[""].license).toBe("UNLICENSED"); + 14| expect.soft(metadata.repository).toEqual({ + | ^ + 15| type: "git", + 16| url: "https://source.ibit.kr/OZ-Group/Find-Word.git", + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 +AssertionError: expected undefined to be 'https://source.ibit.kr/OZ-Group/Find-…' // Object.is equality + +- Expected: +"https://source.ibit.kr/OZ-Group/Find-Word" + ++ Received: +undefined + + ❯ tests/release.test.ts:18:36 + 16| url: "https://source.ibit.kr/OZ-Group/Find-Word.git", + 17| }); + 18| expect.soft(metadata.homepage).toBe("https://source.ibit.kr/OZ-Gro… + | ^ + 19| const license = readFileSync("LICENSE", "utf8"); + 20| expect(license).toMatch(/private/i); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다 +Error: ENOENT: no such file or directory, open 'LICENSE' + ❯ tests/release.test.ts:19:21 + 17| }); + 18| expect.soft(metadata.homepage).toBe("https://source.ibit.kr/OZ-Gro… + 19| const license = readFileSync("LICENSE", "utf8"); + | ^ + 20| expect(license).toMatch(/private/i); + 21| expect(license).toContain("All rights reserved."); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/8]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > 잠금 파일의 모든 런타임 의존성 notice 전문을 배포용 사본에 보존한다 +Error: ENOENT: no such file or directory, open 'THIRD_PARTY_NOTICES.md' + ❯ tests/release.test.ts:28:20 + 26| packages: Record; + 27| }; + 28| const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8"); + | ^ + 29| expect(readFileSync("public/THIRD_PARTY_NOTICES.md", "utf8")).toBe… + 30| for (const [path, pkg] of Object.entries(lock.packages)) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/8]⎯ + + + Test Files 2 failed (2) + Tests 4 failed | 2 passed (6) + Start at 05:51:01 + Duration 884ms (tests 66%, setup 26%, transform 6%, import 1%, worker 1%) diff --git a/.agent/evidence/20-audio-generate.log b/.agent/evidence/20-audio-generate.log new file mode 100644 index 0000000..f01b4f0 --- /dev/null +++ b/.agent/evidence/20-audio-generate.log @@ -0,0 +1,5 @@ + +> find-word@0.1.0 audio:generate +> python3 scripts/generate_audio.py + +101개 WAV 생성, 합계 20,497,222 바이트 diff --git a/.agent/evidence/20-audio-green.log b/.agent/evidence/20-audio-green.log new file mode 100644 index 0000000..e4bf2be --- /dev/null +++ b/.agent/evidence/20-audio-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts tests/game.test.ts tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 3 passed (3) + Tests 24 passed (24) + Start at 05:55:53 + Duration 2.10s (environment 49%, tests 34%, setup 12%, transform 3%, worker 2%, import 1%) diff --git a/.agent/evidence/20-audio-red.log b/.agent/evidence/20-audio-red.log new file mode 100644 index 0000000..54ad5ad --- /dev/null +++ b/.agent/evidence/20-audio-red.log @@ -0,0 +1,540 @@ + +> find-word@0.1.0 test +> vitest run tests/assets.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/assets.test.ts (2 tests | 1 failed) 52ms + ❯ 저장한 한국어 음성 자산 (2) + × 모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다 46ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/assets.test.ts > 저장한 한국어 음성 자산 > 모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다 +AssertionError: expected [ { file: 'intro.wav', …(1) }, …(100) ] to deeply equal [ { file: 'intro.wav', …(1) }, …(100) ] + +- Expected ++ Received + +@@ -3,404 +3,404 @@ + "file": "intro.wav", + "text": "으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.", + }, + { + "file": "luck-apple.wav", +- "text": "오늘의 행운을 담은 단어는 ‘사과’입니다.", ++ "text": "오늘의 내 행운은 사과에 있다.", + }, + { + "file": "luck-sea.wav", +- "text": "오늘의 행운을 담은 단어는 ‘바다’입니다.", ++ "text": "오늘의 내 행운은 바다에 있다.", + }, + { + "file": "luck-tree.wav", +- "text": "오늘의 행운을 담은 단어는 ‘나무’입니다.", ++ "text": "오늘의 내 행운은 나무에 있다.", + }, + { + "file": "luck-hat.wav", +- "text": "오늘의 행운을 담은 단어는 ‘모자’입니다.", ++ "text": "오늘의 내 행운은 모자에 있다.", + }, + { + "file": "luck-umbrella.wav", +- "text": "오늘의 행운을 담은 단어는 ‘우산’입니다.", ++ "text": "오늘의 내 행운은 우산에 있다.", + }, + { + "file": "luck-market.wav", +- "text": "오늘의 행운을 담은 단어는 ‘시장’입니다.", ++ "text": "오늘의 내 행운은 시장에 있다.", + }, + { + "file": "luck-oil.wav", +- "text": "오늘의 행운을 담은 단어는 ‘기름’입니다.", ++ "text": "오늘의 내 행운은 기름에 있다.", + }, + { + "file": "luck-sound.wav", +- "text": "오늘의 행운을 담은 단어는 ‘소리’입니다.", ++ "text": "오늘의 내 행운은 소리에 있다.", + }, + { + "file": "luck-meat.wav", +- "text": "오늘의 행운을 담은 단어는 ‘고기’입니다.", ++ "text": "오늘의 내 행운은 고기에 있다.", + }, + { + "file": "luck-furniture.wav", +- "text": "오늘의 행운을 담은 단어는 ‘가구’입니다.", ++ "text": "오늘의 내 행운은 가구에 있다.", + }, + { + "file": "password-apple.wav", +- "text": "연습 암호는 ‘다과’, 진짜 암호는 ‘사과’입니다.", ++ "text": "비밀 암호는 사과입니다.", + }, + { + "file": "password-sea.wav", +- "text": "연습 암호는 ‘사다’, 진짜 암호는 ‘바다’입니다.", ++ "text": "비밀 암호는 바다입니다.", + }, + { + "file": "password-tree.wav", +- "text": "연습 암호는 ‘나루’, 진짜 암호는 ‘나무’입니다.", ++ "text": "비밀 암호는 나무입니다.", + }, + { + "file": "password-hat.wav", +- "text": "연습 암호는 ‘포자’, 진짜 암호는 ‘모자’입니다.", ++ "text": "비밀 암호는 모자입니다.", + }, + { + "file": "password-umbrella.wav", +- "text": "연습 암호는 ‘부산’, 진짜 암호는 ‘우산’입니다.", ++ "text": "비밀 암호는 우산입니다.", + }, + { + "file": "password-market.wav", +- "text": "연습 암호는 ‘지장’, 진짜 암호는 ‘시장’입니다.", ++ "text": "비밀 암호는 시장입니다.", + }, + { + "file": "password-oil.wav", +- "text": "연습 암호는 ‘이름’, 진짜 암호는 ‘기름’입니다.", ++ "text": "비밀 암호는 기름입니다.", + }, + { + "file": "password-sound.wav", +- "text": "연습 암호는 ‘오리’, 진짜 암호는 ‘소리’입니다.", ++ "text": "비밀 암호는 소리입니다.", + }, + { + "file": "password-meat.wav", +- "text": "연습 암호는 ‘보기’, 진짜 암호는 ‘고기’입니다.", ++ "text": "비밀 암호는 고기입니다.", + }, + { + "file": "password-furniture.wav", +- "text": "연습 암호는 ‘하구’, 진짜 암호는 ‘가구’입니다.", ++ "text": "비밀 암호는 가구입니다.", + }, + { + "file": "remember-apple.wav", +- "text": "‘다과’. 이 단어는 잊으세요. 이제 ‘사과’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 사과입니다.", + }, + { + "file": "remember-sea.wav", +- "text": "‘사다’. 이 단어는 잊으세요. 이제 ‘바다’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 바다입니다.", + }, + { + "file": "remember-tree.wav", +- "text": "‘나루’. 이 단어는 잊으세요. 이제 ‘나무’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 나무입니다.", + }, + { + "file": "remember-hat.wav", +- "text": "‘포자’. 이 단어는 잊으세요. 이제 ‘모자’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 모자입니다.", + }, + { + "file": "remember-umbrella.wav", +- "text": "‘부산’. 이 단어는 잊으세요. 이제 ‘우산’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 우산입니다.", + }, + { + "file": "remember-market.wav", +- "text": "‘지장’. 이 단어는 잊으세요. 이제 ‘시장’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 시장입니다.", + }, + { + "file": "remember-oil.wav", +- "text": "‘이름’. 이 단어는 잊으세요. 이제 ‘기름’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 기름입니다.", + }, + { + "file": "remember-sound.wav", +- "text": "‘오리’. 이 단어는 잊으세요. 이제 ‘소리’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 소리입니다.", + }, + { + "file": "remember-meat.wav", +- "text": "‘보기’. 이 단어는 잊으세요. 이제 ‘고기’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 고기입니다.", + }, + { + "file": "remember-furniture.wav", +- "text": "‘하구’. 이 단어는 잊으세요. 이제 ‘가구’, 이 단어를 기억하세요.", ++ "text": "꼭 기억할 단어는 가구입니다.", + }, + { + "file": "last-apple.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘다과’, ‘사고’, ‘사과’.", ++ "text": "처음 단어는 별, 마지막 단어는 사과입니다.", + }, + { + "file": "last-sea.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘사다’, ‘보다’, ‘바다’.", ++ "text": "처음 단어는 별, 마지막 단어는 바다입니다.", + }, + { + "file": "last-tree.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘나루’, ‘너무’, ‘나무’.", ++ "text": "처음 단어는 별, 마지막 단어는 나무입니다.", + }, + { + "file": "last-hat.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘포자’, ‘모조’, ‘모자’.", ++ "text": "처음 단어는 별, 마지막 단어는 모자입니다.", + }, + { + "file": "last-umbrella.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘부산’, ‘오산’, ‘우산’.", ++ "text": "처음 단어는 별, 마지막 단어는 우산입니다.", + }, + { + "file": "last-market.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘지장’, ‘사장’, ‘시장’.", ++ "text": "처음 단어는 별, 마지막 단어는 시장입니다.", + }, + { + "file": "last-oil.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘이름’, ‘거름’, ‘기름’.", ++ "text": "처음 단어는 별, 마지막 단어는 기름입니다.", + }, + { + "file": "last-sound.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘오리’, ‘수리’, ‘소리’.", ++ "text": "처음 단어는 별, 마지막 단어는 소리입니다.", + }, + { + "file": "last-meat.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘보기’, ‘고개’, ‘고기’.", ++ "text": "처음 단어는 별, 마지막 단어는 고기입니다.", + }, + { + "file": "last-furniture.wav", +- "text": "세 단어 중 마지막을 고르세요. ‘하구’, ‘가교’, ‘가구’.", ++ "text": "처음 단어는 별, 마지막 단어는 가구입니다.", + }, + { + "file": "first-apple.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘사과’, ‘다과’, ‘사고’.", ++ "text": "첫 번째는 사과, 두 번째는 달입니다.", + }, + { + "file": "first-sea.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘바다’, ‘사다’, ‘보다’.", ++ "text": "첫 번째는 바다, 두 번째는 달입니다.", + }, + { + "file": "first-tree.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘나무’, ‘나루’, ‘너무’.", ++ "text": "첫 번째는 나무, 두 번째는 달입니다.", + }, + { + "file": "first-hat.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘모자’, ‘포자’, ‘모조’.", ++ "text": "첫 번째는 모자, 두 번째는 달입니다.", + }, + { + "file": "first-umbrella.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘우산’, ‘부산’, ‘오산’.", ++ "text": "첫 번째는 우산, 두 번째는 달입니다.", + }, + { + "file": "first-market.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘시장’, ‘지장’, ‘사장’.", ++ "text": "첫 번째는 시장, 두 번째는 달입니다.", + }, + { + "file": "first-oil.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘기름’, ‘이름’, ‘거름’.", ++ "text": "첫 번째는 기름, 두 번째는 달입니다.", + }, + { + "file": "first-sound.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘소리’, ‘오리’, ‘수리’.", ++ "text": "첫 번째는 소리, 두 번째는 달입니다.", + }, + { + "file": "first-meat.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘고기’, ‘보기’, ‘고개’.", ++ "text": "첫 번째는 고기, 두 번째는 달입니다.", + }, + { + "file": "first-furniture.wav", +- "text": "세 단어 중 첫 번째를 고르세요. ‘가구’, ‘하구’, ‘가교’.", ++ "text": "첫 번째는 가구, 두 번째는 달입니다.", + }, + { + "file": "repeat-apple.wav", +- "text": "두 번 들린 단어를 고르세요. ‘사과’, ‘다과’, ‘사과’, ‘사고’.", ++ "text": "사과. 다시 한번, 사과.", + }, + { + "file": "repeat-sea.wav", +- "text": "두 번 들린 단어를 고르세요. ‘바다’, ‘사다’, ‘바다’, ‘보다’.", ++ "text": "바다. 다시 한번, 바다.", + }, + { + "file": "repeat-tree.wav", +- "text": "두 번 들린 단어를 고르세요. ‘나무’, ‘나루’, ‘나무’, ‘너무’.", ++ "text": "나무. 다시 한번, 나무.", + }, + { + "file": "repeat-hat.wav", +- "text": "두 번 들린 단어를 고르세요. ‘모자’, ‘포자’, ‘모자’, ‘모조’.", ++ "text": "모자. 다시 한번, 모자.", + }, + { + "file": "repeat-umbrella.wav", +- "text": "두 번 들린 단어를 고르세요. ‘우산’, ‘부산’, ‘우산’, ‘오산’.", ++ "text": "우산. 다시 한번, 우산.", + }, + { + "file": "repeat-market.wav", +- "text": "두 번 들린 단어를 고르세요. ‘시장’, ‘지장’, ‘시장’, ‘사장’.", ++ "text": "시장. 다시 한번, 시장.", + }, + { + "file": "repeat-oil.wav", +- "text": "두 번 들린 단어를 고르세요. ‘기름’, ‘이름’, ‘기름’, ‘거름’.", ++ "text": "기름. 다시 한번, 기름.", + }, + { + "file": "repeat-sound.wav", +- "text": "두 번 들린 단어를 고르세요. ‘소리’, ‘오리’, ‘소리’, ‘수리’.", ++ "text": "소리. 다시 한번, 소리.", + }, + { + "file": "repeat-meat.wav", +- "text": "두 번 들린 단어를 고르세요. ‘고기’, ‘보기’, ‘고기’, ‘고개’.", ++ "text": "고기. 다시 한번, 고기.", + }, + { + "file": "repeat-furniture.wav", +- "text": "두 번 들린 단어를 고르세요. ‘가구’, ‘하구’, ‘가구’, ‘가교’.", ++ "text": "가구. 다시 한번, 가구.", + }, + { + "file": "correction-apple.wav", +- "text": "정답은 ‘다과’입니다. 아니, 잘못 말했어요. ‘사과’로 정정합니다.", ++ "text": "달이 아니라 사과입니다.", + }, + { + "file": "correction-sea.wav", +- "text": "정답은 ‘사다’입니다. 아니, 잘못 말했어요. ‘바다’로 정정합니다.", ++ "text": "달이 아니라 바다입니다.", + }, + { + "file": "correction-tree.wav", +- "text": "정답은 ‘나루’입니다. 아니, 잘못 말했어요. ‘나무’로 정정합니다.", ++ "text": "달이 아니라 나무입니다.", + }, + { + "file": "correction-hat.wav", +- "text": "정답은 ‘포자’입니다. 아니, 잘못 말했어요. ‘모자’로 정정합니다.", ++ "text": "달이 아니라 모자입니다.", + }, + { + "file": "correction-umbrella.wav", +- "text": "정답은 ‘부산’입니다. 아니, 잘못 말했어요. ‘우산’로 정정합니다.", ++ "text": "달이 아니라 우산입니다.", + }, + { + "file": "correction-market.wav", +- "text": "정답은 ‘지장’입니다. 아니, 잘못 말했어요. ‘시장’로 정정합니다.", ++ "text": "달이 아니라 시장입니다.", + }, + { + "file": "correction-oil.wav", +- "text": "정답은 ‘이름’입니다. 아니, 잘못 말했어요. ‘기름’로 정정합니다.", ++ "text": "달이 아니라 기름입니다.", + }, + { + "file": "correction-sound.wav", +- "text": "정답은 ‘오리’입니다. 아니, 잘못 말했어요. ‘소리’로 정정합니다.", ++ "text": "달이 아니라 소리입니다.", + }, + { + "file": "correction-meat.wav", +- "text": "정답은 ‘보기’입니다. 아니, 잘못 말했어요. ‘고기’로 정정합니다.", ++ "text": "달이 아니라 고기입니다.", + }, + { + "file": "correction-furniture.wav", +- "text": "정답은 ‘하구’입니다. 아니, 잘못 말했어요. ‘가구’로 정정합니다.", ++ "text": "달이 아니라 가구입니다.", + }, + { + "file": "middle-apple.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘다과’, ‘사과’, ‘사고’.", ++ "text": "별, 사과, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-sea.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘사다’, ‘바다’, ‘보다’.", ++ "text": "별, 바다, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-tree.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘나루’, ‘나무’, ‘너무’.", ++ "text": "별, 나무, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-hat.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘포자’, ‘모자’, ‘모조’.", ++ "text": "별, 모자, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-umbrella.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘부산’, ‘우산’, ‘오산’.", ++ "text": "별, 우산, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-market.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘지장’, ‘시장’, ‘사장’.", ++ "text": "별, 시장, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-oil.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘이름’, ‘기름’, ‘거름’.", ++ "text": "별, 기름, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-sound.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘오리’, ‘소리’, ‘수리’.", ++ "text": "별, 소리, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-meat.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘보기’, ‘고기’, ‘고개’.", ++ "text": "별, 고기, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "middle-furniture.wav", +- "text": "세 단어 중 가운데를 고르세요. ‘하구’, ‘가구’, ‘가교’.", ++ "text": "별, 가구, 달. 가운데 단어를 기억하세요.", + }, + { + "file": "message-apple.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘다과’, ‘사과’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 사과입니다.", + }, + { + "file": "message-sea.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘사다’, ‘바다’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 바다입니다.", + }, + { + "file": "message-tree.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘나루’, ‘나무’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 나무입니다.", + }, + { + "file": "message-hat.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘포자’, ‘모자’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 모자입니다.", + }, + { + "file": "message-umbrella.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘부산’, ‘우산’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 우산입니다.", + }, + { + "file": "message-market.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘지장’, ‘시장’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 시장입니다.", + }, + { + "file": "message-oil.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘이름’, ‘기름’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 기름입니다.", + }, + { + "file": "message-sound.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘오리’, ‘소리’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 소리입니다.", + }, + { + "file": "message-meat.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘보기’, ‘고기’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 고기입니다.", + }, + { + "file": "message-furniture.wav", +- "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘하구’, ‘가구’. 지금은 빨간 불입니다.", ++ "text": "에스가 남긴 메시지는 가구입니다.", + }, + { + "file": "mission-apple.wav", +- "text": "오른쪽 표지는 ‘사과’, 왼쪽 표지는 ‘다과’입니다.", ++ "text": "이번 임무의 단서는 사과입니다.", + }, + { + "file": "mission-sea.wav", +- "text": "오른쪽 표지는 ‘바다’, 왼쪽 표지는 ‘사다’입니다.", ++ "text": "이번 임무의 단서는 바다입니다.", + }, + { + "file": "mission-tree.wav", +- "text": "오른쪽 표지는 ‘나무’, 왼쪽 표지는 ‘나루’입니다.", ++ "text": "이번 임무의 단서는 나무입니다.", + }, + { + "file": "mission-hat.wav", +- "text": "오른쪽 표지는 ‘모자’, 왼쪽 표지는 ‘포자’입니다.", ++ "text": "이번 임무의 단서는 모자입니다.", + }, + { + "file": "mission-umbrella.wav", +- "text": "오른쪽 표지는 ‘우산’, 왼쪽 표지는 ‘부산’입니다.", ++ "text": "이번 임무의 단서는 우산입니다.", + }, + { + "file": "mission-market.wav", +- "text": "오른쪽 표지는 ‘시장’, 왼쪽 표지는 ‘지장’입니다.", ++ "text": "이번 임무의 단서는 시장입니다.", + }, + { + "file": "mission-oil.wav", +- "text": "오른쪽 표지는 ‘기름’, 왼쪽 표지는 ‘이름’입니다.", ++ "text": "이번 임무의 단서는 기름입니다.", + }, + { + "file": "mission-sound.wav", +- "text": "오른쪽 표지는 ‘소리’, 왼쪽 표지는 ‘오리’입니다.", ++ "text": "이번 임무의 단서는 소리입니다.", + }, + { + "file": "mission-meat.wav", +- "text": "오른쪽 표지는 ‘고기’, 왼쪽 표지는 ‘보기’입니다.", ++ "text": "이번 임무의 단서는 고기입니다.", + }, + { + "file": "mission-furniture.wav", +- "text": "오른쪽 표지는 ‘가구’, 왼쪽 표지는 ‘하구’입니다.", ++ "text": "이번 임무의 단서는 가구입니다.", + }, + ] + + ❯ tests/assets.test.ts:36:70 + 34| ), + 35| ]; + 36| expect(manifest.files.map(({ file, text }) => ({ file, text }))).t… + | ^ + 37| expected, + 38| ); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 1 passed (2) + Start at 05:54:06 + Duration 554ms (setup 57%, transform 22%, tests 15%, import 4%, worker 2%) diff --git a/.agent/evidence/20-mechanics-green.log b/.agent/evidence/20-mechanics-green.log new file mode 100644 index 0000000..7dedf46 --- /dev/null +++ b/.agent/evidence/20-mechanics-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts tests/game.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 2 passed (2) + Tests 22 passed (22) + Start at 05:54:06 + Duration 1.18s (environment 80%, setup 13%, transform 3%, tests 3%, worker 1%, import 1%) diff --git a/.agent/evidence/20-mechanics-red.log b/.agent/evidence/20-mechanics-red.log new file mode 100644 index 0000000..be3c196 --- /dev/null +++ b/.agent/evidence/20-mechanics-red.log @@ -0,0 +1,2329 @@ + +> find-word@0.1.0 test +> vitest run tests/content.test.ts tests/game.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/game.test.ts (7 tests | 1 failed) 21ms + ❯ 출제와 정답 판정 (4) + × 실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다 10ms + ❯ tests/content.test.ts (15 tests | 11 failed) 98ms + ❯ 저장된 문제·환경 데이터 (15) + × 열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다 11ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 14ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 9ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 8ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 9ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 7ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 6ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 6ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 7ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 8ms + × undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 6ms + +⎯⎯⎯⎯⎯⎯ Failed Tests 12 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > 열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다 +AssertionError: expected Set{ undefined } to deeply equal Set{ 'direct-target', …(9) } + +- Expected ++ Received + + Set { +- "conditional-cue", +- "correction", +- "direct-target", +- "distractor-password", +- "first", +- "ignore-then-remember", +- "last", +- "middle", +- "repetition", +- "spatial-right-cue", ++ undefined, + } + + ❯ tests/content.test.ts:15:54 + 13| it("열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다", () => { + 14| expect(PATTERNS).toHaveLength(10); + 15| expect(new Set(PATTERNS.map((p) => p.mechanic))).toEqual(new Set([ + | ^ + 16| "direct-target", "distractor-password", "ignore-then-remember", … + 17| "last", "middle", "repetition", "correction", "conditional-cue",… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'direct-target' // Object.is equality + +- Expected: +"direct-target" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사과' ] + +- Expected ++ Received + +- [ +- "사과", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to have a length of 1 but got +0 + +- Expected ++ Received + +- 1 ++ 0 + + ❯ tests/content.test.ts:47:66 + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + 47| expect.soft(heard.filter((word) => word === entry.answer)).toHav… + | ^ + 48| } + 49| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '바다' ] + +- Expected ++ Received + +- [ +- "바다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나무' ] + +- Expected ++ Received + +- [ +- "나무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '모자' ] + +- Expected ++ Received + +- [ +- "모자", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '우산' ] + +- Expected ++ Received + +- [ +- "우산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '시장' ] + +- Expected ++ Received + +- [ +- "시장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '기름' ] + +- Expected ++ Received + +- [ +- "기름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '소리' ] + +- Expected ++ Received + +- [ +- "소리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '고기' ] + +- Expected ++ Received + +- [ +- "고기", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '가구' ] + +- Expected ++ Received + +- [ +- "가구", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'distractor-password' // Object.is equality + +- Expected: +"distractor-password" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '들은 비밀 암호는 무엇인가요?' to match /진짜 암호/ + +- Expected: +/진짜 암호/ + ++ Received: +"들은 비밀 암호는 무엇인가요?" + + ❯ tests/content.test.ts:36:35 + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + | ^ + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + 38| for (const entry of WORDS) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '비밀 암호는 {word}입니다.' to match /연습 암호/ + +- Expected: +/연습 암호/ + ++ Received: +"비밀 암호는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '비밀 암호는 {word}입니다.' to match /진짜 암호/ + +- Expected: +/진짜 암호/ + ++ Received: +"비밀 암호는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '다과', '사과' ] + +- Expected ++ Received + +- [ +- "다과", +- "사과", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사다', '바다' ] + +- Expected ++ Received + +- [ +- "사다", +- "바다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나루', '나무' ] + +- Expected ++ Received + +- [ +- "나루", +- "나무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '포자', '모자' ] + +- Expected ++ Received + +- [ +- "포자", +- "모자", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '부산', '우산' ] + +- Expected ++ Received + +- [ +- "부산", +- "우산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[22/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '지장', '시장' ] + +- Expected ++ Received + +- [ +- "지장", +- "시장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[23/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '이름', '기름' ] + +- Expected ++ Received + +- [ +- "이름", +- "기름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[24/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '오리', '소리' ] + +- Expected ++ Received + +- [ +- "오리", +- "소리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[25/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '보기', '고기' ] + +- Expected ++ Received + +- [ +- "보기", +- "고기", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[26/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '하구', '가구' ] + +- Expected ++ Received + +- [ +- "하구", +- "가구", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[27/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'ignore-then-remember' // Object.is equality + +- Expected: +"ignore-then-remember" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[28/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '꼭 기억할 단어는 {word}입니다.' to match /잊으세요.*이제.*기억하세요/ + +- Expected: +/잊으세요.*이제.*기억하세요/ + ++ Received: +"꼭 기억할 단어는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[29/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'first' // Object.is equality + +- Expected: +"first" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[30/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '첫 번째는 {word}, 두 번째는 달입니다.' to match /세 단어.*첫/ + +- Expected: +/세 단어.*첫/ + ++ Received: +"첫 번째는 {word}, 두 번째는 달입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[31/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사과', '다과', '사고' ] + +- Expected ++ Received + +- [ +- "사과", +- "다과", +- "사고", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[32/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '바다', '사다', '보다' ] + +- Expected ++ Received + +- [ +- "바다", +- "사다", +- "보다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[33/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나무', '나루', '너무' ] + +- Expected ++ Received + +- [ +- "나무", +- "나루", +- "너무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[34/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '모자', '포자', '모조' ] + +- Expected ++ Received + +- [ +- "모자", +- "포자", +- "모조", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[35/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '우산', '부산', '오산' ] + +- Expected ++ Received + +- [ +- "우산", +- "부산", +- "오산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[36/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '시장', '지장', '사장' ] + +- Expected ++ Received + +- [ +- "시장", +- "지장", +- "사장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[37/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '기름', '이름', '거름' ] + +- Expected ++ Received + +- [ +- "기름", +- "이름", +- "거름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[38/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '소리', '오리', '수리' ] + +- Expected ++ Received + +- [ +- "소리", +- "오리", +- "수리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[39/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '고기', '보기', '고개' ] + +- Expected ++ Received + +- [ +- "고기", +- "보기", +- "고개", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[40/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '가구', '하구', '가교' ] + +- Expected ++ Received + +- [ +- "가구", +- "하구", +- "가교", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[41/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'last' // Object.is equality + +- Expected: +"last" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[42/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '처음 단어는 별, 마지막 단어는 {word}입니다.' to match /세 단어.*마지막/ + +- Expected: +/세 단어.*마지막/ + ++ Received: +"처음 단어는 별, 마지막 단어는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[43/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '다과', '사고', '사과' ] + +- Expected ++ Received + +- [ +- "다과", +- "사고", +- "사과", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[44/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사다', '보다', '바다' ] + +- Expected ++ Received + +- [ +- "사다", +- "보다", +- "바다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[45/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나루', '너무', '나무' ] + +- Expected ++ Received + +- [ +- "나루", +- "너무", +- "나무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[46/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '포자', '모조', '모자' ] + +- Expected ++ Received + +- [ +- "포자", +- "모조", +- "모자", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[47/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '부산', '오산', '우산' ] + +- Expected ++ Received + +- [ +- "부산", +- "오산", +- "우산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[48/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '지장', '사장', '시장' ] + +- Expected ++ Received + +- [ +- "지장", +- "사장", +- "시장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[49/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '이름', '거름', '기름' ] + +- Expected ++ Received + +- [ +- "이름", +- "거름", +- "기름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[50/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '오리', '수리', '소리' ] + +- Expected ++ Received + +- [ +- "오리", +- "수리", +- "소리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[51/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '보기', '고개', '고기' ] + +- Expected ++ Received + +- [ +- "보기", +- "고개", +- "고기", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[52/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '하구', '가교', '가구' ] + +- Expected ++ Received + +- [ +- "하구", +- "가교", +- "가구", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[53/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'middle' // Object.is equality + +- Expected: +"middle" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[54/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '별, {word}, 달. 가운데 단어를 기억하세요.' to match /세 단어.*가운데/ + +- Expected: +/세 단어.*가운데/ + ++ Received: +"별, {word}, 달. 가운데 단어를 기억하세요." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[55/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '다과', '사과', '사고' ] + +- Expected ++ Received + +- [ +- "다과", +- "사과", +- "사고", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[56/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사다', '바다', '보다' ] + +- Expected ++ Received + +- [ +- "사다", +- "바다", +- "보다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[57/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나루', '나무', '너무' ] + +- Expected ++ Received + +- [ +- "나루", +- "나무", +- "너무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[58/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '포자', '모자', '모조' ] + +- Expected ++ Received + +- [ +- "포자", +- "모자", +- "모조", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[59/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '부산', '우산', '오산' ] + +- Expected ++ Received + +- [ +- "부산", +- "우산", +- "오산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[60/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '지장', '시장', '사장' ] + +- Expected ++ Received + +- [ +- "지장", +- "시장", +- "사장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[61/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '이름', '기름', '거름' ] + +- Expected ++ Received + +- [ +- "이름", +- "기름", +- "거름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[62/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '오리', '소리', '수리' ] + +- Expected ++ Received + +- [ +- "오리", +- "소리", +- "수리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[63/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '보기', '고기', '고개' ] + +- Expected ++ Received + +- [ +- "보기", +- "고기", +- "고개", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[64/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '하구', '가구', '가교' ] + +- Expected ++ Received + +- [ +- "하구", +- "가구", +- "가교", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[65/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'repetition' // Object.is equality + +- Expected: +"repetition" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[66/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '{word}. 다시 한번, {word}.' to match /두 번/ + +- Expected: +/두 번/ + ++ Received: +"{word}. 다시 한번, {word}." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[67/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사과', '다과', '사과', '사고' ] + +- Expected ++ Received + +- [ +- "사과", +- "다과", +- "사과", +- "사고", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[68/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to have a length of 2 but got +0 + +- Expected ++ Received + +- 2 ++ 0 + + ❯ tests/content.test.ts:47:66 + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + 47| expect.soft(heard.filter((word) => word === entry.answer)).toHav… + | ^ + 48| } + 49| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[69/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '바다', '사다', '바다', '보다' ] + +- Expected ++ Received + +- [ +- "바다", +- "사다", +- "바다", +- "보다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[70/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나무', '나루', '나무', '너무' ] + +- Expected ++ Received + +- [ +- "나무", +- "나루", +- "나무", +- "너무", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[71/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '모자', '포자', '모자', '모조' ] + +- Expected ++ Received + +- [ +- "모자", +- "포자", +- "모자", +- "모조", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[72/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '우산', '부산', '우산', '오산' ] + +- Expected ++ Received + +- [ +- "우산", +- "부산", +- "우산", +- "오산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[73/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '시장', '지장', '시장', '사장' ] + +- Expected ++ Received + +- [ +- "시장", +- "지장", +- "시장", +- "사장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[74/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '기름', '이름', '기름', '거름' ] + +- Expected ++ Received + +- [ +- "기름", +- "이름", +- "기름", +- "거름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[75/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '소리', '오리', '소리', '수리' ] + +- Expected ++ Received + +- [ +- "소리", +- "오리", +- "소리", +- "수리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[76/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '고기', '보기', '고기', '고개' ] + +- Expected ++ Received + +- [ +- "고기", +- "보기", +- "고기", +- "고개", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[77/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '가구', '하구', '가구', '가교' ] + +- Expected ++ Received + +- [ +- "가구", +- "하구", +- "가구", +- "가교", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[78/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'correction' // Object.is equality + +- Expected: +"correction" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[79/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '말을 바로잡아 알려준 단어는 무엇인가요?' to match /정정/ + +- Expected: +/정정/ + ++ Received: +"말을 바로잡아 알려준 단어는 무엇인가요?" + + ❯ tests/content.test.ts:36:35 + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + | ^ + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + 38| for (const entry of WORDS) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[80/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '달이 아니라 {word}입니다.' to match /아니.*잘못.*정정/ + +- Expected: +/아니.*잘못.*정정/ + ++ Received: +"달이 아니라 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[81/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'conditional-cue' // Object.is equality + +- Expected: +"conditional-cue" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[82/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '에스가 메시지로 남긴 단어는 무엇인가요?' to match /신호/ + +- Expected: +/신호/ + ++ Received: +"에스가 메시지로 남긴 단어는 무엇인가요?" + + ❯ tests/content.test.ts:36:35 + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + | ^ + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + 38| for (const entry of WORDS) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[83/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '에스가 남긴 메시지는 {word}입니다.' to match /초록 불이면 앞 단어/ + +- Expected: +/초록 불이면 앞 단어/ + ++ Received: +"에스가 남긴 메시지는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[84/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '에스가 남긴 메시지는 {word}입니다.' to match /빨간 불이면 뒤 단어/ + +- Expected: +/빨간 불이면 뒤 단어/ + ++ Received: +"에스가 남긴 메시지는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[85/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '에스가 남긴 메시지는 {word}입니다.' to match /지금은 빨간 불/ + +- Expected: +/지금은 빨간 불/ + ++ Received: +"에스가 남긴 메시지는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[86/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected undefined to be 'spatial-right-cue' // Object.is equality + +- Expected: +"spatial-right-cue" + ++ Received: +undefined + + ❯ tests/content.test.ts:35:35 + 33| ])("$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", ({ id, mechanic, order,… + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + | ^ + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[87/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '이번 임무의 단서는 무엇인가요?' to match /오른쪽/ + +- Expected: +/오른쪽/ + ++ Received: +"이번 임무의 단서는 무엇인가요?" + + ❯ tests/content.test.ts:36:35 + 34| const pattern = PATTERNS.find((p) => p.id === id)!; + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + | ^ + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + 38| for (const entry of WORDS) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[88/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected '이번 임무의 단서는 {word}입니다.' to match /오른쪽 표지.*왼쪽 표지/ + +- Expected: +/오른쪽 표지.*왼쪽 표지/ + ++ Received: +"이번 임무의 단서는 {word}입니다." + + ❯ tests/content.test.ts:37:57 + 35| expect.soft(pattern.mechanic).toBe(mechanic); + 36| expect.soft(pattern.question).toMatch(question); + 37| cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + | ^ + 38| for (const entry of WORDS) { + 39| const choices = [entry.answer, ...entry.distractors.map((d) => d… + ❯ tests/content.test.ts:37:10 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[89/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '사과', '다과' ] + +- Expected ++ Received + +- [ +- "사과", +- "다과", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[90/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '바다', '사다' ] + +- Expected ++ Received + +- [ +- "바다", +- "사다", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[91/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '나무', '나루' ] + +- Expected ++ Received + +- [ +- "나무", +- "나루", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[92/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '모자', '포자' ] + +- Expected ++ Received + +- [ +- "모자", +- "포자", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[93/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '우산', '부산' ] + +- Expected ++ Received + +- [ +- "우산", +- "부산", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[94/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '시장', '지장' ] + +- Expected ++ Received + +- [ +- "시장", +- "지장", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[95/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '기름', '이름' ] + +- Expected ++ Received + +- [ +- "기름", +- "이름", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[96/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '소리', '오리' ] + +- Expected ++ Received + +- [ +- "소리", +- "오리", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[97/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '고기', '보기' ] + +- Expected ++ Received + +- [ +- "고기", +- "보기", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[98/228]⎯ + + FAIL tests/content.test.ts > 저장된 문제·환경 데이터 > undefined 열 문장은 해당 청취 의도로만 답을 고를 수 있다 +AssertionError: expected [] to deeply equal [ '가구', '하구' ] + +- Expected ++ Received + +- [ +- "가구", +- "하구", +- ] ++ [] + + ❯ tests/content.test.ts:44:26 + 42| .replaceAll("{distractor2}", choices[2]!); + 43| const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map((match) =… + 44| expect.soft(heard).toEqual(order.map((index) => choices[index])); + | ^ + 45| expect(sentence).not.toMatch(/[{}]/); + 46| // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[99/228]⎯ + + FAIL tests/game.test.ts > 출제와 정답 판정 > 실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다 +AssertionError: expected '비밀 암호는 사과입니다.' to contain '‘사과’' + +Expected: "‘사과’" +Received: "비밀 암호는 사과입니다." + + ❯ tests/game.test.ts:18:28 + 16| for (const q of createQuestions(seed, "choice")) { + 17| expect(q.sentence).not.toMatch(/[{}]/); + 18| expect(q.sentence).toContain(`‘${q.word.answer}’`); + | ^ + 19| if (q.pattern.id !== "luck") { + 20| expect(q.sentence).toContain(`‘${q.word.distractors[0]!.word… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[100/228]⎯ + + + Test Files 2 failed (2) + Tests 12 failed | 10 passed (22) + Start at 05:53:17 + Duration 1.83s (environment 75%, setup 13%, tests 6%, transform 4%, import 1%, worker 1%) diff --git a/.agent/evidence/21-node-green.log b/.agent/evidence/21-node-green.log new file mode 100644 index 0000000..ccc7721 --- /dev/null +++ b/.agent/evidence/21-node-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/release.test.ts -t Node 최소 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 1 passed | 3 skipped (4) + Start at 05:55:53 + Duration 558ms (setup 82%, transform 8%, worker 4%, tests 3%, import 2%) diff --git a/.agent/evidence/21-node-red.log b/.agent/evidence/21-node-red.log new file mode 100644 index 0000000..3226a36 --- /dev/null +++ b/.agent/evidence/21-node-red.log @@ -0,0 +1,438 @@ + +> find-word@0.1.0 test +> vitest run tests/release.test.ts -t Node 최소 + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/release.test.ts (4 tests | 1 failed | 3 skipped) 27ms + ❯ 릴리스 문서 계약 (4) + × Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 25ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: expected '>=22.12.0' to be '>=22.22.2' // Object.is equality + +Expected: ">=22.22.2" +Received: ">=22.12.0" + + ❯ tests/release.test.ts:11:40 + 9| const metadata = JSON.parse(readFileSync("package.json", "utf8")); + 10| const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + 11| expect.soft(metadata.engines.node).toBe(">=22.22.2"); + | ^ + 12| expect.soft(lock.packages[""].engines.node).toBe(">=22.22.2"); + 13| expect(lock.packages["node_modules/jsdom"].engines.node).toContain… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/6]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: expected '>=22.12.0' to be '>=22.22.2' // Object.is equality + +Expected: ">=22.22.2" +Received: ">=22.12.0" + + ❯ tests/release.test.ts:12:49 + 10| const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + 11| expect.soft(metadata.engines.node).toBe(">=22.22.2"); + 12| expect.soft(lock.packages[""].engines.node).toBe(">=22.22.2"); + | ^ + 13| expect(lock.packages["node_modules/jsdom"].engines.node).toContain… + 14| for (const path of ["README.md", "docs/DEPLOYMENT.md"]) { + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/6]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain '22.22.2' + +- Expected ++ Received + +- 22.22.2 ++ # 단어를 맞춰라 · Find-Word v0.1 ++ ++ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. ++ ++ **Designed by OZ at Heat-T Medical Inc.** ++ ++ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word). ++ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의 ++ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요. ++ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은 ++ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며, ++ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다. ++ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다. ++ ++ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, ++ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 ++ 의료적 진단·치료나 효과를 주장하지 않습니다. ++ ++ ## 실행 ++ ++ Node.js **22.12 이상**의 지원되는 LTS 버전을 권장합니다. `package-lock.json`을 ++ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. ++ ++ ```bash ++ npm ci ++ npm run dev ++ ``` ++ ++ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 ++ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. ++ ++ ## 플레이 ++ ++ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. ++ - 8개 환경을 순환하거나 하나를 지정합니다. ++ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. ++ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**. ++ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. ++ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다. ++ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다. ++ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. ++ ++ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 ++ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 ++ [WORDS.md](docs/WORDS.md)에 설명했습니다. ++ ++ ## 검증과 정적 빌드 ++ ++ ```bash ++ npm run lint ++ npm test ++ npm run build ++ npm run preview ++ ``` ++ ++ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 ++ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로 ++ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. ++ ++ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: ++ ++ ```bash ++ # Chromium이 없는 개발 환경에서 한 번만 실행합니다. ++ npx playwright install chromium ++ npm run test:browser ++ # 이미 설치된 Chrome을 사용할 때의 대안입니다. ++ CHROME_PATH=/usr/bin/google-chrome npm run test:browser ++ ``` ++ ++ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. ++ 실제 음성 재생이 끝나기를 기다려 약 1–2분이 걸립니다. 로컬 4173 포트를 ++ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다. ++ ++ ## 음성과 문서 ++ ++ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은 ++ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다. ++ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 ++ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. ++ ++ - [설계와 상태 전이](docs/ARCHITECTURE.md) ++ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) ++ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md) ++ ++ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. ++ + + ❯ tests/release.test.ts:16:35 + 14| for (const path of ["README.md", "docs/DEPLOYMENT.md"]) { + 15| const document = readFileSync(path, "utf8"); + 16| expect.soft(document, path).toContain("22.22.2"); + | ^ + 17| expect.soft(document, path).not.toContain("22.12"); + 18| } + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/6]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' not to contain '22.12' + +- Expected ++ Received + +- 22.12 ++ # 단어를 맞춰라 · Find-Word v0.1 ++ ++ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. ++ ++ **Designed by OZ at Heat-T Medical Inc.** ++ ++ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word). ++ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의 ++ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요. ++ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은 ++ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며, ++ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다. ++ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다. ++ ++ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, ++ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 ++ 의료적 진단·치료나 효과를 주장하지 않습니다. ++ ++ ## 실행 ++ ++ Node.js **22.12 이상**의 지원되는 LTS 버전을 권장합니다. `package-lock.json`을 ++ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. ++ ++ ```bash ++ npm ci ++ npm run dev ++ ``` ++ ++ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 ++ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. ++ ++ ## 플레이 ++ ++ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. ++ - 8개 환경을 순환하거나 하나를 지정합니다. ++ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. ++ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**. ++ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. ++ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다. ++ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다. ++ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. ++ ++ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 ++ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 ++ [WORDS.md](docs/WORDS.md)에 설명했습니다. ++ ++ ## 검증과 정적 빌드 ++ ++ ```bash ++ npm run lint ++ npm test ++ npm run build ++ npm run preview ++ ``` ++ ++ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 ++ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로 ++ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. ++ ++ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: ++ ++ ```bash ++ # Chromium이 없는 개발 환경에서 한 번만 실행합니다. ++ npx playwright install chromium ++ npm run test:browser ++ # 이미 설치된 Chrome을 사용할 때의 대안입니다. ++ CHROME_PATH=/usr/bin/google-chrome npm run test:browser ++ ``` ++ ++ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. ++ 실제 음성 재생이 끝나기를 기다려 약 1–2분이 걸립니다. 로컬 4173 포트를 ++ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다. ++ ++ ## 음성과 문서 ++ ++ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은 ++ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다. ++ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 ++ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. ++ ++ - [설계와 상태 전이](docs/ARCHITECTURE.md) ++ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) ++ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md) ++ ++ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. ++ + + ❯ tests/release.test.ts:17:39 + 15| const document = readFileSync(path, "utf8"); + 16| expect.soft(document, path).toContain("22.22.2"); + 17| expect.soft(document, path).not.toContain("22.12"); + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/6]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: docs/DEPLOYMENT.md: expected '# 정적 배포 인계\n\n이 문서는 **Neo가 검토 후 수행할 절…' to contain '22.22.2' + +- Expected ++ Received + +- 22.22.2 ++ # 정적 배포 인계 ++ ++ 이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에 ++ 접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다. ++ ++ ## 산출물 ++ ++ Node.js 22.12 이상 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`, ++ `npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다. ++ `dist/audio/`의 WAV 101개와 manifest가 반드시 포함되어야 한다. 런타임은 ++ Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다. ++ ++ Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다. ++ 프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다. ++ React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도 ++ 반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다. ++ ++ 현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라 ++ 음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다. ++ ++ ## Nginx 예시 ++ ++ 아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다. ++ 기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다. ++ ++ ```nginx ++ # 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다. ++ server { ++ listen 80; ++ server_name findword.heart-m.com; ++ root /var/www/findword/current; ++ index index.html; ++ ++ # 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다. ++ include /etc/nginx/mime.types; ++ add_header X-Content-Type-Options nosniff always; ++ add_header Referrer-Policy no-referrer always; ++ add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; ++ ++ # 파일명이 해시로 바뀌는 번들만 장기 캐시한다. ++ location /assets/ { ++ try_files $uri =404; ++ expires 1y; ++ } ++ ++ # 음성 파일명은 재생성해도 같으므로 재검증한다. 누락 시 index.html 대신 ++ # 404를 반환해야 앱이 명확한 음성 로드 오류와 재시도를 제공할 수 있다. ++ location /audio/ { ++ try_files $uri =404; ++ expires -1; ++ } ++ ++ # SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다. ++ location / { ++ try_files $uri $uri/ /index.html; ++ expires -1; ++ } ++ } ++ ``` ++ ++ ## 인수 확인 ++ ++ 1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다. ++ 2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다. ++ 3. `audio/intro.wav`와 목표 WAV가 정상 응답하고 존재하지 않는 오디오는 404인지 확인한다. ++ 4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·10문제 완주를 직접 확인한다. ++ 5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다. ++ ++ 오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버 ++ 연결이 필요하다. 페이지·오디오 캐시가 서로 다른 버전으로 섞이지 않게 HTML과 ++ 음성의 재검증 정책을 유지한다. ++ + + ❯ tests/release.test.ts:16:35 + 14| for (const path of ["README.md", "docs/DEPLOYMENT.md"]) { + 15| const document = readFileSync(path, "utf8"); + 16| expect.soft(document, path).toContain("22.22.2"); + | ^ + 17| expect.soft(document, path).not.toContain("22.12"); + 18| } + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/6]⎯ + + FAIL tests/release.test.ts > 릴리스 문서 계약 > Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다 +AssertionError: docs/DEPLOYMENT.md: expected '# 정적 배포 인계\n\n이 문서는 **Neo가 검토 후 수행할 절…' not to contain '22.12' + +- Expected ++ Received + +- 22.12 ++ # 정적 배포 인계 ++ ++ 이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에 ++ 접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다. ++ ++ ## 산출물 ++ ++ Node.js 22.12 이상 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`, ++ `npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다. ++ `dist/audio/`의 WAV 101개와 manifest가 반드시 포함되어야 한다. 런타임은 ++ Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다. ++ ++ Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다. ++ 프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다. ++ React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도 ++ 반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다. ++ ++ 현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라 ++ 음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다. ++ ++ ## Nginx 예시 ++ ++ 아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다. ++ 기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다. ++ ++ ```nginx ++ # 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다. ++ server { ++ listen 80; ++ server_name findword.heart-m.com; ++ root /var/www/findword/current; ++ index index.html; ++ ++ # 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다. ++ include /etc/nginx/mime.types; ++ add_header X-Content-Type-Options nosniff always; ++ add_header Referrer-Policy no-referrer always; ++ add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; ++ ++ # 파일명이 해시로 바뀌는 번들만 장기 캐시한다. ++ location /assets/ { ++ try_files $uri =404; ++ expires 1y; ++ } ++ ++ # 음성 파일명은 재생성해도 같으므로 재검증한다. 누락 시 index.html 대신 ++ # 404를 반환해야 앱이 명확한 음성 로드 오류와 재시도를 제공할 수 있다. ++ location /audio/ { ++ try_files $uri =404; ++ expires -1; ++ } ++ ++ # SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다. ++ location / { ++ try_files $uri $uri/ /index.html; ++ expires -1; ++ } ++ } ++ ``` ++ ++ ## 인수 확인 ++ ++ 1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다. ++ 2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다. ++ 3. `audio/intro.wav`와 목표 WAV가 정상 응답하고 존재하지 않는 오디오는 404인지 확인한다. ++ 4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·10문제 완주를 직접 확인한다. ++ 5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다. ++ ++ 오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버 ++ 연결이 필요하다. 페이지·오디오 캐시가 서로 다른 버전으로 섞이지 않게 HTML과 ++ 음성의 재검증 정책을 유지한다. ++ + + ❯ tests/release.test.ts:17:39 + 15| const document = readFileSync(path, "utf8"); + 16| expect.soft(document, path).toContain("22.22.2"); + 17| expect.soft(document, path).not.toContain("22.12"); + | ^ + 18| } + 19| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/6]⎯ + + + Test Files 1 failed (1) + Tests 1 failed | 3 skipped (4) + Start at 05:54:57 + Duration 357ms (setup 64%, transform 18%, tests 14%, worker 2%, import 2%) diff --git a/.agent/evidence/22-sandbox-green.log b/.agent/evidence/22-sandbox-green.log new file mode 100644 index 0000000..302939c --- /dev/null +++ b/.agent/evidence/22-sandbox-green.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/playwright-config.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 5 passed (5) + Start at 05:55:53 + Duration 3.51s (tests 89%, setup 10%, transform 1%) diff --git a/.agent/evidence/22-sandbox-red.log b/.agent/evidence/22-sandbox-red.log new file mode 100644 index 0000000..7c1a470 --- /dev/null +++ b/.agent/evidence/22-sandbox-red.log @@ -0,0 +1,78 @@ + +> find-word@0.1.0 test +> vitest run tests/playwright-config.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + ❯ tests/playwright-config.test.ts (5 tests | 5 failed) 2254ms + ❯ 브라우저 sandbox 기본 정책 (5) + × opt-in=undefined이면 sandbox를 켠다 472ms + × opt-in=이면 sandbox를 켠다 441ms + × opt-in=0이면 sandbox를 켠다 447ms + × opt-in=false이면 sandbox를 켠다 450ms + × 명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다 443ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=undefined이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=0이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=false이면 sandbox를 켠다 +AssertionError: expected undefined to be true // Object.is equality + +- Expected: +true + ++ Received: +undefined + + ❯ tests/playwright-config.test.ts:22:42 + 20| it.each([undefined, "", "0", "false"])("opt-in=%s이면 sandbox를 켠다", (v… + 21| const options = launchOptions(value); + 22| expect.soft(options.chromiumSandbox).toBe(true); + | ^ + 23| expect.soft(options.args ?? []).not.toContain("--no-sandbox"); + 24| expect(options.executablePath).toBe("/usr/bin/google-chrome"); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/9]⎯ + + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=undefined이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=0이면 sandbox를 켠다 + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > opt-in=false이면 sandbox를 켠다 +AssertionError: expected [ '--no-sandbox' ] to not include '--no-sandbox' + ❯ tests/playwright-config.test.ts:23:41 + 21| const options = launchOptions(value); + 22| expect.soft(options.chromiumSandbox).toBe(true); + 23| expect.soft(options.args ?? []).not.toContain("--no-sandbox"); + | ^ + 24| expect(options.executablePath).toBe("/usr/bin/google-chrome"); + 25| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/9]⎯ + + FAIL tests/playwright-config.test.ts > 브라우저 sandbox 기본 정책 > 명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다 +AssertionError: expected undefined to be false // Object.is equality + +- Expected: +false + ++ Received: +undefined + + ❯ tests/playwright-config.test.ts:27:48 + 25| }); + 26| it("명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다", () => { + 27| expect(launchOptions("1").chromiumSandbox).toBe(false); + | ^ + 28| }); + 29| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/9]⎯ + + + Test Files 1 failed (1) + Tests 5 failed (5) + Start at 05:54:57 + Duration 2.57s (tests 93%, setup 5%, transform 1%) diff --git a/.agent/evidence/23-delivery-browser-red.log b/.agent/evidence/23-delivery-browser-red.log new file mode 100644 index 0000000..2059849 --- /dev/null +++ b/.agent/evidence/23-delivery-browser-red.log @@ -0,0 +1,162 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/release.spec.ts --project=mobile + +[WebServer] (node:763846) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 1 test using 1 worker + +(node:763874) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [mobile] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 (1.7s) + + + 1) [mobile] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 ────────── + + Error: expect(locator).toBeVisible() failed + + Locator: getByText('Designed by OZ at Heat-T Medical Inc.', { exact: true }) + Expected: visible + Timeout: 1000ms + Error: element(s) not found + + Call log: + - Expect "soft toBeVisible" getByText('Designed by OZ at Heat-T Medical Inc.', { exact: true }) with timeout 1000ms + - waiting for getByText('Designed by OZ at Heat-T Medical Inc.', { exact: true }) + + + 7 | page, + 8 | request, + > 9 | }) => { + | ^ + 10 | await page.goto("/"); + 11 | await expect + 12 | .soft( + at /home/hermes/worktrees/find-word-v0.1/tests/browser/release.spec.ts:9:6 + + Error: expect(received).toBe(expected) // Object.is equality + + - Expected - 77 + + Received + 20 + + - # Third-party notices + - + - Find-Word is private / proprietary; all rights reserved for original project + - materials. The following runtime components retain their own MIT licenses. + - These notices must accompany the static distribution. + - + - ## react 19.3.0 + - + - MIT License + - + - Copyright (c) Meta Platforms, Inc. and affiliates. + - + - Permission is hereby granted, free of charge, to any person obtaining a copy + - of this software and associated documentation files (the "Software"), to deal + - in the Software without restriction, including without limitation the rights + - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + - copies of the Software, and to permit persons to whom the Software is + - furnished to do so, subject to the following conditions: + - + - The above copyright notice and this permission notice shall be included in all + - copies or substantial portions of the Software. + - + - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + - SOFTWARE. + - + - ## react-dom 19.3.0 + - + - MIT License + - + - Copyright (c) Meta Platforms, Inc. and affiliates. + - + - Permission is hereby granted, free of charge, to any person obtaining a copy + - of this software and associated documentation files (the "Software"), to deal + - in the Software without restriction, including without limitation the rights + - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + - copies of the Software, and to permit persons to whom the Software is + - furnished to do so, subject to the following conditions: + - + - The above copyright notice and this permission notice shall be included in all + - copies or substantial portions of the Software. + - + - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + - SOFTWARE. + - + - ## scheduler 0.28.0 + - + - MIT License + - + - Copyright (c) Meta Platforms, Inc. and affiliates. + - + - Permission is hereby granted, free of charge, to any person obtaining a copy + - of this software and associated documentation files (the "Software"), to deal + - in the Software without restriction, including without limitation the rights + - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + - copies of the Software, and to permit persons to whom the Software is + - furnished to do so, subject to the following conditions: + - + - The above copyright notice and this permission notice shall be included in all + - copies or substantial portions of the Software. + - + - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + - SOFTWARE. + + + + + + + + + + + + + + + + + + 단어를 맞춰라 · Find-Word v0.1 + + + + + + + + + +
+ + + + + ↵ + + 10 | await page.goto("/"); + 11 | await expect + > 12 | .soft( + | ^ + 13 | page.getByText("Designed by OZ at Heat-T Medical Inc.", { exact: true }), + 14 | ) + 15 | .toBeVisible({ timeout: 1000 }); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/release.spec.ts:12:33 + + Error Context: test-results/release-릴리스는-사용자-크레딧과-런타임-MIT-notice-전문을-제공한다-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/release-릴리스는-사용자-크레딧과-런타임-MIT-notice-전문을-제공한다-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/release-릴리스는-사용자-크레딧과-런타임-MIT-notice-전문을-제공한다-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 1 failed + [mobile] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 ─────────── diff --git a/.agent/evidence/24-retry-audio-budget.log b/.agent/evidence/24-retry-audio-budget.log new file mode 100644 index 0000000..6d06ab7 --- /dev/null +++ b/.agent/evidence/24-retry-audio-budget.log @@ -0,0 +1,11 @@ +Measured with Python wave: frames / sample rate, public/audio/*.wav +WAV count: 101 +intro.wav: 8.1205 s +Longest target: message-market.wav: 10.1725 s +AudioEngine scheduling: start +0.05 s; intro-to-target gap +0.35 s +Maximum scheduled intro + target: 18.6930 s +Old assertion headroom (20 s): 1.3070 s +New assertion headroom (30 s): 11.3070 s +Product timeouts remain: preparation 15000 ms; playback scheduled end + 5 s. +Retry control flow: failed play does not set introDone; retry includes intro; +target.onended -> task.finish -> play resolves -> ENDED -> answer group visible. diff --git a/.agent/evidence/24-retry-final-browser.log b/.agent/evidence/24-retry-final-browser.log new file mode 100644 index 0000000..e408202 --- /dev/null +++ b/.agent/evidence/24-retry-final-browser.log @@ -0,0 +1,33 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:770976) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 12 tests using 2 workers + +(node:770997) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:770996) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (19.9s) + ✓ 3 [desktop] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 (317ms) + ✓ 4 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.1s) + ✓ 5 [desktop] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (823ms) + ✓ 6 [desktop] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (17.7s) +(node:771406) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (1.5m) +(node:771655) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 8 [mobile] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.8s) + ✓ 9 [mobile] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 (310ms) + ✓ 10 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (948ms) + ✓ 11 [mobile] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (781ms) + ✓ 12 [mobile] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (17.2s) + ✓ 7 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (1.5m) + + 12 passed (2.2m) + +Exit code: 0 diff --git a/.agent/evidence/24-retry-final-build.log b/.agent/evidence/24-retry-final-build.log new file mode 100644 index 0000000..4bac010 --- /dev/null +++ b/.agent/evidence/24-retry-final-build.log @@ -0,0 +1,18 @@ +$ npm run build + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-KT6VDfPn.css 15.25 kB │ gzip: 4.39 kB +dist/assets/index-BJ9zMFYJ.js 245.34 kB │ gzip: 77.26 kB + +✓ built in 163ms +빌드 음성 자산 101개 및 런타임 notice 전문 확인 + +Exit code: 0 diff --git a/.agent/evidence/24-retry-final-lint.log b/.agent/evidence/24-retry-final-lint.log new file mode 100644 index 0000000..2db7661 --- /dev/null +++ b/.agent/evidence/24-retry-final-lint.log @@ -0,0 +1,7 @@ +$ npm run lint + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit + + +Exit code: 0 diff --git a/.agent/evidence/24-retry-final-test.log b/.agent/evidence/24-retry-final-test.log new file mode 100644 index 0000000..892698f --- /dev/null +++ b/.agent/evidence/24-retry-final-test.log @@ -0,0 +1,19 @@ +$ npm test + +> find-word@0.1.0 test +> vitest run + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 9 passed (9) + Tests 57 passed (57) + Start at 06:11:14 + Duration 7.00s (tests 67%, environment 25%, setup 7%, import 1%) + + Isolate 9 workers spawned · ~662ms startup each (spawn + environment, per file) + at least ~1.32s faster with isolate: false — reuses workers across files instead of one per file + + +Exit code: 0 diff --git a/.agent/evidence/24-retry-integrity.log b/.agent/evidence/24-retry-integrity.log new file mode 100644 index 0000000..bdd370b --- /dev/null +++ b/.agent/evidence/24-retry-integrity.log @@ -0,0 +1,27 @@ +Worktree snapshot comparison (SHA-256; ignored build/cache/dependency outputs excluded) +Existing files changed: .agent/RESULT.md, tests/browser/game.spec.ts +New evidence logs: .agent/evidence/24-retry-audio-budget.log, .agent/evidence/24-retry-final-browser.log, .agent/evidence/24-retry-final-build.log, .agent/evidence/24-retry-final-lint.log, .agent/evidence/24-retry-final-test.log, .agent/evidence/24-retry-targeted-green.log +Removed files: none +src/, public/, dependencies, Playwright config, all other existing files: unchanged +Historical release screenshots: restored byte-for-byte from pre-run copies +git diff --check: exit 0 (existing source tree was untracked) +Changed text files: no trailing whitespace +RESULT evidence links checked: 114 +Test patch: +--- .cache/retry-timeout/game.spec.before.ts 2026-10-14 05:57:19.919982148 +0000 ++++ tests/browser/game.spec.ts 2026-10-14 06:10:58.987220682 +0000 +@@ -137,7 +137,11 @@ + ); + await page.unroute("**/audio/*.wav"); + await page.getByRole("button", { name: "다시 시도" }).click(); +- await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible(); ++ // 재시도는 안내부터 재생한다. 최대 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: "게임 시작 · 소리 듣기" }), + +Exit code: 0 diff --git a/.agent/evidence/24-retry-targeted-green.log b/.agent/evidence/24-retry-targeted-green.log new file mode 100644 index 0000000..40d6ffc --- /dev/null +++ b/.agent/evidence/24-retry-targeted-green.log @@ -0,0 +1,22 @@ + +> find-word@0.1.0 test:browser +> playwright test tests/browser/game.spec.ts --project=desktop --workers=1 --repeat-each=3 --grep 오디오 HTTP 실패 + +[WebServer] (node:770385) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 3 tests using 1 worker + +(node:770405) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (17.7s) +(node:770579) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (15.5s) +(node:770757) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 3 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (16.8s) + + 3 passed (52.9s) + +Exit code: 0 diff --git a/.agent/evidence/audio-reproduce.log b/.agent/evidence/audio-reproduce.log new file mode 100644 index 0000000..ad7cf97 --- /dev/null +++ b/.agent/evidence/audio-reproduce.log @@ -0,0 +1,6 @@ + +> find-word@0.1.0 audio:generate +> python3 scripts/generate_audio.py + +101개 WAV 생성, 합계 10,864,582 바이트 +재생성 검증: 101개 WAV의 원문·크기·SHA-256 모두 이전과 동일 diff --git a/.agent/evidence/browser-final.log b/.agent/evidence/browser-final.log new file mode 100644 index 0000000..00e9641 --- /dev/null +++ b/.agent/evidence/browser-final.log @@ -0,0 +1,23 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:746238) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 4 tests using 2 workers + +(node:746258) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:746259) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:95:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.4s) +(node:746526) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (56.6s) +(node:746780) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 4 [mobile] › tests/browser/game.spec.ts:95:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (12.1s) + ✓ 3 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (56.0s) + + 4 passed (1.2m) diff --git a/.agent/evidence/browser-first.log b/.agent/evidence/browser-first.log new file mode 100644 index 0000000..29e9f5e --- /dev/null +++ b/.agent/evidence/browser-first.log @@ -0,0 +1,234 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:743569) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 4 tests using 2 workers + +(node:743591) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:743590) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (2.4s) +(node:743836) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 3 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (2.0s) +(node:743961) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:58:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (16.3s) + ✓ 4 [mobile] › tests/browser/game.spec.ts:58:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (12.9s) + + + 1) [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ─────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 93 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#eef2e4", + + "contrastRatio": 3.75, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#6e805d", + + "fontSize": "9.8pt (13px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 3.75 (foreground color: #6e805d, background color: #eef2e4, font size: 9.8pt (13px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "01", + + "target": Array [ + + ".section-number", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 3.75 (foreground color: #6e805d, background color: #eef2e4, font size: 9.8pt (13px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "01", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".section-number", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e9", + + "contrastRatio": 4.07, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#707967", + + "fontSize": "7.5pt (10px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.07 (foreground color: #707967, background color: #f0f4e9, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "", + + "target": Array [ + + ".selected", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.07 (foreground color: #707967, background color: #f0f4e9, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "선택 + 직접 입력", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".selected > small", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 19 | expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + 20 | await page.screenshot({ path: `.agent/evidence/${info.project.name}-home.png`, fullPage: true }); + > 21 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 22 | await page.emulateMedia({ reducedMotion: 'reduce' }); + 23 | await page.getByRole('button', { name: /소리 시작/ }).click(); + 24 | await expect(page.getByRole('button', { name: /듣는 중/ })).toBeDisabled(); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:21:65 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2) [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 58 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e9", + + "contrastRatio": 4.07, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#707967", + + "fontSize": "6.8pt (9px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.07 (foreground color: #707967, background color: #f0f4e9, font size: 6.8pt (9px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "", + + "target": Array [ + + ".selected", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.07 (foreground color: #707967, background color: #f0f4e9, font size: 6.8pt (9px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "선택 + 직접 입력", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".selected > small", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 19 | expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + 20 | await page.screenshot({ path: `.agent/evidence/${info.project.name}-home.png`, fullPage: true }); + > 21 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 22 | await page.emulateMedia({ reducedMotion: 'reduce' }); + 23 | await page.getByRole('button', { name: /소리 시작/ }).click(); + 24 | await expect(page.getByRole('button', { name: /듣는 중/ })).toBeDisabled(); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:21:65 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2 failed + [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ───────────────── + 2 passed (21.4s) diff --git a/.agent/evidence/browser-second.log b/.agent/evidence/browser-second.log new file mode 100644 index 0000000..8088ec2 --- /dev/null +++ b/.agent/evidence/browser-second.log @@ -0,0 +1,269 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:744290) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 4 tests using 2 workers + +(node:744311) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:744312) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:58:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.5s) +(node:744575) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (16.2s) +(node:744703) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 4 [mobile] › tests/browser/game.spec.ts:58:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (12.7s) + ✘ 3 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (15.3s) + + + 1) [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ─────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 93 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#edf1e4", + + "contrastRatio": 4.08, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#69795a", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.08 (foreground color: #69795a, background color: #edf1e4, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "객관식", + + "target": Array [ + + ".question-meta > span:nth-child(2)", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.08 (foreground color: #69795a, background color: #edf1e4, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "객관식", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".question-meta > span:nth-child(2)", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#fffefa", + + "contrastRatio": 4.41, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#707b65", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.41 (foreground color: #707b65, background color: #fffefa, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
", + + "target": Array [ + + ".question-panel", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.41 (foreground color: #707b65, background color: #fffefa, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "다시 들어도 점수는 줄어들지 않아요.", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + "small", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 32 | expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + 33 | await page.screenshot({ path: `.agent/evidence/${info.project.name}-game.png`, fullPage: true }); + > 34 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 35 | await page.getByRole('button', { name: /다시 듣기/ }).click(); + 36 | await expect(target).toBeVisible(); + 37 | } + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:34:69 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2) [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 93 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#edf1e4", + + "contrastRatio": 4.08, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#69795a", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.08 (foreground color: #69795a, background color: #edf1e4, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "객관식", + + "target": Array [ + + ".question-meta > span:nth-child(2)", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.08 (foreground color: #69795a, background color: #edf1e4, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "객관식", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".question-meta > span:nth-child(2)", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#fffefa", + + "contrastRatio": 4.41, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#707b65", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.41 (foreground color: #707b65, background color: #fffefa, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
", + + "target": Array [ + + ".question-panel", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.41 (foreground color: #707b65, background color: #fffefa, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "다시 들어도 점수는 줄어들지 않아요.", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + "small", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 32 | expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + 33 | await page.screenshot({ path: `.agent/evidence/${info.project.name}-game.png`, fullPage: true }); + > 34 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 35 | await page.getByRole('button', { name: /다시 듣기/ }).click(); + 36 | await expect(target).toBeVisible(); + 37 | } + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:34:69 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2 failed + [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ───────────────── + 2 passed (32.5s) diff --git a/.agent/evidence/browser-third.log b/.agent/evidence/browser-third.log new file mode 100644 index 0000000..631309e --- /dev/null +++ b/.agent/evidence/browser-third.log @@ -0,0 +1,339 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:745155) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 4 tests using 2 workers + +(node:745175) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:745176) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:95:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.5s) +(node:745459) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (56.8s) +(node:745658) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✘ 3 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (56.0s) + ✓ 4 [mobile] › tests/browser/game.spec.ts:95:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.2s) + + + 1) [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ─────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 128 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "총 점수", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".stats > div:nth-child(1) > span", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "정답률 · 10/10", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".stats > div:nth-child(2) > span", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "8.3pt (11px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 8.3pt (11px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "최고 연속 정답", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + "div:nth-child(3) > span", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 86 | fullPage: true, + 87 | }); + > 88 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 89 | await page.getByRole("button", { name: "다시 시작" }).click(); + 90 | await expect(page.getByRole("button", { name: /소리 시작/ })).toBeVisible(); + 91 | expect(external).toEqual([]); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:88:65 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-desktop/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2) [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + + Error: expect(received).toEqual(expected) // deep equality + + - Expected - 1 + + Received + 128 + + - Array [] + + Array [ + + Object { + + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + + "help": "Elements must meet minimum color contrast ratio thresholds", + + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=playwright", + + "id": "color-contrast", + + "impact": "serious", + + "nodes": Array [ + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "7.5pt (10px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "총 점수", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".stats > div:nth-child(1) > span", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "7.5pt (10px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "정답률 · 10/10", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + ".stats > div:nth-child(2) > span", + + ], + + }, + + Object { + + "all": Array [], + + "any": Array [ + + Object { + + "data": Object { + + "bgColor": "#f0f4e8", + + "contrastRatio": 4.38, + + "expectedContrastRatio": "4.5:1", + + "fgColor": "#65765a", + + "fontSize": "7.5pt (10px)", + + "fontWeight": "normal", + + "messageKey": null, + + }, + + "id": "color-contrast", + + "impact": "serious", + + "message": "Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "relatedNodes": Array [ + + Object { + + "html": "
3,300총 점수
100%정답률 · 10/10
10회최고 연속 정답
", + + "target": Array [ + + ".stats", + + ], + + }, + + ], + + }, + + ], + + "failureSummary": "Fix any of the following: + + Element has insufficient color contrast of 4.38 (foreground color: #65765a, background color: #f0f4e8, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + + "html": "최고 연속 정답", + + "impact": "serious", + + "none": Array [], + + "target": Array [ + + "div:nth-child(3) > span", + + ], + + }, + + ], + + "tags": Array [ + + "cat.color", + + "wcag2aa", + + "wcag143", + + "TTv5", + + "TT13.c", + + "EN-301-549", + + "EN-9.1.4.3", + + "ACT", + + "RGAAv4", + + "RGAA-3.2.1", + + ], + + }, + + ] + + 86 | fullPage: true, + 87 | }); + > 88 | expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + | ^ + 89 | await page.getByRole("button", { name: "다시 시작" }).click(); + 90 | await expect(page.getByRole("button", { name: /소리 시작/ })).toBeVisible(); + 91 | expect(external).toEqual([]); + at /home/hermes/worktrees/find-word-v0.1/tests/browser/game.spec.ts:88:65 + + Error Context: test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/error-context.md + + attachment #2: trace (application/zip) ───────────────────────────────────────────────────────── + test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + Usage: + + npx playwright show-trace test-results/game-실제-오디오로-혼합-모드-완주·레이아웃·접근성·외부-요청-없음-mobile/trace.zip + + ──────────────────────────────────────────────────────────────────────────────────────────────── + + 2 failed + [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ──────────────── + [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 ───────────────── + 2 passed (1.2m) diff --git a/.agent/evidence/changed-files.txt b/.agent/evidence/changed-files.txt new file mode 100644 index 0000000..3b8e739 --- /dev/null +++ b/.agent/evidence/changed-files.txt @@ -0,0 +1,181 @@ +# Git commit/push 없음. 보존한 입력 문서와 새 작성 파일 목록. +PRESERVED .agent/ACCEPTANCE.md +PRESERVED .agent/CONTEXT.md +ADDED .agent/RESULT.md +PRESERVED .agent/TASK.md +ADDED .agent/evidence/01-content-green.log +ADDED .agent/evidence/01-content-red.log +ADDED .agent/evidence/02-game-green.log +ADDED .agent/evidence/02-game-red.log +ADDED .agent/evidence/03-dsp-green.log +ADDED .agent/evidence/03-dsp-intermediate.log +ADDED .agent/evidence/03-dsp-red.log +ADDED .agent/evidence/04-audio-green.log +ADDED .agent/evidence/04-audio-red.log +ADDED .agent/evidence/05-assets-red.log +ADDED .agent/evidence/06-ui-05-assets-green.log +ADDED .agent/evidence/06-ui-intermediate.log +ADDED .agent/evidence/06-ui-red.log +ADDED .agent/evidence/07-build-green.log +ADDED .agent/evidence/07-build-red.log +ADDED .agent/evidence/08-rule-green.log +ADDED .agent/evidence/08-rule-red.log +ADDED .agent/evidence/09-ime-green.log +ADDED .agent/evidence/09-ime-red.log +ADDED .agent/evidence/10-ime-reset-green.log +ADDED .agent/evidence/10-ime-reset-red.log +ADDED .agent/evidence/11-provenance-green.log +ADDED .agent/evidence/11-provenance-red.log +ADDED .agent/evidence/12-ime-replay-green.log +ADDED .agent/evidence/12-ime-replay-red.log +ADDED .agent/evidence/audio-reproduce.log +ADDED .agent/evidence/browser-final.log +ADDED .agent/evidence/browser-first.log +ADDED .agent/evidence/browser-second.log +ADDED .agent/evidence/browser-third.log +ADDED .agent/evidence/changed-files.txt +ADDED .agent/evidence/desktop-game.png +ADDED .agent/evidence/desktop-home.png +ADDED .agent/evidence/desktop-results.png +ADDED .agent/evidence/final-audit.log +ADDED .agent/evidence/final-browser.log +ADDED .agent/evidence/final-build.log +ADDED .agent/evidence/final-lint.log +ADDED .agent/evidence/final-security.log +ADDED .agent/evidence/final-test.log +ADDED .agent/evidence/mobile-game.png +ADDED .agent/evidence/mobile-home.png +ADDED .agent/evidence/mobile-results.png +ADDED .gitignore +ADDED README.md +ADDED docs/ARCHITECTURE.md +ADDED docs/AUDIO_PROVENANCE.md +ADDED docs/DEPLOYMENT.md +ADDED docs/WORDS.md +ADDED eslint.config.js +ADDED index.html +ADDED package-lock.json +ADDED package.json +ADDED playwright.config.ts +ADDED public/audio/correction-apple.wav +ADDED public/audio/correction-furniture.wav +ADDED public/audio/correction-hat.wav +ADDED public/audio/correction-market.wav +ADDED public/audio/correction-meat.wav +ADDED public/audio/correction-oil.wav +ADDED public/audio/correction-sea.wav +ADDED public/audio/correction-sound.wav +ADDED public/audio/correction-tree.wav +ADDED public/audio/correction-umbrella.wav +ADDED public/audio/first-apple.wav +ADDED public/audio/first-furniture.wav +ADDED public/audio/first-hat.wav +ADDED public/audio/first-market.wav +ADDED public/audio/first-meat.wav +ADDED public/audio/first-oil.wav +ADDED public/audio/first-sea.wav +ADDED public/audio/first-sound.wav +ADDED public/audio/first-tree.wav +ADDED public/audio/first-umbrella.wav +ADDED public/audio/intro.wav +ADDED public/audio/last-apple.wav +ADDED public/audio/last-furniture.wav +ADDED public/audio/last-hat.wav +ADDED public/audio/last-market.wav +ADDED public/audio/last-meat.wav +ADDED public/audio/last-oil.wav +ADDED public/audio/last-sea.wav +ADDED public/audio/last-sound.wav +ADDED public/audio/last-tree.wav +ADDED public/audio/last-umbrella.wav +ADDED public/audio/luck-apple.wav +ADDED public/audio/luck-furniture.wav +ADDED public/audio/luck-hat.wav +ADDED public/audio/luck-market.wav +ADDED public/audio/luck-meat.wav +ADDED public/audio/luck-oil.wav +ADDED public/audio/luck-sea.wav +ADDED public/audio/luck-sound.wav +ADDED public/audio/luck-tree.wav +ADDED public/audio/luck-umbrella.wav +ADDED public/audio/manifest.json +ADDED public/audio/message-apple.wav +ADDED public/audio/message-furniture.wav +ADDED public/audio/message-hat.wav +ADDED public/audio/message-market.wav +ADDED public/audio/message-meat.wav +ADDED public/audio/message-oil.wav +ADDED public/audio/message-sea.wav +ADDED public/audio/message-sound.wav +ADDED public/audio/message-tree.wav +ADDED public/audio/message-umbrella.wav +ADDED public/audio/middle-apple.wav +ADDED public/audio/middle-furniture.wav +ADDED public/audio/middle-hat.wav +ADDED public/audio/middle-market.wav +ADDED public/audio/middle-meat.wav +ADDED public/audio/middle-oil.wav +ADDED public/audio/middle-sea.wav +ADDED public/audio/middle-sound.wav +ADDED public/audio/middle-tree.wav +ADDED public/audio/middle-umbrella.wav +ADDED public/audio/mission-apple.wav +ADDED public/audio/mission-furniture.wav +ADDED public/audio/mission-hat.wav +ADDED public/audio/mission-market.wav +ADDED public/audio/mission-meat.wav +ADDED public/audio/mission-oil.wav +ADDED public/audio/mission-sea.wav +ADDED public/audio/mission-sound.wav +ADDED public/audio/mission-tree.wav +ADDED public/audio/mission-umbrella.wav +ADDED public/audio/password-apple.wav +ADDED public/audio/password-furniture.wav +ADDED public/audio/password-hat.wav +ADDED public/audio/password-market.wav +ADDED public/audio/password-meat.wav +ADDED public/audio/password-oil.wav +ADDED public/audio/password-sea.wav +ADDED public/audio/password-sound.wav +ADDED public/audio/password-tree.wav +ADDED public/audio/password-umbrella.wav +ADDED public/audio/remember-apple.wav +ADDED public/audio/remember-furniture.wav +ADDED public/audio/remember-hat.wav +ADDED public/audio/remember-market.wav +ADDED public/audio/remember-meat.wav +ADDED public/audio/remember-oil.wav +ADDED public/audio/remember-sea.wav +ADDED public/audio/remember-sound.wav +ADDED public/audio/remember-tree.wav +ADDED public/audio/remember-umbrella.wav +ADDED public/audio/repeat-apple.wav +ADDED public/audio/repeat-furniture.wav +ADDED public/audio/repeat-hat.wav +ADDED public/audio/repeat-market.wav +ADDED public/audio/repeat-meat.wav +ADDED public/audio/repeat-oil.wav +ADDED public/audio/repeat-sea.wav +ADDED public/audio/repeat-sound.wav +ADDED public/audio/repeat-tree.wav +ADDED public/audio/repeat-umbrella.wav +ADDED scripts/check-build.mjs +ADDED scripts/generate_audio.py +ADDED src/App.tsx +ADDED src/audio/dsp.ts +ADDED src/audio/engine.ts +ADDED src/content.ts +ADDED src/game.ts +ADDED src/main.tsx +ADDED src/styles.css +ADDED tests/App.test.tsx +ADDED tests/assets.test.ts +ADDED tests/audio.test.ts +ADDED tests/browser/game.spec.ts +ADDED tests/build.test.ts +ADDED tests/content.test.ts +ADDED tests/dsp.test.ts +ADDED tests/game.test.ts +ADDED tests/setup.ts +ADDED tsconfig.json +ADDED vite.config.ts diff --git a/.agent/evidence/desktop-game.png b/.agent/evidence/desktop-game.png new file mode 100644 index 0000000..f131756 Binary files /dev/null and b/.agent/evidence/desktop-game.png differ diff --git a/.agent/evidence/desktop-home.png b/.agent/evidence/desktop-home.png new file mode 100644 index 0000000..6b7235e Binary files /dev/null and b/.agent/evidence/desktop-home.png differ diff --git a/.agent/evidence/desktop-results.png b/.agent/evidence/desktop-results.png new file mode 100644 index 0000000..3e992f6 Binary files /dev/null and b/.agent/evidence/desktop-results.png differ diff --git a/.agent/evidence/final-audit.log b/.agent/evidence/final-audit.log new file mode 100644 index 0000000..f372dee --- /dev/null +++ b/.agent/evidence/final-audit.log @@ -0,0 +1 @@ +found 0 vulnerabilities diff --git a/.agent/evidence/final-browser.log b/.agent/evidence/final-browser.log new file mode 100644 index 0000000..da9eb70 --- /dev/null +++ b/.agent/evidence/final-browser.log @@ -0,0 +1,23 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:748800) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 4 tests using 2 workers + +(node:748834) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:748833) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:99:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (16.1s) +(node:749105) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (57.2s) +(node:749299) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 4 [mobile] › tests/browser/game.spec.ts:99:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (12.8s) + ✓ 3 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (54.9s) + + 4 passed (1.2m) diff --git a/.agent/evidence/final-build.log b/.agent/evidence/final-build.log new file mode 100644 index 0000000..d476e97 --- /dev/null +++ b/.agent/evidence/final-build.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-BEQhGx9x.css 14.87 kB │ gzip: 4.29 kB +dist/assets/index-ZqUFJyNr.js 244.26 kB │ gzip: 76.95 kB + +✓ built in 154ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/final-lint.log b/.agent/evidence/final-lint.log new file mode 100644 index 0000000..6551b95 --- /dev/null +++ b/.agent/evidence/final-lint.log @@ -0,0 +1,3 @@ + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit diff --git a/.agent/evidence/final-security.log b/.agent/evidence/final-security.log new file mode 100644 index 0000000..bccb5c1 --- /dev/null +++ b/.agent/evidence/final-security.log @@ -0,0 +1,4 @@ +Secret pattern scan: 34 text files, 0 matches. +Korean source explanations: 22 files, 0 missing. +Python generator syntax: PASS. +Scope: source, tests, scripts, docs, root metadata, .agent requirements; dependency binaries and PCM excluded. diff --git a/.agent/evidence/final-test.log b/.agent/evidence/final-test.log new file mode 100644 index 0000000..22a17cf --- /dev/null +++ b/.agent/evidence/final-test.log @@ -0,0 +1,16 @@ + +> find-word@0.1.0 test +> vitest run + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 7 passed (7) + Tests 29 passed (29) + Start at 05:19:37 + Duration 4.63s (tests 49%, environment 38%, setup 9%, transform 2%, import 1%, worker 1%) + +Environment jsdom was created 7 times · 4.24s total, 38% of tracked time + create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files) + learn more: https://vitest.dev/guide/improving-performance#test-environments diff --git a/.agent/evidence/mobile-game.png b/.agent/evidence/mobile-game.png new file mode 100644 index 0000000..149c5e9 Binary files /dev/null and b/.agent/evidence/mobile-game.png differ diff --git a/.agent/evidence/mobile-home.png b/.agent/evidence/mobile-home.png new file mode 100644 index 0000000..66b1c1d Binary files /dev/null and b/.agent/evidence/mobile-home.png differ diff --git a/.agent/evidence/mobile-results.png b/.agent/evidence/mobile-results.png new file mode 100644 index 0000000..176c468 Binary files /dev/null and b/.agent/evidence/mobile-results.png differ diff --git a/.agent/evidence/neo-before-spacing-browser.log b/.agent/evidence/neo-before-spacing-browser.log new file mode 100644 index 0000000..fe693ce --- /dev/null +++ b/.agent/evidence/neo-before-spacing-browser.log @@ -0,0 +1,29 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:754672) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 10 tests using 2 workers + +(node:754693) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:754694) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:124:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (16.6s) + ✓ 3 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.1s) + ✓ 4 [desktop] › tests/browser/ui.spec.ts:50:1 › 난이도 설명은 모바일에서도 보인다 (756ms) + ✓ 5 [desktop] › tests/browser/ui.spec.ts:70:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (11.8s) +(node:755063) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (58.8s) +(node:755272) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 7 [mobile] › tests/browser/game.spec.ts:124:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.2s) + ✓ 8 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (939ms) + ✓ 9 [mobile] › tests/browser/ui.spec.ts:50:1 › 난이도 설명은 모바일에서도 보인다 (711ms) + ✓ 10 [mobile] › tests/browser/ui.spec.ts:70:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (11.9s) + ✓ 6 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (56.3s) + + 10 passed (1.5m) diff --git a/.agent/evidence/neo-desktop-game.png b/.agent/evidence/neo-desktop-game.png new file mode 100644 index 0000000..21eb85a Binary files /dev/null and b/.agent/evidence/neo-desktop-game.png differ diff --git a/.agent/evidence/neo-desktop-heading.png b/.agent/evidence/neo-desktop-heading.png new file mode 100644 index 0000000..0a47829 Binary files /dev/null and b/.agent/evidence/neo-desktop-heading.png differ diff --git a/.agent/evidence/neo-desktop-home.png b/.agent/evidence/neo-desktop-home.png new file mode 100644 index 0000000..f9ea85a Binary files /dev/null and b/.agent/evidence/neo-desktop-home.png differ diff --git a/.agent/evidence/neo-desktop-results.png b/.agent/evidence/neo-desktop-results.png new file mode 100644 index 0000000..e0e97a3 Binary files /dev/null and b/.agent/evidence/neo-desktop-results.png differ diff --git a/.agent/evidence/neo-final-browser.log b/.agent/evidence/neo-final-browser.log new file mode 100644 index 0000000..a27437c --- /dev/null +++ b/.agent/evidence/neo-final-browser.log @@ -0,0 +1,29 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:756541) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 10 tests using 2 workers + +(node:756562) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:756561) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 1 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.0s) + ✓ 3 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.3s) + ✓ 4 [desktop] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (1.0s) + ✓ 5 [desktop] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (11.8s) +(node:756932) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 2 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (57.0s) +(node:757164) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ 7 [mobile] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.2s) + ✓ 8 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (881ms) + ✓ 9 [mobile] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (792ms) + ✓ 6 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (55.8s) + ✓ 10 [mobile] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (11.5s) + + 10 passed (1.4m) diff --git a/.agent/evidence/neo-final-build.log b/.agent/evidence/neo-final-build.log new file mode 100644 index 0000000..2c6c350 --- /dev/null +++ b/.agent/evidence/neo-final-build.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-KT6VDfPn.css 15.25 kB │ gzip: 4.39 kB +dist/assets/index-JDsKSYsp.js 244.43 kB │ gzip: 77.02 kB + +✓ built in 302ms +빌드 음성 자산 101개 확인 diff --git a/.agent/evidence/neo-final-lint.log b/.agent/evidence/neo-final-lint.log new file mode 100644 index 0000000..6551b95 --- /dev/null +++ b/.agent/evidence/neo-final-lint.log @@ -0,0 +1,3 @@ + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit diff --git a/.agent/evidence/neo-final-test.log b/.agent/evidence/neo-final-test.log new file mode 100644 index 0000000..8036a92 --- /dev/null +++ b/.agent/evidence/neo-final-test.log @@ -0,0 +1,16 @@ + +> find-word@0.1.0 test +> vitest run + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 7 passed (7) + Tests 34 passed (34) + Start at 05:37:00 + Duration 11.09s (tests 54%, environment 36%, setup 8%, import 1%, worker 1%, transform 1%) + +Environment jsdom was created 7 times · 8.03s total, 36% of tracked time + create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files) + learn more: https://vitest.dev/guide/improving-performance#test-environments diff --git a/.agent/evidence/neo-intermediate-build.log b/.agent/evidence/neo-intermediate-build.log new file mode 100644 index 0000000..47ee730 --- /dev/null +++ b/.agent/evidence/neo-intermediate-build.log @@ -0,0 +1,9 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +tests/App.test.tsx(62,71): error TS2769: No overload matches this call. + Overload 1 of 2, '(role: ByRoleMatcher, options?: ByRoleOptions | undefined): HTMLElement', gave the following error. + Object literal may only specify known properties, and 'exact' does not exist in type 'ByRoleOptions'. + Overload 2 of 2, '(role: ByRoleMatcher, options?: ByRoleOptions | undefined): HTMLElement', gave the following error. + Object literal may only specify known properties, and 'exact' does not exist in type 'ByRoleOptions'. diff --git a/.agent/evidence/neo-intermediate-lint.log b/.agent/evidence/neo-intermediate-lint.log new file mode 100644 index 0000000..2f8a517 --- /dev/null +++ b/.agent/evidence/neo-intermediate-lint.log @@ -0,0 +1,9 @@ + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit + +tests/App.test.tsx(62,71): error TS2769: No overload matches this call. + Overload 1 of 2, '(role: ByRoleMatcher, options?: ByRoleOptions | undefined): HTMLElement', gave the following error. + Object literal may only specify known properties, and 'exact' does not exist in type 'ByRoleOptions'. + Overload 2 of 2, '(role: ByRoleMatcher, options?: ByRoleOptions | undefined): HTMLElement', gave the following error. + Object literal may only specify known properties, and 'exact' does not exist in type 'ByRoleOptions'. diff --git a/.agent/evidence/neo-mobile-game.png b/.agent/evidence/neo-mobile-game.png new file mode 100644 index 0000000..ea5c59b Binary files /dev/null and b/.agent/evidence/neo-mobile-game.png differ diff --git a/.agent/evidence/neo-mobile-heading.png b/.agent/evidence/neo-mobile-heading.png new file mode 100644 index 0000000..b082c66 Binary files /dev/null and b/.agent/evidence/neo-mobile-heading.png differ diff --git a/.agent/evidence/neo-mobile-home.png b/.agent/evidence/neo-mobile-home.png new file mode 100644 index 0000000..a06de9c Binary files /dev/null and b/.agent/evidence/neo-mobile-home.png differ diff --git a/.agent/evidence/neo-mobile-results.png b/.agent/evidence/neo-mobile-results.png new file mode 100644 index 0000000..d600346 Binary files /dev/null and b/.agent/evidence/neo-mobile-results.png differ diff --git a/.agent/evidence/release-desktop-game.png b/.agent/evidence/release-desktop-game.png new file mode 100644 index 0000000..56c8d01 Binary files /dev/null and b/.agent/evidence/release-desktop-game.png differ diff --git a/.agent/evidence/release-desktop-heading.png b/.agent/evidence/release-desktop-heading.png new file mode 100644 index 0000000..44fb51e Binary files /dev/null and b/.agent/evidence/release-desktop-heading.png differ diff --git a/.agent/evidence/release-desktop-home.png b/.agent/evidence/release-desktop-home.png new file mode 100644 index 0000000..f357ab1 Binary files /dev/null and b/.agent/evidence/release-desktop-home.png differ diff --git a/.agent/evidence/release-desktop-results.png b/.agent/evidence/release-desktop-results.png new file mode 100644 index 0000000..d537dd3 Binary files /dev/null and b/.agent/evidence/release-desktop-results.png differ diff --git a/.agent/evidence/release-docs-final.log b/.agent/evidence/release-docs-final.log new file mode 100644 index 0000000..b362a8a --- /dev/null +++ b/.agent/evidence/release-docs-final.log @@ -0,0 +1,12 @@ + +> find-word@0.1.0 test +> vitest run tests/release.test.ts + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 1 passed (1) + Tests 4 passed (4) + Start at 06:01:24 + Duration 243ms (setup 80%, tests 8%, transform 7%, worker 3%, import 2%) diff --git a/.agent/evidence/release-final-audit.log b/.agent/evidence/release-final-audit.log new file mode 100644 index 0000000..f372dee --- /dev/null +++ b/.agent/evidence/release-final-audit.log @@ -0,0 +1 @@ +found 0 vulnerabilities diff --git a/.agent/evidence/release-final-browser.log b/.agent/evidence/release-final-browser.log new file mode 100644 index 0000000..d990bad --- /dev/null +++ b/.agent/evidence/release-final-browser.log @@ -0,0 +1,74 @@ + +> find-word@0.1.0 test:browser +> playwright test + +[WebServer] (node:764527) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +[WebServer] (Use `node --trace-warnings ...` to show where the warning was created) + +Running 12 tests using 2 workers + +(node:764561) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) +(node:764562) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + pw:browser /usr/bin/google-chrome --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,BlockOriginHeaderModificationOnRedirect,Translate,AutoDeElevate,OptimizationHints,NetworkTimeServiceQuerying,AimEnabled,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-updater-scheduler --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --user-data-dir=/tmp/playwright_chromiumdev_profile-0To5Bv --remote-debugging-pipe --no-startup-window +0ms + pw:browser pid=764587 +6ms + pw:browser /usr/bin/google-chrome --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,BlockOriginHeaderModificationOnRedirect,Translate,AutoDeElevate,OptimizationHints,NetworkTimeServiceQuerying,AimEnabled,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-updater-scheduler --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --user-data-dir=/tmp/playwright_chromiumdev_profile-HcZBqk --remote-debugging-pipe --no-startup-window +0ms + pw:browser pid=764593 +6ms + pw:browser [pid=764587][err] [764650:764650:1014/055756.388591:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +171ms + pw:browser [pid=764593][err] [764684:764684:1014/055756.449383:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +203ms + pw:browser [pid=764593][err] [764796:764796:1014/055757.236733:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +785ms + pw:browser [pid=764587][err] [764834:764834:1014/055758.794092:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +2s + pw:browser [pid=764593][err] [764593:764657:1014/055801.114717:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT +4s + pw:browser [pid=764587][err] [764587:764631:1014/055802.267235:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT +3s + ✓ 2 [desktop] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (17.7s) + ✓ 3 [desktop] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 (319ms) + ✓ 4 [desktop] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (1.3s) + ✓ 5 [desktop] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (949ms) + pw:browser [pid=764593][err] [764593:764657:1014/055825.258517:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: QUOTA_EXCEEDED +24s + pw:browser [pid=764587][err] [764587:764631:1014/055825.720740:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: QUOTA_EXCEEDED +23s + ✓ 6 [desktop] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (17.8s) + pw:browser [pid=764593] +9s + pw:browser [pid=764593] +53ms + pw:browser [pid=764593] starting temporary directories cleanup +0ms + pw:browser [pid=764593] finished temporary directories cleanup +10ms + pw:browser [pid=764593] +0ms +(node:764988) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + pw:browser /usr/bin/google-chrome --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,BlockOriginHeaderModificationOnRedirect,Translate,AutoDeElevate,OptimizationHints,NetworkTimeServiceQuerying,AimEnabled,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-updater-scheduler --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --user-data-dir=/tmp/playwright_chromiumdev_profile-PhGYQv --remote-debugging-pipe --no-startup-window +0ms + pw:browser pid=765000 +6ms + pw:browser [pid=765000][err] [765044:765044:1014/055835.399619:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +137ms + pw:browser [pid=765000][err] [765116:765116:1014/055836.893279:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +1s + pw:browser [pid=765000][err] [765000:765028:1014/055840.206206:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: QUOTA_EXCEEDED +3s + ✓ 1 [desktop] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (1.6m) + pw:browser [pid=764587] +1m + pw:browser [pid=764587] +62ms + pw:browser [pid=764587] starting temporary directories cleanup +0ms + pw:browser [pid=764587] finished temporary directories cleanup +7ms + pw:browser [pid=764587] +0ms +(node:765228) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use `node --trace-warnings ...` to show where the warning was created) + pw:browser /usr/bin/google-chrome --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,BlockOriginHeaderModificationOnRedirect,Translate,AutoDeElevate,OptimizationHints,NetworkTimeServiceQuerying,AimEnabled,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-updater-scheduler --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --user-data-dir=/tmp/playwright_chromiumdev_profile-Xkimft --remote-debugging-pipe --no-startup-window +0ms + pw:browser pid=765240 +5ms + pw:browser [pid=765240][err] [765281:765281:1014/055932.857720:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +112ms + pw:browser [pid=765240][err] [765321:7:1014/055933.005529:ERROR:gpu/ipc/client/command_buffer_proxy_impl.cc:287] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer. +145ms + pw:browser [pid=765240][err] [765341:765341:1014/055933.326596:ERROR:base/memory/shared_memory_switch.cc:289] Failed global descriptor lookup: 7 +321ms + pw:browser [pid=765240][err] [765240:765268:1014/055936.694432:ERROR:google_apis/gcm/engine/registration_request.cc:291] Registration response error message: QUOTA_EXCEEDED +3s + ✓ 8 [mobile] › tests/browser/game.spec.ts:127:1 › 오디오 HTTP 실패를 화면에 표시하고 재시도한다 (14.8s) + ✓ 9 [mobile] › tests/browser/release.spec.ts:6:1 › 릴리스는 사용자 크레딧과 런타임 MIT notice 전문을 제공한다 (295ms) + ✓ 10 [mobile] › tests/browser/ui.spec.ts:4:1 › 전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된다 (977ms) + ✓ 11 [mobile] › tests/browser/ui.spec.ts:54:1 › 난이도 설명은 모바일에서도 보인다 (979ms) + ✓ 7 [mobile] › tests/browser/game.spec.ts:7:1 › 실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부 요청 없음 (1.5m) + pw:browser [pid=765000] +1m + pw:browser [pid=765000] +53ms + pw:browser [pid=765000] starting temporary directories cleanup +0ms + pw:browser [pid=765000] finished temporary directories cleanup +8ms + pw:browser [pid=765000] +0ms + ✓ 12 [mobile] › tests/browser/ui.spec.ts:74:1 › 한국어 제목은 단어 중간에서 줄바꿈하지 않는다 (17.7s) + pw:browser [pid=765240] +31s + pw:browser [pid=765240] +49ms + pw:browser [pid=765240] starting temporary directories cleanup +0ms + pw:browser [pid=765240] finished temporary directories cleanup +7ms + pw:browser [pid=765240] +0ms + + 12 passed (2.2m) diff --git a/.agent/evidence/release-final-build.log b/.agent/evidence/release-final-build.log new file mode 100644 index 0000000..c3e94dc --- /dev/null +++ b/.agent/evidence/release-final-build.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 build +> tsc --noEmit && vite build && node scripts/check-build.mjs + +vite v8.3.4 building client environment for production... +transforming... +✓ 20 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.89 kB │ gzip: 0.63 kB +dist/assets/index-KT6VDfPn.css 15.25 kB │ gzip: 4.39 kB +dist/assets/index-BJ9zMFYJ.js 245.34 kB │ gzip: 77.26 kB + +✓ built in 198ms +빌드 음성 자산 101개 및 런타임 notice 전문 확인 diff --git a/.agent/evidence/release-final-lint.log b/.agent/evidence/release-final-lint.log new file mode 100644 index 0000000..6551b95 --- /dev/null +++ b/.agent/evidence/release-final-lint.log @@ -0,0 +1,3 @@ + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit diff --git a/.agent/evidence/release-final-test.log b/.agent/evidence/release-final-test.log new file mode 100644 index 0000000..d14839d --- /dev/null +++ b/.agent/evidence/release-final-test.log @@ -0,0 +1,15 @@ + +> find-word@0.1.0 test +> vitest run + + + RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1 + + + Test Files 9 passed (9) + Tests 57 passed (57) + Start at 05:59:36 + Duration 7.41s (tests 67%, environment 24%, setup 8%, transform 1%, worker 1%, import 1%) + + Isolate 9 workers spawned · ~692ms startup each (spawn + environment, per file) + at least ~1.38s faster with isolate: false — reuses workers across files instead of one per file diff --git a/.agent/evidence/release-format.log b/.agent/evidence/release-format.log new file mode 100644 index 0000000..04f107a --- /dev/null +++ b/.agent/evidence/release-format.log @@ -0,0 +1,13 @@ +src/content.ts 165ms +src/game.ts 75ms +tests/content.test.ts 114ms +tests/game.test.ts 52ms +tests/assets.test.ts 43ms +tests/App.test.tsx 127ms +tests/release.test.ts 15ms +tests/build.test.ts 10ms +tests/playwright-config.test.ts 9ms +tests/browser/release.spec.ts 4ms +scripts/check-build.mjs 36ms +playwright.config.ts 5ms (unchanged) +package.json 6ms (unchanged) diff --git a/.agent/evidence/release-integrity.log b/.agent/evidence/release-integrity.log new file mode 100644 index 0000000..1aa8751 --- /dev/null +++ b/.agent/evidence/release-integrity.log @@ -0,0 +1,6 @@ +Brand literal scan: PASS (28 source/test/document files) +RESULT evidence links: PASS (107) +Actual Chrome launches without --no-sandbox: PASS (4) +WAV source/build count, size and SHA-256: PASS (101, 20,497,222 bytes) +Root/public/dist notice bytes: PASS +git diff --check + text trailing whitespace: PASS (36 files) diff --git a/.agent/evidence/release-intermediate-lint.log b/.agent/evidence/release-intermediate-lint.log new file mode 100644 index 0000000..ac298f2 --- /dev/null +++ b/.agent/evidence/release-intermediate-lint.log @@ -0,0 +1,6 @@ + +> find-word@0.1.0 lint +> eslint . && tsc --noEmit + +tests/playwright-config.test.ts(9,14): error TS2339: Property 'PLAYWRIGHT_NO_SANDBOX' does not exist on type '{ CHROME_PATH: string; }'. +tests/playwright-config.test.ts(10,32): error TS2339: Property 'PLAYWRIGHT_NO_SANDBOX' does not exist on type '{ CHROME_PATH: string; }'. diff --git a/.agent/evidence/release-mobile-game.png b/.agent/evidence/release-mobile-game.png new file mode 100644 index 0000000..0772d6a Binary files /dev/null and b/.agent/evidence/release-mobile-game.png differ diff --git a/.agent/evidence/release-mobile-heading.png b/.agent/evidence/release-mobile-heading.png new file mode 100644 index 0000000..d6a246b Binary files /dev/null and b/.agent/evidence/release-mobile-heading.png differ diff --git a/.agent/evidence/release-mobile-home.png b/.agent/evidence/release-mobile-home.png new file mode 100644 index 0000000..0d4f76b Binary files /dev/null and b/.agent/evidence/release-mobile-home.png differ diff --git a/.agent/evidence/release-mobile-results.png b/.agent/evidence/release-mobile-results.png new file mode 100644 index 0000000..7c9baa8 Binary files /dev/null and b/.agent/evidence/release-mobile-results.png differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e71100 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# 재현 가능한 의존성·빌드와 로컬 검증 도구는 검토 대상 소스에서 제외한다. +node_modules/ +dist/ +.cache/ +test-results/ +playwright-report/ +*.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15e763e --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Find-Word v0.1 +Private / proprietary. All rights reserved. + +No license is granted to use, copy, modify, publish, distribute, sublicense, +or sell the original project code, documentation, or assets without prior +written permission from the respective rights holders. + +Third-party components remain subject to their own licenses. Their notices +are reproduced in THIRD_PARTY_NOTICES.md and accompany the distribution. diff --git a/README.md b/README.md new file mode 100644 index 0000000..52367a3 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# 단어를 맞춰라 · Find-Word v0.1 + +소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. + +**Designed by OZ at Heat-T Medical Inc.** + +Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word). +프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의 +`license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요. +런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은 +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며, +`public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다. +빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다. + +React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, +분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 +의료적 진단·치료나 효과를 주장하지 않습니다. + +## 실행 + +Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이 +요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다. +이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을 +포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. + +```bash +npm ci +npm run dev +``` + +표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 +누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. + +## 플레이 + +- 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. +- 8개 환경을 순환하거나 하나를 지정합니다. +- 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. +- 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**. + 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. +- 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다. +- 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다. +- 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. + +10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 +단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 +[WORDS.md](docs/WORDS.md)에 설명했습니다. +청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음 +단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용, +오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다. + +## 검증과 정적 빌드 + +```bash +npm run lint +npm test +npm run build +npm run preview +``` + +`lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 +테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로 +포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. +런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다. + +실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: + +```bash +# Chromium이 없는 개발 환경에서 한 번만 실행합니다. +npx playwright install chromium +npm run test:browser +# 이미 설치된 Chrome을 사용할 때의 대안입니다. +CHROME_PATH=/usr/bin/google-chrome npm run test:browser +``` + +Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발 +환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`, +빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다. + +브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. +실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를 +테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다. + +## 음성과 문서 + +한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은 +브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다. +생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 +참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. + +- [설계와 상태 전이](docs/ARCHITECTURE.md) +- [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) +- [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md) + +이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..add3137 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,77 @@ +# Third-party notices + +Find-Word is private / proprietary; all rights reserved for original project +materials. The following runtime components retain their own MIT licenses. +These notices must accompany the static distribution. + +## react 19.3.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## react-dom 19.3.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## scheduler 0.28.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..7fcc5aa --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,112 @@ +# 설계 + +## 데이터와 출제 + +`src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 배경 프리셋, 상대 dB를 서로 다른 +배열로 보관한다. `renderSentence`가 각 패턴의 `{word}`를 정답으로, +`{distractor1}`·`{distractor2}`를 해당 보기의 오답으로 채운다. 같은 함수를 +음성 생성기도 사용하며 런타임 문장과 WAV의 대응은 manifest 테스트가 검증한다. + +`Pattern.mechanic`은 다음 10개 고유 청취 연산을 나타낸다. 인용 단어를 활용형으로 +바꾸지 않으므로 동사·부사 표제어가 오답으로 나와도 문장이 자연스럽다. + +| mechanic | 청취 과제 | +| --- | --- | +| `direct-target` | 한 개의 목표 단어를 직접 식별 | +| `distractor-password` | 연습 암호를 제외하고 진짜 암호 선택 | +| `ignore-then-remember` | 앞 단어를 잊고 이후에 지시된 단어 기억 | +| `first` | 세 단어 중 처음 선택 | +| `last` | 세 단어 중 마지막 선택 | +| `middle` | 세 단어 중 가운데 선택 | +| `repetition` | 오답 사이에서 두 번 등장한 단어 찾기 | +| `correction` | 처음 답을 취소하고 정정된 답 선택 | +| `conditional-cue` | 초록/빨강 규칙과 현재 신호를 함께 적용 | +| `spatial-right-cue` | 좌우 표지 중 오른쪽에 해당하는 단어 선택 | + +방향 단서는 문장의 의미이며 stereo 위치 재현 과제는 아니다. 모든 음성은 mono다. +직접 식별을 제외한 문장은 실제 보기의 경쟁 단어도 발화하므로, 들린 단어 하나를 +무조건 고르는 것으로 해결되지 않는다. 테스트는 100개 문장 전체에서 단어 순서, +목표 반복 횟수, 취소·조건·방향 단서와 질문 의도를 검증한다. + +`src/game.ts`의 `createQuestions`는 seed 기반 PRNG와 Fisher–Yates로 +유형·단어·정답 위치를 섞는다. 한 세션에서 유형과 정답 단어는 각각 한 번씩 +나온다. 100개 문장 조합에 응답 방식·환경·난이도를 독립적으로 결합한다. + +오답의 `rule`은 `RULE_DIFFICULTY`에 연결된다. 음절 교체 1, 초성 교체 2, +모음 교체 3, 받침 교체 4는 편집상 예상 혼동 순서이며 검증된 청각 척도가 +아니다. 모든 문항에 서로 다른 세 변형을 포함한다. 음량 단계는 이 값과 분리된다. + +## 게임 상태 + +```mermaid +stateDiagram-v2 + [*] --> ready + ready --> loading: PLAY + loading --> playing: STARTED + loading --> ready: ERROR + playing --> ready: ERROR / 중단 + playing --> answer: ENDED + answer --> loading: 다시 듣기 + answer --> feedback: ANSWER + feedback --> ready: NEXT (다음 문제) + feedback --> results: NEXT (마지막 문제) +``` + +허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고, +피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와 +동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된 +Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은 +채점 후에만 표시된다. + +주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는 +답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여 +입력을 HTML로 해석하지 않는다. + +점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점, +현재 연속 정답을 초기화한다. 최고 연속 정답과 난이도별 정답 수는 유지한다. + +## 오디오 + +`src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다. +정수 seed 잡음에 저역 통과 필터, 사인파, 진폭 변조를 조합하고 양 끝을 페이드한다. +8개 프리셋은 실제 현장 녹음이 아니라 환경을 추상적으로 표현한 합성 신호다. + +`src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤 +같은 출처의 WAV를 로드한다. mono PCM을 decode하고 음성과 배경의 전체 버퍼 +RMS를 0.03으로 정규화한다. `gain = 10^(dB/20)`으로 목표 gain을 지정한다. +시간별·주파수별 청감이나 물리적 dB SPL을 보장하는 수치는 아니다. + +```text +배경 BufferSource → 배경 Gain (1, 시작/끝 fade) ─┐ +목표 BufferSource → 음성 Gain (10^(dB/20)) ─────┼→ master Gain → destination +안내 BufferSource → 안내 Gain (1.8 → 0.12) ─────┘ +``` + +첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을 +재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도 +다시 재생한다. 기본 master는 35%이며 음량 조절은 두 신호의 비율을 유지한다. + +동시 play는 거부한다. 정지·실패·완료는 onended 해제, 소스 stop/disconnect, +AbortController 취소, 타이머 제거를 수행한다. load/decode 뒤에도 작업 토큰을 +검사하여 취소된 이전 작업이 소리를 시작하지 못하게 한다. 준비 15초, +재생 길이 + 5초의 제한으로 무한 대기를 피한다. unmount는 context까지 닫는다. + +## 화면·접근성·보안 + +`App.tsx`는 시작·플레이·결과 화면, 음량, 모드, 배경 선택을 담당한다. +정답/피드백/결과 제목으로 초점을 이동하고 결과표에 caption에 해당하는 +접근성 이름과 scope를 둔다. 네이티브 radio/select/button/form으로 방향키, +Tab/Enter와 터치 조작을 지원한다. 장식 파형·캐릭터는 aria-hidden이며 +reduced motion에서는 파형이 움직이지 않는다. 오류는 role=alert로 전달한다. + +외부 폰트, CDN, 이미지, 분석 도구, 로그인, 쿠키, localStorage 사용이 없다. +개인 입력과 점수는 현재 React 메모리에만 존재한다. 단어·정답은 클라이언트 +번들에 있으므로 개발자 도구를 통한 정답 열람을 막는 시험 시스템은 아니다. + +## 검증 경계 + +Vitest는 실제 RED/GREEN으로 데이터·채점·상태·오디오 그래프·화면을 검증한다. +Playwright는 production build와 실제 Chrome AudioContext로 두 크기의 10문제 +완주·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의 +자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지 +증명하지 않으므로 인수 시 직접 청취를 추가한다. diff --git a/docs/AUDIO_PROVENANCE.md b/docs/AUDIO_PROVENANCE.md new file mode 100644 index 0000000..583cc94 --- /dev/null +++ b/docs/AUDIO_PROVENANCE.md @@ -0,0 +1,83 @@ +# 오디오 출처와 재현 + +## 포함 자산 + +| 대상 | 생성 방식 | 원문/설정 | 배포 방식 | +| --- | --- | --- | --- | +| `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV | +| 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV | +| 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 | +| 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 | + +모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래, +샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의 +‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을 +다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다. + +WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는 +**20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은 +`public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51, +FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM +포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다. +빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다. +청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁 +단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다. + +## 사용 도구와 라이선스 + +- [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later. + 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다. + [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다. +- [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라 + LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다. +- 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다. + [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및 + [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면 + 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가 + 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며, + 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다. +- 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로 + 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다. + 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다. + [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM, + scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에 + 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다. + +## 생성 절차 + +일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가 +필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.** + +```bash +npm run audio:generate +npm test -- tests/assets.test.ts +npm run build +``` + +현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다. +이 명령은 문서화용이며 서버에서 실행한 이력이 없다. + +```bash +mkdir -p .cache/espeak +cd .cache/espeak +apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0 +for pkg in *.deb; do dpkg-deb -x "$pkg" root; done +cd ../.. +npm run audio:generate +``` + +`scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를 +자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz +PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는 +Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는 +해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다. + +## 음량·품질 한계 + +보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다. +안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다. +물리적 음압을 측정하거나 장치 출력을 보정하지 않는다. + +포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제 +장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은 +검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..72a7c9b --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,74 @@ +# 정적 배포 인계 + +이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에 +접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다. + +## 산출물 + +Node.js 최소 **22.22.2** (`>=22.22.2`)의 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`, +`npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다. +`dist/audio/`의 WAV 101개와 manifest가 반드시 포함되어야 한다. 런타임은 +Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다. +잠긴 jsdom 계열 의존성 때문에 24.x는 24.15.0 이상, 26.x는 26.0.0 이상이어야 한다. +브라우저 검증의 Chromium sandbox는 기본 활성화다. sandbox를 지원하지 않는 +격리된 개발 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`로 명시적으로 끈다. + +Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다. +프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다. +React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도 +반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다. + +현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라 +음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다. + +## Nginx 예시 + +아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다. +기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다. + +```nginx +# 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다. +server { + listen 80; + server_name findword.heart-m.com; + root /var/www/findword/current; + index index.html; + + # 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다. + include /etc/nginx/mime.types; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + + # 파일명이 해시로 바뀌는 번들만 장기 캐시한다. + location /assets/ { + try_files $uri =404; + expires 1y; + } + + # 음성 파일명은 재생성해도 같으므로 재검증한다. 누락 시 index.html 대신 + # 404를 반환해야 앱이 명확한 음성 로드 오류와 재시도를 제공할 수 있다. + location /audio/ { + try_files $uri =404; + expires -1; + } + + # SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다. + location / { + try_files $uri $uri/ /index.html; + expires -1; + } +} +``` + +## 인수 확인 + +1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다. +2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다. +3. `audio/intro.wav`와 목표 WAV가 정상 응답하고 존재하지 않는 오디오는 404인지 확인한다. +4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·10문제 완주를 직접 확인한다. +5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다. + +오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버 +연결이 필요하다. 페이지·오디오 캐시가 서로 다른 버전으로 섞이지 않게 HTML과 +음성의 재검증 정책을 유지한다. diff --git a/docs/WORDS.md b/docs/WORDS.md new file mode 100644 index 0000000..ea2c1ea --- /dev/null +++ b/docs/WORDS.md @@ -0,0 +1,40 @@ +# 단어와 오답 변형 + +임의의 한글 조합을 자동 생성하지 않고 사전에 있는 표제어만 고른다. 다음의 +짧은 의미 구분은 앱 작성자가 작성한 설명이며 사전의 정의·예문·음성을 +복제한 데이터셋이 아니다. 표기 단어는 `src/content.ts`에 명시적으로 저장한다. + +| 정답 | 초성 교체 | 모음 교체 | 세 번째 변형 | +| --- | --- | --- | --- | +| 사과 | 다과 (차와 간식) | 사고 (뜻밖의 사건) | 사자 (동물), 음절 교체 | +| 바다 | 사다 (구매하다) | 보다 (눈으로 살피다) | 받다 (수령하다), 받침 추가 | +| 나무 | 나루 (배가 오가는 곳) | 너무 (정도가 지나치게) | 나물 (먹는 풀), 받침 추가 | +| 모자 | 포자 (생물의 번식 단위) | 모조 (원본을 본뜸) | 목자 (양을 돌보는 사람), 받침 추가 | +| 우산 | 부산 (도시 이름) | 오산 (잘못된 계산) | 우상 (숭배의 대상), 받침 교체 | +| 시장 | 지장 (일을 방해하는 사정) | 사장 (회사 책임자) | 신장 (몸의 키), 받침 추가 | +| 기름 | 이름 (대상을 부르는 말) | 거름 (식물에 주는 영양) | 기틀 (기본 구조), 음절 교체 | +| 소리 | 오리 (새) | 수리 (고침) | 소금 (조미료), 음절 교체 | +| 고기 | 보기 (예시) | 고개 (목 위의 머리) | 공기 (대기), 받침 추가 | +| 가구 | 하구 (강이 바다와 만나는 곳) | 가교 (연결하는 다리) | 강구 (방안을 찾음), 받침 추가 | + +동사·부사는 사전 표제어 형태를 사용하고 부산은 사전에 등재된 고유명사로 +사용한다. 문장의 의미 적합성을 오답 생성 규칙으로 삼지 않고 **들은 낱말의 +소리 차이**를 묻는다. 이는 의미상 동의어가 아니라 음운상 비슷한 오답이다. + +참조 사전은 국립국어원의 [한국어기초사전](https://krdict.korean.go.kr/kor/mainAction), +[표준국어대사전](https://stdict.korean.go.kr/main/main.do), +[우리말샘](https://opendict.korean.go.kr/)이다. 혼동하기 쉬운 일부 표제어의 +직접 확인 링크는 [하구](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=85229), +[목자](https://krdict.korean.go.kr/eng/dicSearch/SearchView?ParaWordNo=55879), +[부산](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=68895)이다. +[국립국어원의 나루 설명](https://www.korean.go.kr/front/mcfaq/mcfaqView.do?mcfaq_seq=8815)도 참고한다. +앱 실행 중에는 사전에 접속하지 않는다. + +`tests/content.test.ts`는 한글 완성형을 초성·중성·종성으로 분해한다. 초성·모음·받침 +변형은 정확히 한 자모만 바뀌는지 확인한다. 음절 교체는 더 큰 차이를 허용한다. +받침 추가도 종성 0에서 다른 값으로 바뀌는 ‘받침 교체’로 분류한다. + +`RULE_DIFFICULTY`는 음절 1, 초성 2, 모음 3, 받침 4의 예상 혼동 순서를 +기록한다. 각 묶음의 세 오답은 서로 다른 규칙과 순위를 가진다. 이 순서는 +출제 메타데이터이며 청감·발음·청력에 대한 실측 난이도나 임상 척도가 아니다. +새 단어를 넣을 때는 표제어·의미·변형 규칙을 검토하고 이 목록을 갱신한다. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b4eba63 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import hooks from "eslint-plugin-react-hooks"; +import globals from "globals"; + +// 생성물은 검사하지 않고 직접 작성한 코드만 검사한다. 브라우저와 Node의 +// 실행 경계를 파일별로 지정하여 실수로 서버 전용 API를 앱에서 쓰지 않게 한다. +export default tseslint.config( + { + ignores: [ + "dist/**", + "node_modules/**", + ".cache/**", + "test-results/**", + "playwright-report/**", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { files: ["**/*.{ts,tsx}"], languageOptions: { globals: globals.browser } }, + { + files: ["**/*.{js,mjs}", "*config.ts", "tests/**"], + languageOptions: { globals: globals.node }, + }, + { + files: ["src/**/*.{ts,tsx}"], + plugins: { "react-hooks": hooks }, + rules: hooks.configs.recommended.rules, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..5e2527c --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + + + 단어를 맞춰라 · Find-Word v0.1 + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..89002c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3908 @@ +{ + "name": "find-word", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "find-word", + "version": "0.1.0", + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", + "@types/node": "^26.6.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.13.0", + "jsdom": "^30.1.2", + "prettier": "^3.9.10", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + }, + "engines": { + "node": ">=22.22.2" + }, + "license": "UNLICENSED" + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.455", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.455.tgz", + "integrity": "sha512-EDKY1GvJLJC0UQpk+zWxnXp315azNHIns3arXVNUQUOLaon6GtIzBBXWvNno7bVoDXRPnEAV2uwgMCb+RvG5hQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb397e4 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "find-word", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=22.22.2" + }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "lint": "eslint . && tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "build": "tsc --noEmit && vite build && node scripts/check-build.mjs", + "preview": "vite preview --host 127.0.0.1", + "audio:generate": "python3 scripts/generate_audio.py", + "test:browser": "playwright test" + }, + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", + "@types/node": "^26.6.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.13.0", + "jsdom": "^30.1.2", + "prettier": "^3.9.10", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + }, + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://source.ibit.kr/OZ-Group/Find-Word.git" + }, + "homepage": "https://source.ibit.kr/OZ-Group/Find-Word" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..76f6b74 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "@playwright/test"; + +// 로컬 정적 빌드만 검증하며 원격 서버에 접속하지 않는다. 시스템 Chrome이 +// 있으면 재사용하고, 다른 환경에서는 Playwright가 설치한 Chromium을 쓴다. +// Playwright 자체 기본값도 sandbox를 끄므로 true를 명시한다. 이를 지원하지 +// 않는 격리 환경에서만 PLAYWRIGHT_NO_SANDBOX=1로 예외를 선택할 수 있다. +export default defineConfig({ + testDir: "./tests/browser", + timeout: 120000, + expect: { timeout: 20000 }, + fullyParallel: true, + workers: 2, + use: { + baseURL: "http://127.0.0.1:4173", + launchOptions: { + executablePath: process.env.CHROME_PATH, + chromiumSandbox: process.env.PLAYWRIGHT_NO_SANDBOX !== "1", + }, + trace: "retain-on-failure", + }, + projects: [ + { name: "desktop", use: { viewport: { width: 1280, height: 1000 } } }, + { + name: "mobile", + use: { + viewport: { width: 360, height: 800 }, + isMobile: true, + hasTouch: true, + }, + }, + ], + webServer: { + command: "npm run preview -- --port 4173 --strictPort", + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + }, +}); diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..add3137 --- /dev/null +++ b/public/THIRD_PARTY_NOTICES.md @@ -0,0 +1,77 @@ +# Third-party notices + +Find-Word is private / proprietary; all rights reserved for original project +materials. The following runtime components retain their own MIT licenses. +These notices must accompany the static distribution. + +## react 19.3.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## react-dom 19.3.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## scheduler 0.28.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/audio/correction-apple.wav b/public/audio/correction-apple.wav new file mode 100644 index 0000000..40aff58 Binary files /dev/null and b/public/audio/correction-apple.wav differ diff --git a/public/audio/correction-furniture.wav b/public/audio/correction-furniture.wav new file mode 100644 index 0000000..3808a79 Binary files /dev/null and b/public/audio/correction-furniture.wav differ diff --git a/public/audio/correction-hat.wav b/public/audio/correction-hat.wav new file mode 100644 index 0000000..50fa7d2 Binary files /dev/null and b/public/audio/correction-hat.wav differ diff --git a/public/audio/correction-market.wav b/public/audio/correction-market.wav new file mode 100644 index 0000000..4c9fb00 Binary files /dev/null and b/public/audio/correction-market.wav differ diff --git a/public/audio/correction-meat.wav b/public/audio/correction-meat.wav new file mode 100644 index 0000000..e64f5bc Binary files /dev/null and b/public/audio/correction-meat.wav differ diff --git a/public/audio/correction-oil.wav b/public/audio/correction-oil.wav new file mode 100644 index 0000000..236556d Binary files /dev/null and b/public/audio/correction-oil.wav differ diff --git a/public/audio/correction-sea.wav b/public/audio/correction-sea.wav new file mode 100644 index 0000000..23eedd8 Binary files /dev/null and b/public/audio/correction-sea.wav differ diff --git a/public/audio/correction-sound.wav b/public/audio/correction-sound.wav new file mode 100644 index 0000000..323dad7 Binary files /dev/null and b/public/audio/correction-sound.wav differ diff --git a/public/audio/correction-tree.wav b/public/audio/correction-tree.wav new file mode 100644 index 0000000..305bd0b Binary files /dev/null and b/public/audio/correction-tree.wav differ diff --git a/public/audio/correction-umbrella.wav b/public/audio/correction-umbrella.wav new file mode 100644 index 0000000..067343e Binary files /dev/null and b/public/audio/correction-umbrella.wav differ diff --git a/public/audio/first-apple.wav b/public/audio/first-apple.wav new file mode 100644 index 0000000..3de4412 Binary files /dev/null and b/public/audio/first-apple.wav differ diff --git a/public/audio/first-furniture.wav b/public/audio/first-furniture.wav new file mode 100644 index 0000000..8f17f6c Binary files /dev/null and b/public/audio/first-furniture.wav differ diff --git a/public/audio/first-hat.wav b/public/audio/first-hat.wav new file mode 100644 index 0000000..ca2be6b Binary files /dev/null and b/public/audio/first-hat.wav differ diff --git a/public/audio/first-market.wav b/public/audio/first-market.wav new file mode 100644 index 0000000..2bfb415 Binary files /dev/null and b/public/audio/first-market.wav differ diff --git a/public/audio/first-meat.wav b/public/audio/first-meat.wav new file mode 100644 index 0000000..1c90920 Binary files /dev/null and b/public/audio/first-meat.wav differ diff --git a/public/audio/first-oil.wav b/public/audio/first-oil.wav new file mode 100644 index 0000000..9feb23d Binary files /dev/null and b/public/audio/first-oil.wav differ diff --git a/public/audio/first-sea.wav b/public/audio/first-sea.wav new file mode 100644 index 0000000..8406e18 Binary files /dev/null and b/public/audio/first-sea.wav differ diff --git a/public/audio/first-sound.wav b/public/audio/first-sound.wav new file mode 100644 index 0000000..6932dbe Binary files /dev/null and b/public/audio/first-sound.wav differ diff --git a/public/audio/first-tree.wav b/public/audio/first-tree.wav new file mode 100644 index 0000000..e6dc9e6 Binary files /dev/null and b/public/audio/first-tree.wav differ diff --git a/public/audio/first-umbrella.wav b/public/audio/first-umbrella.wav new file mode 100644 index 0000000..81846ca Binary files /dev/null and b/public/audio/first-umbrella.wav differ diff --git a/public/audio/intro.wav b/public/audio/intro.wav new file mode 100644 index 0000000..45ee424 Binary files /dev/null and b/public/audio/intro.wav differ diff --git a/public/audio/last-apple.wav b/public/audio/last-apple.wav new file mode 100644 index 0000000..63e58a5 Binary files /dev/null and b/public/audio/last-apple.wav differ diff --git a/public/audio/last-furniture.wav b/public/audio/last-furniture.wav new file mode 100644 index 0000000..171099f Binary files /dev/null and b/public/audio/last-furniture.wav differ diff --git a/public/audio/last-hat.wav b/public/audio/last-hat.wav new file mode 100644 index 0000000..8a94791 Binary files /dev/null and b/public/audio/last-hat.wav differ diff --git a/public/audio/last-market.wav b/public/audio/last-market.wav new file mode 100644 index 0000000..256b2df Binary files /dev/null and b/public/audio/last-market.wav differ diff --git a/public/audio/last-meat.wav b/public/audio/last-meat.wav new file mode 100644 index 0000000..1ed65af Binary files /dev/null and b/public/audio/last-meat.wav differ diff --git a/public/audio/last-oil.wav b/public/audio/last-oil.wav new file mode 100644 index 0000000..82e6b32 Binary files /dev/null and b/public/audio/last-oil.wav differ diff --git a/public/audio/last-sea.wav b/public/audio/last-sea.wav new file mode 100644 index 0000000..921dc6e Binary files /dev/null and b/public/audio/last-sea.wav differ diff --git a/public/audio/last-sound.wav b/public/audio/last-sound.wav new file mode 100644 index 0000000..07a6280 Binary files /dev/null and b/public/audio/last-sound.wav differ diff --git a/public/audio/last-tree.wav b/public/audio/last-tree.wav new file mode 100644 index 0000000..981f876 Binary files /dev/null and b/public/audio/last-tree.wav differ diff --git a/public/audio/last-umbrella.wav b/public/audio/last-umbrella.wav new file mode 100644 index 0000000..518a2ee Binary files /dev/null and b/public/audio/last-umbrella.wav differ diff --git a/public/audio/luck-apple.wav b/public/audio/luck-apple.wav new file mode 100644 index 0000000..733afe7 Binary files /dev/null and b/public/audio/luck-apple.wav differ diff --git a/public/audio/luck-furniture.wav b/public/audio/luck-furniture.wav new file mode 100644 index 0000000..add6c1d Binary files /dev/null and b/public/audio/luck-furniture.wav differ diff --git a/public/audio/luck-hat.wav b/public/audio/luck-hat.wav new file mode 100644 index 0000000..ef0ce7a Binary files /dev/null and b/public/audio/luck-hat.wav differ diff --git a/public/audio/luck-market.wav b/public/audio/luck-market.wav new file mode 100644 index 0000000..8081cbe Binary files /dev/null and b/public/audio/luck-market.wav differ diff --git a/public/audio/luck-meat.wav b/public/audio/luck-meat.wav new file mode 100644 index 0000000..f53ccd0 Binary files /dev/null and b/public/audio/luck-meat.wav differ diff --git a/public/audio/luck-oil.wav b/public/audio/luck-oil.wav new file mode 100644 index 0000000..c7c98bd Binary files /dev/null and b/public/audio/luck-oil.wav differ diff --git a/public/audio/luck-sea.wav b/public/audio/luck-sea.wav new file mode 100644 index 0000000..c12185a Binary files /dev/null and b/public/audio/luck-sea.wav differ diff --git a/public/audio/luck-sound.wav b/public/audio/luck-sound.wav new file mode 100644 index 0000000..d66528c Binary files /dev/null and b/public/audio/luck-sound.wav differ diff --git a/public/audio/luck-tree.wav b/public/audio/luck-tree.wav new file mode 100644 index 0000000..aa05fcb Binary files /dev/null and b/public/audio/luck-tree.wav differ diff --git a/public/audio/luck-umbrella.wav b/public/audio/luck-umbrella.wav new file mode 100644 index 0000000..54a8200 Binary files /dev/null and b/public/audio/luck-umbrella.wav differ diff --git a/public/audio/manifest.json b/public/audio/manifest.json new file mode 100644 index 0000000..f2449f1 --- /dev/null +++ b/public/audio/manifest.json @@ -0,0 +1,617 @@ +{ + "generator": "eSpeak NG ko, speed 145, pitch 45, amplitude 100; FFmpeg PCM 16kHz mono s16", + "toolVersions": { + "espeak": "eSpeak NG text-to-speech: 1.51", + "ffmpeg": "ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers" + }, + "source": "https://github.com/espeak-ng/espeak-ng", + "method": "자체 작성 문장의 오프라인 포먼트 합성; 외부 녹음·네트워크 TTS 없음", + "files": [ + { + "file": "intro.wav", + "text": "으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.", + "sha256": "4c271551d99cd9dffc01f35c176e98b54b4879549543ff8e2c79c6af8d4dc53a", + "bytes": 259900 + }, + { + "file": "luck-apple.wav", + "text": "오늘의 행운을 담은 단어는 ‘사과’입니다.", + "sha256": "51e6cf35b219f13c0d72ec9633f0c108c3393bc5ef6ae6aca6aa5d00153b4177", + "bytes": 125658 + }, + { + "file": "luck-sea.wav", + "text": "오늘의 행운을 담은 단어는 ‘바다’입니다.", + "sha256": "6585cb8d50eea173395417fc5fb357f55901665fe45dd07d942f79e202729111", + "bytes": 123366 + }, + { + "file": "luck-tree.wav", + "text": "오늘의 행운을 담은 단어는 ‘나무’입니다.", + "sha256": "6d188188d1478d0298536e0e03a58883629459dee56c6ae8ab88b4d7170c1def", + "bytes": 121224 + }, + { + "file": "luck-hat.wav", + "text": "오늘의 행운을 담은 단어는 ‘모자’입니다.", + "sha256": "be8d4287ebce15e221a8908f6b2d7478e789ecc51ba95d65fdf34f130954e7e5", + "bytes": 121644 + }, + { + "file": "luck-umbrella.wav", + "text": "오늘의 행운을 담은 단어는 ‘우산’입니다.", + "sha256": "188e24932e5e68fb0de8270e5b48128d486da956ad4069afbbcd0ad9af5913ce", + "bytes": 122178 + }, + { + "file": "luck-market.wav", + "text": "오늘의 행운을 담은 단어는 ‘시장’입니다.", + "sha256": "e58cdfd5a43074179e65edaf5f223dd15ad1accdd2fd547a6d3311de57b78eca", + "bytes": 124352 + }, + { + "file": "luck-oil.wav", + "text": "오늘의 행운을 담은 단어는 ‘기름’입니다.", + "sha256": "8b1070414191ff87b9531caf0782b13458b246a1f07b51f7ddcff2cd613930f7", + "bytes": 120286 + }, + { + "file": "luck-sound.wav", + "text": "오늘의 행운을 담은 단어는 ‘소리’입니다.", + "sha256": "052e2c2724c2fcf9d4c68ba3b746a430af0cfc74b42ed6f8317ae2cc5af1c2ad", + "bytes": 122660 + }, + { + "file": "luck-meat.wav", + "text": "오늘의 행운을 담은 단어는 ‘고기’입니다.", + "sha256": "dcf3d1c84a9ee00ea8a1ea5552ac69b3dc46b9c4160ee3375a4afe38426c0b8d", + "bytes": 121754 + }, + { + "file": "luck-furniture.wav", + "text": "오늘의 행운을 담은 단어는 ‘가구’입니다.", + "sha256": "6265e3807ea10eb82e9bb15a7e72dcab5e148277bc49bacfdb2663817d151ddf", + "bytes": 122164 + }, + { + "file": "password-apple.wav", + "text": "연습 암호는 ‘다과’, 진짜 암호는 ‘사과’입니다.", + "sha256": "14b7c8c0253784f319a04b568da594d8e46077d03931a36a23e8a7689dd4b928", + "bytes": 161688 + }, + { + "file": "password-sea.wav", + "text": "연습 암호는 ‘사다’, 진짜 암호는 ‘바다’입니다.", + "sha256": "832178b1eee4fe29653ca8dfda5bfb356cab83f4f880a704ee9eab937443f872", + "bytes": 160256 + }, + { + "file": "password-tree.wav", + "text": "연습 암호는 ‘나루’, 진짜 암호는 ‘나무’입니다.", + "sha256": "ef25aa09f89acadcbb2c43a1f18d0c2da292fae4d87d052868942d08b3825a8d", + "bytes": 156642 + }, + { + "file": "password-hat.wav", + "text": "연습 암호는 ‘포자’, 진짜 암호는 ‘모자’입니다.", + "sha256": "aef3f595e1b2d36212d4611f9998d841303e96ee5385639094aebcc984ff8a94", + "bytes": 158616 + }, + { + "file": "password-umbrella.wav", + "text": "연습 암호는 ‘부산’, 진짜 암호는 ‘우산’입니다.", + "sha256": "6bc1bf13c3fd1739bc9e490525ecd5839b9a4032faecb9f101a2c68824d57b82", + "bytes": 162668 + }, + { + "file": "password-market.wav", + "text": "연습 암호는 ‘지장’, 진짜 암호는 ‘시장’입니다.", + "sha256": "33036e08a06dd33b6aa86e5b78352d2f90e35ac1c7f9f202af2a32e2ceffcfc1", + "bytes": 166360 + }, + { + "file": "password-oil.wav", + "text": "연습 암호는 ‘이름’, 진짜 암호는 ‘기름’입니다.", + "sha256": "d88914ab7302112beecb6b2edbe114c3c119ee375985eca9716bf239aea94038", + "bytes": 157396 + }, + { + "file": "password-sound.wav", + "text": "연습 암호는 ‘오리’, 진짜 암호는 ‘소리’입니다.", + "sha256": "d3df570902e1f1fb5b2a7c573d57857bf4da547fb97ebdf27f04fbbf29c1170f", + "bytes": 155298 + }, + { + "file": "password-meat.wav", + "text": "연습 암호는 ‘보기’, 진짜 암호는 ‘고기’입니다.", + "sha256": "59c422dbf3c2ed7a397e7bf3455a2bf59132f85477765c6ec13673633cdb1e8c", + "bytes": 157754 + }, + { + "file": "password-furniture.wav", + "text": "연습 암호는 ‘하구’, 진짜 암호는 ‘가구’입니다.", + "sha256": "c349bd57598eec8543ce488dd86a4c4ddd4ec661ac9e8d7f42d914d545f38f61", + "bytes": 158998 + }, + { + "file": "remember-apple.wav", + "text": "‘다과’. 이 단어는 잊으세요. 이제 ‘사과’, 이 단어를 기억하세요.", + "sha256": "3123d84c546ff4e630a8b93d59bd5e668a5de30ea651e56e134d47690aead28b", + "bytes": 205094 + }, + { + "file": "remember-sea.wav", + "text": "‘사다’. 이 단어는 잊으세요. 이제 ‘바다’, 이 단어를 기억하세요.", + "sha256": "d802055500865e38b9beffe0213b70314aa59ec181cb1af1897dd010bb5875e3", + "bytes": 204654 + }, + { + "file": "remember-tree.wav", + "text": "‘나루’. 이 단어는 잊으세요. 이제 ‘나무’, 이 단어를 기억하세요.", + "sha256": "1e3f340609eb9844b7fc727f0c17a7f9f60245d4e00df2330d734efa97d26a33", + "bytes": 201206 + }, + { + "file": "remember-hat.wav", + "text": "‘포자’. 이 단어는 잊으세요. 이제 ‘모자’, 이 단어를 기억하세요.", + "sha256": "8a3cd1ef975d8a6782c018bbe23d9e69b4876fe8e64dcb9971f5025bb7072a4c", + "bytes": 202530 + }, + { + "file": "remember-umbrella.wav", + "text": "‘부산’. 이 단어는 잊으세요. 이제 ‘우산’, 이 단어를 기억하세요.", + "sha256": "5c5ab3c30f02d3a3b8188f1d81f4d8f617a9e9783c18da38bf6d4bee9d03709e", + "bytes": 209448 + }, + { + "file": "remember-market.wav", + "text": "‘지장’. 이 단어는 잊으세요. 이제 ‘시장’, 이 단어를 기억하세요.", + "sha256": "eb582873b55043a61b80e34b25302f8d52ae9c7d641032ce65ddca8fb9bfb181", + "bytes": 212988 + }, + { + "file": "remember-oil.wav", + "text": "‘이름’. 이 단어는 잊으세요. 이제 ‘기름’, 이 단어를 기억하세요.", + "sha256": "d56d8639bf462b1c341c07252666975ff6f3428de74f0bef886019fe0085be57", + "bytes": 204388 + }, + { + "file": "remember-sound.wav", + "text": "‘오리’. 이 단어는 잊으세요. 이제 ‘소리’, 이 단어를 기억하세요.", + "sha256": "c3d8e3e75086fd52a38806ea2122542d4fe495e381d4aa4c36e72277449451bf", + "bytes": 200100 + }, + { + "file": "remember-meat.wav", + "text": "‘보기’. 이 단어는 잊으세요. 이제 ‘고기’, 이 단어를 기억하세요.", + "sha256": "be4cc7dc1af803a23eb4319d076ecf4c5ab37b56913287f49c3b3c96eb4a75ce", + "bytes": 201730 + }, + { + "file": "remember-furniture.wav", + "text": "‘하구’. 이 단어는 잊으세요. 이제 ‘가구’, 이 단어를 기억하세요.", + "sha256": "acc3efbba9ef6bf53a3992a669946b6c58e90c9821e2fb5ef8e8c4a738f8bdf7", + "bytes": 202972 + }, + { + "file": "last-apple.wav", + "text": "세 단어 중 마지막을 고르세요. ‘다과’, ‘사고’, ‘사과’.", + "sha256": "5f583e9af9213cd5dc78abb68e5fcba3dbb7c65d7d8131a53210d8326e5cbb87", + "bytes": 187732 + }, + { + "file": "last-sea.wav", + "text": "세 단어 중 마지막을 고르세요. ‘사다’, ‘보다’, ‘바다’.", + "sha256": "21e589a6f7c262cca5533a8599a9d4fb257028a6e0c42732f6147209ad35ecb6", + "bytes": 190468 + }, + { + "file": "last-tree.wav", + "text": "세 단어 중 마지막을 고르세요. ‘나루’, ‘너무’, ‘나무’.", + "sha256": "915249a9860aa29c36149fc57b04057b2fa6ab9253fdfa1e0837236f84cb7aab", + "bytes": 186820 + }, + { + "file": "last-hat.wav", + "text": "세 단어 중 마지막을 고르세요. ‘포자’, ‘모조’, ‘모자’.", + "sha256": "0ce70943449ec5fb18c57d4ba7ea09a6e995f4bc991db07c70c9a846e16679a1", + "bytes": 189216 + }, + { + "file": "last-umbrella.wav", + "text": "세 단어 중 마지막을 고르세요. ‘부산’, ‘오산’, ‘우산’.", + "sha256": "8e5a3a57fc55a7deae5df5be5425a970b0a4926b74844f0f6fba74d882d8ea73", + "bytes": 192394 + }, + { + "file": "last-market.wav", + "text": "세 단어 중 마지막을 고르세요. ‘지장’, ‘사장’, ‘시장’.", + "sha256": "2419dbb03e18d3528a615b4977405699ab5285ea4b392e45164b625ebb5ff0db", + "bytes": 201286 + }, + { + "file": "last-oil.wav", + "text": "세 단어 중 마지막을 고르세요. ‘이름’, ‘거름’, ‘기름’.", + "sha256": "8a7108a6b23dddb5f2ae1c72edf6c488335ccd322acdb6d96a371e7613d47eb2", + "bytes": 191000 + }, + { + "file": "last-sound.wav", + "text": "세 단어 중 마지막을 고르세요. ‘오리’, ‘수리’, ‘소리’.", + "sha256": "c2b77970b477fd05671c833daaf2fb48aace796e1da54a1f9286fca0a8b7f484", + "bytes": 185296 + }, + { + "file": "last-meat.wav", + "text": "세 단어 중 마지막을 고르세요. ‘보기’, ‘고개’, ‘고기’.", + "sha256": "f1bbcef87d1b2fda6d36ecc484c62c6f1a4a182c12e2e0ac8bb58f494809ddf8", + "bytes": 185540 + }, + { + "file": "last-furniture.wav", + "text": "세 단어 중 마지막을 고르세요. ‘하구’, ‘가교’, ‘가구’.", + "sha256": "5e625527e69a3f9a9c508cb9da26252ab621949d7ae9ff41bdd9336627234de0", + "bytes": 185132 + }, + { + "file": "first-apple.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘사과’, ‘다과’, ‘사고’.", + "sha256": "d539d03875121570650cac2a2bf800cc67e0a72effaf755e02c870f9569e5028", + "bytes": 203904 + }, + { + "file": "first-sea.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘바다’, ‘사다’, ‘보다’.", + "sha256": "2d789349b36dab9bbe784e2fcb8412704f2dd15b22ad63937bad9948720aa5fb", + "bytes": 207002 + }, + { + "file": "first-tree.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘나무’, ‘나루’, ‘너무’.", + "sha256": "af4ad056b18af0d376a1df7dda3d7b8419e76cda6ffe57dd71054692aa653fd9", + "bytes": 203354 + }, + { + "file": "first-hat.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘모자’, ‘포자’, ‘모조’.", + "sha256": "e34fd5edff5323e45f8621a2adb79ed871d1ce671bd3ac8c4a49a8c703878811", + "bytes": 205388 + }, + { + "file": "first-umbrella.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘우산’, ‘부산’, ‘오산’.", + "sha256": "4be32c725583f1579b15be4bbe110b4ab42705221a63621125aa1aa98f7f061c", + "bytes": 210768 + }, + { + "file": "first-market.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘시장’, ‘지장’, ‘사장’.", + "sha256": "37925d65ce12429905a1b185824722a34af8d77b15eb7bec3b52665b5c1fbc7c", + "bytes": 217794 + }, + { + "file": "first-oil.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘기름’, ‘이름’, ‘거름’.", + "sha256": "0991123aee956c26ca658382102c9b6d0a164fbf2dea45f07a978d2cc1fc6b80", + "bytes": 205784 + }, + { + "file": "first-sound.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘소리’, ‘오리’, ‘수리’.", + "sha256": "5b3063285cddd8f46a2c2cbbee1ecc9b64ecdbd287df76df65dec854679ec884", + "bytes": 201840 + }, + { + "file": "first-meat.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘고기’, ‘보기’, ‘고개’.", + "sha256": "acf07b6db586616e67e2841a6f122120a5b0b18543047271bd5b8c55e33786e3", + "bytes": 201712 + }, + { + "file": "first-furniture.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘가구’, ‘하구’, ‘가교’.", + "sha256": "acba37fc2641c7073210855d294eb7c6e5f3328652c606186b1b0e569e2d0bf6", + "bytes": 203872 + }, + { + "file": "repeat-apple.wav", + "text": "두 번 들린 단어를 고르세요. ‘사과’, ‘다과’, ‘사과’, ‘사고’.", + "sha256": "5fcef242c63dc7e86c02a1a5813e409e6392511aad5834510696b0d0499a5ef1", + "bytes": 215994 + }, + { + "file": "repeat-sea.wav", + "text": "두 번 들린 단어를 고르세요. ‘바다’, ‘사다’, ‘바다’, ‘보다’.", + "sha256": "ef675bfa38f4eb4abac04cc64aa2c3ec416d405f9c67646356c9a8f39fe17d21", + "bytes": 211180 + }, + { + "file": "repeat-tree.wav", + "text": "두 번 들린 단어를 고르세요. ‘나무’, ‘나루’, ‘나무’, ‘너무’.", + "sha256": "b6a1d2923d90f9dd35b324aea419daf3891b76cc8c5c9551fe5ec1140c2ab0bf", + "bytes": 206504 + }, + { + "file": "repeat-hat.wav", + "text": "두 번 들린 단어를 고르세요. ‘모자’, ‘포자’, ‘모자’, ‘모조’.", + "sha256": "32cbc9823f5af5f897416bec2c377a8559c82fd3fae66273152826dd000cdc4c", + "bytes": 208872 + }, + { + "file": "repeat-umbrella.wav", + "text": "두 번 들린 단어를 고르세요. ‘우산’, ‘부산’, ‘우산’, ‘오산’.", + "sha256": "4830662f39bc0c62ee9be89de80a44a837c50063fad8fb63129b865673de5525", + "bytes": 220408 + }, + { + "file": "repeat-market.wav", + "text": "두 번 들린 단어를 고르세요. ‘시장’, ‘지장’, ‘시장’, ‘사장’.", + "sha256": "0e146387377dc38fc10435e06e8eb8cd30a595092887bf8ee4c9d0e7b577650d", + "bytes": 232742 + }, + { + "file": "repeat-oil.wav", + "text": "두 번 들린 단어를 고르세요. ‘기름’, ‘이름’, ‘기름’, ‘거름’.", + "sha256": "94de2ef7f7d7f9132f3646c3dc09399de23b81909f3edf4bbd1c5b01fad4d8fe", + "bytes": 216502 + }, + { + "file": "repeat-sound.wav", + "text": "두 번 들린 단어를 고르세요. ‘소리’, ‘오리’, ‘소리’, ‘수리’.", + "sha256": "7f12b6f49a7b4f64af796aa4ad8cfffc85a6b39e2bc6698f74b420b74af6c7c8", + "bytes": 205404 + }, + { + "file": "repeat-meat.wav", + "text": "두 번 들린 단어를 고르세요. ‘고기’, ‘보기’, ‘고기’, ‘고개’.", + "sha256": "ba8beeb33bcbe4771aaea9b3832a4c25868f6e34e37b3e3d026e891c07b8e71a", + "bytes": 203544 + }, + { + "file": "repeat-furniture.wav", + "text": "두 번 들린 단어를 고르세요. ‘가구’, ‘하구’, ‘가구’, ‘가교’.", + "sha256": "581a94726b08b6cfd3c688f08838a05787ed902ab6d1d4a038e0ff6b34f63c3a", + "bytes": 208274 + }, + { + "file": "correction-apple.wav", + "text": "정답은 ‘다과’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘사과’입니다.", + "sha256": "53d1c1b207607e610964e68134a3d35ff521b569e228366ec4eb7c03691724bb", + "bytes": 256484 + }, + { + "file": "correction-sea.wav", + "text": "정답은 ‘사다’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘바다’입니다.", + "sha256": "df61f91374b9fbb5817249ae3b6cadfae914d3e97ee2c6d07366ef82080a11db", + "bytes": 255204 + }, + { + "file": "correction-tree.wav", + "text": "정답은 ‘나루’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘나무’입니다.", + "sha256": "837c24b18f63a73e135511b2edd48ba12593317de3be4d225622e6d11b1a57da", + "bytes": 251316 + }, + { + "file": "correction-hat.wav", + "text": "정답은 ‘포자’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘모자’입니다.", + "sha256": "00d7c21caccbbb6c80ec6218415be1b0a6683a2d509b60821d3556e43353b5dc", + "bytes": 253208 + }, + { + "file": "correction-umbrella.wav", + "text": "정답은 ‘부산’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘우산’입니다.", + "sha256": "9bb3ce5b00ccc1632c37638550f329cbb6cda01864433e068f85eb7ccab86370", + "bytes": 255400 + }, + { + "file": "correction-market.wav", + "text": "정답은 ‘지장’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘시장’입니다.", + "sha256": "c8152db42c956701cd5e21c588ef62aedaefa922658d4bc0fecf8243548d7054", + "bytes": 257262 + }, + { + "file": "correction-oil.wav", + "text": "정답은 ‘이름’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘기름’입니다.", + "sha256": "228759e87b1c80be72ea34ff5b77cb5e4557afca1b73434ca8f6f1c2fedaa836", + "bytes": 247864 + }, + { + "file": "correction-sound.wav", + "text": "정답은 ‘오리’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘소리’입니다.", + "sha256": "24f2d52bb865c71fc4735a4aebd69ce0e429a9f68646e73e77adababbe34af93", + "bytes": 250170 + }, + { + "file": "correction-meat.wav", + "text": "정답은 ‘보기’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘고기’입니다.", + "sha256": "821caf897ddab107df8435b00b686bf9223e043c91a311edcaa0c3c5eb8c6511", + "bytes": 253432 + }, + { + "file": "correction-furniture.wav", + "text": "정답은 ‘하구’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘가구’입니다.", + "sha256": "5d69426f3319fa435ee60b234540dd8d9a07f00900177f03da2770f44511ade8", + "bytes": 254342 + }, + { + "file": "middle-apple.wav", + "text": "세 단어 중 가운데를 고르세요. ‘다과’, ‘사과’, ‘사고’.", + "sha256": "0ecc7a7d27508f73c9064a35609c922bbdd6fce8ebfc155e74f7b78d4e897dae", + "bytes": 191056 + }, + { + "file": "middle-sea.wav", + "text": "세 단어 중 가운데를 고르세요. ‘사다’, ‘바다’, ‘보다’.", + "sha256": "c7828af4e64745d89a6f23fda6c86efb76febe379bab0901c2972a4569947604", + "bytes": 194176 + }, + { + "file": "middle-tree.wav", + "text": "세 단어 중 가운데를 고르세요. ‘나루’, ‘나무’, ‘너무’.", + "sha256": "756d13453c7e13dcfaa65c8cb3497ac8d3a5d12bedb56dcd74e7f1c022392416", + "bytes": 190516 + }, + { + "file": "middle-hat.wav", + "text": "세 단어 중 가운데를 고르세요. ‘포자’, ‘모자’, ‘모조’.", + "sha256": "6ad5ec1e2c2c0de568c55a0c1f0e8361885725a2515cf396c8fe5d2106ae22d0", + "bytes": 192548 + }, + { + "file": "middle-umbrella.wav", + "text": "세 단어 중 가운데를 고르세요. ‘부산’, ‘우산’, ‘오산’.", + "sha256": "a507896dd50f98909c6a9fc0a4bfddfde5c975836e494cd9b7f40b69bc8ec33b", + "bytes": 196102 + }, + { + "file": "middle-market.wav", + "text": "세 단어 중 가운데를 고르세요. ‘지장’, ‘시장’, ‘사장’.", + "sha256": "900024616e512ea6777d08f80e3e2c83815ea1f03b6eeb537d212995474e1048", + "bytes": 204956 + }, + { + "file": "middle-oil.wav", + "text": "세 단어 중 가운데를 고르세요. ‘이름’, ‘기름’, ‘거름’.", + "sha256": "c42389a5ccd3b39a5877a38e647a6abbec6ff6a7eabf3d4c95c0507fa65d6d2f", + "bytes": 194772 + }, + { + "file": "middle-sound.wav", + "text": "세 단어 중 가운데를 고르세요. ‘오리’, ‘소리’, ‘수리’.", + "sha256": "b05ae2042bc973c3d1873fe15b5bc7f2af7e803794315023486efa788048342c", + "bytes": 188984 + }, + { + "file": "middle-meat.wav", + "text": "세 단어 중 가운데를 고르세요. ‘보기’, ‘고기’, ‘고개’.", + "sha256": "d54d6f9b4bd90b840443b9e0b0390a93909eeb81c37af6a9b57ce545bede532d", + "bytes": 188870 + }, + { + "file": "middle-furniture.wav", + "text": "세 단어 중 가운데를 고르세요. ‘하구’, ‘가구’, ‘가교’.", + "sha256": "d9318ef33ae1c60630ddda5abeefcbe43626d8f8a9abd5d62efd132eeb77c8fc", + "bytes": 191024 + }, + { + "file": "message-apple.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘다과’, ‘사과’. 지금은 빨간 불입니다.", + "sha256": "3867563d5acd2530c72cbe6f98ca2b463ec0c92285069c99c8ba084c45aabfd6", + "bytes": 316834 + }, + { + "file": "message-sea.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘사다’, ‘바다’. 지금은 빨간 불입니다.", + "sha256": "b7d8d89d93247eca9aec124f80bf6328d6d88511d05b513ce6d9fa32b3211848", + "bytes": 317816 + }, + { + "file": "message-tree.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘나루’, ‘나무’. 지금은 빨간 불입니다.", + "sha256": "9539e5ee87068e241598db2e4fbacb240daa011a2238c9864da8ffde6b644918", + "bytes": 315930 + }, + { + "file": "message-hat.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘포자’, ‘모자’. 지금은 빨간 불입니다.", + "sha256": "ccbba6a22c9cbc1231f45fbd7420c6257ac22ac72de8e8c0c97f1cc31bb2b16e", + "bytes": 317258 + }, + { + "file": "message-umbrella.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘부산’, ‘우산’. 지금은 빨간 불입니다.", + "sha256": "e980f4476f7275cc5ae24e7ce0e6d9c1e6db027f7cbd1eb39cc5bc4bc07cbf6e", + "bytes": 320532 + }, + { + "file": "message-market.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘지장’, ‘시장’. 지금은 빨간 불입니다.", + "sha256": "35432a3ed8dd1e15894ace5aad6f5a66cf28509ba92b8ee47bcaae424cb25ba1", + "bytes": 325564 + }, + { + "file": "message-oil.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘이름’, ‘기름’. 지금은 빨간 불입니다.", + "sha256": "814fc749006ed040de58b5236a8d92f0d79683c8eea3b9d095386344f41a47bd", + "bytes": 317786 + }, + { + "file": "message-sound.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘오리’, ‘소리’. 지금은 빨간 불입니다.", + "sha256": "e25c867e6c18d906c010816475253d2b10aa3cc4192b5551ba78055d65c461c8", + "bytes": 313270 + }, + { + "file": "message-meat.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘보기’, ‘고기’. 지금은 빨간 불입니다.", + "sha256": "f1863e1527b7714ae64f5d752b542e8768b8ed89b5885012283f7ca20742ee00", + "bytes": 314464 + }, + { + "file": "message-furniture.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘하구’, ‘가구’. 지금은 빨간 불입니다.", + "sha256": "43f87d0e3d92bd132f9ea46e024b9a940736b7399e899866ed42af367a550f78", + "bytes": 316152 + }, + { + "file": "mission-apple.wav", + "text": "오른쪽 표지는 ‘사과’, 왼쪽 표지는 ‘다과’입니다.", + "sha256": "36697151a1a57645f5acd9fcca2d13bffa21937e1bbcde08ab2d8d485730145a", + "bytes": 166540 + }, + { + "file": "mission-sea.wav", + "text": "오른쪽 표지는 ‘바다’, 왼쪽 표지는 ‘사다’입니다.", + "sha256": "2741f404e39ec64af7f73452f82c68b3ae1d377e9f40a586034f9b147fbfb114", + "bytes": 165122 + }, + { + "file": "mission-tree.wav", + "text": "오른쪽 표지는 ‘나무’, 왼쪽 표지는 ‘나루’입니다.", + "sha256": "8a7bb2c795d8877433be2621b69a48c2b6c5c1e192c094c01257fb93eb5d9449", + "bytes": 160834 + }, + { + "file": "mission-hat.wav", + "text": "오른쪽 표지는 ‘모자’, 왼쪽 표지는 ‘포자’입니다.", + "sha256": "26dfa26bd8dcb9d63c231dfc37bf25970f4a8dadcaa40347dd15974887e948b1", + "bytes": 163102 + }, + { + "file": "mission-umbrella.wav", + "text": "오른쪽 표지는 ‘우산’, 왼쪽 표지는 ‘부산’입니다.", + "sha256": "79be98918c3fb4e5e4e2e51b2bfa5e5aad6c600478ae1a34c055b16ce71d5264", + "bytes": 167480 + }, + { + "file": "mission-market.wav", + "text": "오른쪽 표지는 ‘시장’, 왼쪽 표지는 ‘지장’입니다.", + "sha256": "8f18144a91072be37dc31a161fe6e9bb1980ab36af0edee8d7a359397b8bb8ac", + "bytes": 170724 + }, + { + "file": "mission-oil.wav", + "text": "오른쪽 표지는 ‘기름’, 왼쪽 표지는 ‘이름’입니다.", + "sha256": "1ceabc58f445f7bd5609b2f9de80b309ad64d11c888c7261cf84315a7637a748", + "bytes": 161758 + }, + { + "file": "mission-sound.wav", + "text": "오른쪽 표지는 ‘소리’, 왼쪽 표지는 ‘오리’입니다.", + "sha256": "6651b14caf4e54069730f137e5d8c3c1e5a9c66aa72f15623cc442edb967cadb", + "bytes": 160162 + }, + { + "file": "mission-meat.wav", + "text": "오른쪽 표지는 ‘고기’, 왼쪽 표지는 ‘보기’입니다.", + "sha256": "5d2ab3071fd27e850d99c50ad37905096ef28267b660a35992559bdc126c4931", + "bytes": 162626 + }, + { + "file": "mission-furniture.wav", + "text": "오른쪽 표지는 ‘가구’, 왼쪽 표지는 ‘하구’입니다.", + "sha256": "080307bb46bb47378c0c03437d9ef44118a5d4ea17b2b6d70e5cbc710c71ba78", + "bytes": 163884 + } + ] +} diff --git a/public/audio/message-apple.wav b/public/audio/message-apple.wav new file mode 100644 index 0000000..33c07b6 Binary files /dev/null and b/public/audio/message-apple.wav differ diff --git a/public/audio/message-furniture.wav b/public/audio/message-furniture.wav new file mode 100644 index 0000000..a315407 Binary files /dev/null and b/public/audio/message-furniture.wav differ diff --git a/public/audio/message-hat.wav b/public/audio/message-hat.wav new file mode 100644 index 0000000..3567e24 Binary files /dev/null and b/public/audio/message-hat.wav differ diff --git a/public/audio/message-market.wav b/public/audio/message-market.wav new file mode 100644 index 0000000..94e8c60 Binary files /dev/null and b/public/audio/message-market.wav differ diff --git a/public/audio/message-meat.wav b/public/audio/message-meat.wav new file mode 100644 index 0000000..607a993 Binary files /dev/null and b/public/audio/message-meat.wav differ diff --git a/public/audio/message-oil.wav b/public/audio/message-oil.wav new file mode 100644 index 0000000..8e311d1 Binary files /dev/null and b/public/audio/message-oil.wav differ diff --git a/public/audio/message-sea.wav b/public/audio/message-sea.wav new file mode 100644 index 0000000..f215908 Binary files /dev/null and b/public/audio/message-sea.wav differ diff --git a/public/audio/message-sound.wav b/public/audio/message-sound.wav new file mode 100644 index 0000000..306e3c9 Binary files /dev/null and b/public/audio/message-sound.wav differ diff --git a/public/audio/message-tree.wav b/public/audio/message-tree.wav new file mode 100644 index 0000000..b0fc06f Binary files /dev/null and b/public/audio/message-tree.wav differ diff --git a/public/audio/message-umbrella.wav b/public/audio/message-umbrella.wav new file mode 100644 index 0000000..bc29eab Binary files /dev/null and b/public/audio/message-umbrella.wav differ diff --git a/public/audio/middle-apple.wav b/public/audio/middle-apple.wav new file mode 100644 index 0000000..fc5573a Binary files /dev/null and b/public/audio/middle-apple.wav differ diff --git a/public/audio/middle-furniture.wav b/public/audio/middle-furniture.wav new file mode 100644 index 0000000..53fcca9 Binary files /dev/null and b/public/audio/middle-furniture.wav differ diff --git a/public/audio/middle-hat.wav b/public/audio/middle-hat.wav new file mode 100644 index 0000000..fa511b4 Binary files /dev/null and b/public/audio/middle-hat.wav differ diff --git a/public/audio/middle-market.wav b/public/audio/middle-market.wav new file mode 100644 index 0000000..ed769b1 Binary files /dev/null and b/public/audio/middle-market.wav differ diff --git a/public/audio/middle-meat.wav b/public/audio/middle-meat.wav new file mode 100644 index 0000000..7ab8dc4 Binary files /dev/null and b/public/audio/middle-meat.wav differ diff --git a/public/audio/middle-oil.wav b/public/audio/middle-oil.wav new file mode 100644 index 0000000..1f32982 Binary files /dev/null and b/public/audio/middle-oil.wav differ diff --git a/public/audio/middle-sea.wav b/public/audio/middle-sea.wav new file mode 100644 index 0000000..1a50c18 Binary files /dev/null and b/public/audio/middle-sea.wav differ diff --git a/public/audio/middle-sound.wav b/public/audio/middle-sound.wav new file mode 100644 index 0000000..0eda14c Binary files /dev/null and b/public/audio/middle-sound.wav differ diff --git a/public/audio/middle-tree.wav b/public/audio/middle-tree.wav new file mode 100644 index 0000000..d565b05 Binary files /dev/null and b/public/audio/middle-tree.wav differ diff --git a/public/audio/middle-umbrella.wav b/public/audio/middle-umbrella.wav new file mode 100644 index 0000000..6d7106c Binary files /dev/null and b/public/audio/middle-umbrella.wav differ diff --git a/public/audio/mission-apple.wav b/public/audio/mission-apple.wav new file mode 100644 index 0000000..ff18515 Binary files /dev/null and b/public/audio/mission-apple.wav differ diff --git a/public/audio/mission-furniture.wav b/public/audio/mission-furniture.wav new file mode 100644 index 0000000..b3b6a77 Binary files /dev/null and b/public/audio/mission-furniture.wav differ diff --git a/public/audio/mission-hat.wav b/public/audio/mission-hat.wav new file mode 100644 index 0000000..89b0f39 Binary files /dev/null and b/public/audio/mission-hat.wav differ diff --git a/public/audio/mission-market.wav b/public/audio/mission-market.wav new file mode 100644 index 0000000..a78bed0 Binary files /dev/null and b/public/audio/mission-market.wav differ diff --git a/public/audio/mission-meat.wav b/public/audio/mission-meat.wav new file mode 100644 index 0000000..a5bf74c Binary files /dev/null and b/public/audio/mission-meat.wav differ diff --git a/public/audio/mission-oil.wav b/public/audio/mission-oil.wav new file mode 100644 index 0000000..0497e0e Binary files /dev/null and b/public/audio/mission-oil.wav differ diff --git a/public/audio/mission-sea.wav b/public/audio/mission-sea.wav new file mode 100644 index 0000000..919941d Binary files /dev/null and b/public/audio/mission-sea.wav differ diff --git a/public/audio/mission-sound.wav b/public/audio/mission-sound.wav new file mode 100644 index 0000000..d7ceab1 Binary files /dev/null and b/public/audio/mission-sound.wav differ diff --git a/public/audio/mission-tree.wav b/public/audio/mission-tree.wav new file mode 100644 index 0000000..2caf41d Binary files /dev/null and b/public/audio/mission-tree.wav differ diff --git a/public/audio/mission-umbrella.wav b/public/audio/mission-umbrella.wav new file mode 100644 index 0000000..39f35f3 Binary files /dev/null and b/public/audio/mission-umbrella.wav differ diff --git a/public/audio/password-apple.wav b/public/audio/password-apple.wav new file mode 100644 index 0000000..cd5510a Binary files /dev/null and b/public/audio/password-apple.wav differ diff --git a/public/audio/password-furniture.wav b/public/audio/password-furniture.wav new file mode 100644 index 0000000..91cdc97 Binary files /dev/null and b/public/audio/password-furniture.wav differ diff --git a/public/audio/password-hat.wav b/public/audio/password-hat.wav new file mode 100644 index 0000000..e47af21 Binary files /dev/null and b/public/audio/password-hat.wav differ diff --git a/public/audio/password-market.wav b/public/audio/password-market.wav new file mode 100644 index 0000000..6344a6d Binary files /dev/null and b/public/audio/password-market.wav differ diff --git a/public/audio/password-meat.wav b/public/audio/password-meat.wav new file mode 100644 index 0000000..172ea2f Binary files /dev/null and b/public/audio/password-meat.wav differ diff --git a/public/audio/password-oil.wav b/public/audio/password-oil.wav new file mode 100644 index 0000000..2304a39 Binary files /dev/null and b/public/audio/password-oil.wav differ diff --git a/public/audio/password-sea.wav b/public/audio/password-sea.wav new file mode 100644 index 0000000..764d899 Binary files /dev/null and b/public/audio/password-sea.wav differ diff --git a/public/audio/password-sound.wav b/public/audio/password-sound.wav new file mode 100644 index 0000000..50f476e Binary files /dev/null and b/public/audio/password-sound.wav differ diff --git a/public/audio/password-tree.wav b/public/audio/password-tree.wav new file mode 100644 index 0000000..ce4e587 Binary files /dev/null and b/public/audio/password-tree.wav differ diff --git a/public/audio/password-umbrella.wav b/public/audio/password-umbrella.wav new file mode 100644 index 0000000..bf97452 Binary files /dev/null and b/public/audio/password-umbrella.wav differ diff --git a/public/audio/remember-apple.wav b/public/audio/remember-apple.wav new file mode 100644 index 0000000..e3c19d5 Binary files /dev/null and b/public/audio/remember-apple.wav differ diff --git a/public/audio/remember-furniture.wav b/public/audio/remember-furniture.wav new file mode 100644 index 0000000..c969da9 Binary files /dev/null and b/public/audio/remember-furniture.wav differ diff --git a/public/audio/remember-hat.wav b/public/audio/remember-hat.wav new file mode 100644 index 0000000..02fa29a Binary files /dev/null and b/public/audio/remember-hat.wav differ diff --git a/public/audio/remember-market.wav b/public/audio/remember-market.wav new file mode 100644 index 0000000..71a4ed6 Binary files /dev/null and b/public/audio/remember-market.wav differ diff --git a/public/audio/remember-meat.wav b/public/audio/remember-meat.wav new file mode 100644 index 0000000..2aac715 Binary files /dev/null and b/public/audio/remember-meat.wav differ diff --git a/public/audio/remember-oil.wav b/public/audio/remember-oil.wav new file mode 100644 index 0000000..9e49583 Binary files /dev/null and b/public/audio/remember-oil.wav differ diff --git a/public/audio/remember-sea.wav b/public/audio/remember-sea.wav new file mode 100644 index 0000000..d5076ed Binary files /dev/null and b/public/audio/remember-sea.wav differ diff --git a/public/audio/remember-sound.wav b/public/audio/remember-sound.wav new file mode 100644 index 0000000..e58c076 Binary files /dev/null and b/public/audio/remember-sound.wav differ diff --git a/public/audio/remember-tree.wav b/public/audio/remember-tree.wav new file mode 100644 index 0000000..306c9a1 Binary files /dev/null and b/public/audio/remember-tree.wav differ diff --git a/public/audio/remember-umbrella.wav b/public/audio/remember-umbrella.wav new file mode 100644 index 0000000..7db2b2e Binary files /dev/null and b/public/audio/remember-umbrella.wav differ diff --git a/public/audio/repeat-apple.wav b/public/audio/repeat-apple.wav new file mode 100644 index 0000000..87d6284 Binary files /dev/null and b/public/audio/repeat-apple.wav differ diff --git a/public/audio/repeat-furniture.wav b/public/audio/repeat-furniture.wav new file mode 100644 index 0000000..9fec355 Binary files /dev/null and b/public/audio/repeat-furniture.wav differ diff --git a/public/audio/repeat-hat.wav b/public/audio/repeat-hat.wav new file mode 100644 index 0000000..43fd603 Binary files /dev/null and b/public/audio/repeat-hat.wav differ diff --git a/public/audio/repeat-market.wav b/public/audio/repeat-market.wav new file mode 100644 index 0000000..1aaf4be Binary files /dev/null and b/public/audio/repeat-market.wav differ diff --git a/public/audio/repeat-meat.wav b/public/audio/repeat-meat.wav new file mode 100644 index 0000000..54c89a1 Binary files /dev/null and b/public/audio/repeat-meat.wav differ diff --git a/public/audio/repeat-oil.wav b/public/audio/repeat-oil.wav new file mode 100644 index 0000000..a0af96f Binary files /dev/null and b/public/audio/repeat-oil.wav differ diff --git a/public/audio/repeat-sea.wav b/public/audio/repeat-sea.wav new file mode 100644 index 0000000..a6bd217 Binary files /dev/null and b/public/audio/repeat-sea.wav differ diff --git a/public/audio/repeat-sound.wav b/public/audio/repeat-sound.wav new file mode 100644 index 0000000..692a8f2 Binary files /dev/null and b/public/audio/repeat-sound.wav differ diff --git a/public/audio/repeat-tree.wav b/public/audio/repeat-tree.wav new file mode 100644 index 0000000..c5e4cc1 Binary files /dev/null and b/public/audio/repeat-tree.wav differ diff --git a/public/audio/repeat-umbrella.wav b/public/audio/repeat-umbrella.wav new file mode 100644 index 0000000..ae7d7b7 Binary files /dev/null and b/public/audio/repeat-umbrella.wav differ diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs new file mode 100644 index 0000000..6d333f5 --- /dev/null +++ b/scripts/check-build.mjs @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포 +// 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다. +export function verifyBuild(directory) { + // 번들에 실제 포함되는 전이 의존성도 잠금 파일에서 찾는다. 프로젝트의 + // 비공개 라이선스와 별개로 설치된 MIT 전문·버전을 사본 세 곳에서 보존한다. + try { + const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8"); + const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + for (const [path, pkg] of Object.entries(lock.packages)) { + if (!path || pkg.dev) continue; + const name = path.split("node_modules/").at(-1); + const license = readFileSync(`${path}/LICENSE`, "utf8").trim(); + if (!notice.includes(`## ${name} ${pkg.version}\n\n${license}`)) + throw new Error(`${name} ${pkg.version}의 전문이 없습니다.`); + } + for (const path of [ + "public/THIRD_PARTY_NOTICES.md", + resolve(directory, "THIRD_PARTY_NOTICES.md"), + ]) { + if (readFileSync(path, "utf8") !== notice) + throw new Error(`${path}가 원본과 다릅니다.`); + } + } catch (cause) { + throw new Error("런타임 의존성 notice 누락 또는 불일치", { cause }); + } + const manifest = JSON.parse( + readFileSync("public/audio/manifest.json", "utf8"), + ); + const builtManifest = JSON.parse( + readFileSync(resolve(directory, "audio/manifest.json"), "utf8"), + ); + if (JSON.stringify(manifest) !== JSON.stringify(builtManifest)) + throw new Error("빌드 음성 목록이 원본과 다릅니다."); + for (const entry of manifest.files) { + const bytes = readFileSync(resolve(directory, "audio", entry.file)); + if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) + throw new Error(`빌드 자산 해시 불일치: ${entry.file}`); + } + return manifest.files.length; +} +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + console.log( + `빌드 음성 자산 ${verifyBuild("dist")}개 및 런타임 notice 전문 확인`, + ); +} diff --git a/scripts/generate_audio.py b/scripts/generate_audio.py new file mode 100644 index 0000000..1b7d019 --- /dev/null +++ b/scripts/generate_audio.py @@ -0,0 +1,71 @@ +"""직접 작성한 한국어 문장을 로컬 포먼트 합성기로 WAV로 만든다. + +네트워크 TTS·녹음 샘플·API 키를 사용하지 않는다. 원문은 TypeScript 데이터에서 +읽어 중복 관리를 없애고, 바이트 해시를 남겨 빌드에서 자산 누락·변조를 찾는다. +""" + +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import wave + +ROOT = Path(__file__).resolve().parent.parent +OUTPUT = ROOT / "public/audio" +LOCAL = ROOT / ".cache/espeak/root/usr" +ENV = os.environ.copy() +ENGINE = shutil.which("espeak-ng") +if ENGINE is None: + # 시스템 설치 권한 없이 worktree 안에 압축 해제한 Ubuntu 패키지도 지원한다. + ENGINE = str(LOCAL / "bin/espeak-ng") + ENV["LD_LIBRARY_PATH"] = str(LOCAL / "lib/x86_64-linux-gnu") + ENV["ESPEAK_DATA_PATH"] = str(LOCAL / "lib/x86_64-linux-gnu/espeak-ng-data") + +source = subprocess.check_output([ + "node", "--experimental-strip-types", "--input-type=module", "-e", + "import {INTRO,PATTERNS,WORDS,renderSentence} from './src/content.ts'; " + "console.log(JSON.stringify([{file:'intro.wav',text:INTRO}," + "...PATTERNS.flatMap(p=>WORDS.map(w=>({file:p.id+'-'+w.id+'.wav'," + "text:renderSentence(p,w)})))]));", +], cwd=ROOT, text=True) +entries = json.loads(source) +OUTPUT.mkdir(parents=True, exist_ok=True) +# 버전을 상수로 쓰면 다른 PC에서 재생성할 때 출처가 거짓이 된다. 실행 중인 +# 도구에서 직접 읽되 eSpeak의 로컬 데이터 경로는 재현성 비교에서 제외한다. +versions = { + "espeak": subprocess.check_output([ENGINE, "--version"], env=ENV, text=True).split("Data at:")[0].strip(), + "ffmpeg": subprocess.check_output(["ffmpeg", "-version"], text=True).splitlines()[0], +} +for entry in entries: + # 명령 인자로 텍스트를 넘겨 쉘 보간을 피한다. 합성 속도·피치·리샘플링 + # 설정을 고정하고 메타데이터 없는 표준 PCM 헤더를 직접 작성한다. + speech = subprocess.check_output([ + ENGINE, "-v", "ko", "-s", "145", "-p", "45", "-a", "100", "--stdout", entry["text"], + ], env=ENV) + pcm = subprocess.check_output([ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0", + "-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1", + ], input=speech) + stream = io.BytesIO() + with wave.open(stream, "wb") as output: + output.setnchannels(1) + output.setsampwidth(2) + output.setframerate(16000) + output.writeframes(pcm) + data = stream.getvalue() + (OUTPUT / entry["file"]).write_bytes(data) + entry["sha256"] = hashlib.sha256(data).hexdigest() + entry["bytes"] = len(data) + +manifest = { + "generator": "eSpeak NG ko, speed 145, pitch 45, amplitude 100; FFmpeg PCM 16kHz mono s16", + "toolVersions": versions, + "source": "https://github.com/espeak-ng/espeak-ng", + "method": "자체 작성 문장의 오프라인 포먼트 합성; 외부 녹음·네트워크 TTS 없음", + "files": entries, +} +(OUTPUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") +print(f"{len(entries)}개 WAV 생성, 합계 {sum(e['bytes'] for e in entries):,} 바이트") diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3c2be36 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,659 @@ +import { useEffect, useRef, useState } from "react"; +import { AudioEngine, type AudioPort } from "./audio/engine"; +import { ENVIRONMENTS, LEVELS } from "./content"; +import { + createQuestions, + gameReducer, + initialGame, + normalizeAnswer, + summarize, + type Action, + type GameState, + type Mode, + type Question, +} from "./game"; + +// 화면은 순수 reducer의 단계만 표시한다. 엔진과 난수 seed를 주입할 수 있어 +// 사용자 흐름을 실제 장치 없이도 재현하고 별도로 실제 브라우저에서 검증한다. +export interface AppProps { + audio?: AudioPort; + seed?: number; +} + +function SoundWave({ active = false }: { active?: boolean }) { + // 파형은 장식이며 답의 글자 수나 실제 음성 데이터를 노출하지 않는다. + return ( + + ); +} + +function Mascot({ small = false }: { small?: boolean }) { + // CSS로 직접 그린 캐릭터라 외부 이미지·폰트·저작권 불명 자산이 필요 없다. + return ( + + ); +} + +// 난이도의 dB는 배경 대비 음성의 상대 크기다. 게임·결과에서 같은 표기를 +// 쓰고 부호의 의미를 풀어 설명하되 엔진에 전달하는 수치는 바꾸지 않는다. +function formatSnr(db: number) { + return `SNR ${db > 0 ? "+" : ""}${db} dB`; +} + +function describeSnr(db: number) { + if (db === 0) return "음성과 배경의 크기가 같아요."; + return `음성이 배경보다 ${Math.abs(db)} dB ${db > 0 ? "커요" : "작아요"}.`; +} + +export default function App({ audio, seed }: AppProps) { + const [engine] = useState(() => audio ?? new AudioEngine()); + const [mode, setMode] = useState("mixed"); + const [environment, setEnvironment] = useState("rotate"); + const [volume, setVolume] = useState(35); + const [game, setGame] = useState(null); + const [input, setInput] = useState(""); + const [attempt, setAttempt] = useState(0); + const token = useRef(0); + const playing = useRef(false); + const introDone = useRef(false); + // 한글 IME의 Enter는 글자 조합 확정에도 사용된다. 조합 중 form 제출은 + // 막고 확정 뒤 명시적인 제출만 채점하여 모바일·데스크톱 입력을 보호한다. + const composing = useRef(false); + const focusTarget = useRef(null); + const startButton = useRef(null); + const dispatch = (action: Action) => + setGame((previous) => + previous ? gameReducer(previous, action) : previous, + ); + + useEffect( + () => () => { + token.current++; + playing.current = false; + engine.dispose(); + }, + [engine], + ); + useEffect(() => { + // 단계가 바뀌면 제목으로 초점을 옮겨 스크린리더·키보드 사용자 모두 새 + // 질문/피드백의 위치를 알게 한다. 입력 중 재렌더에는 초점을 빼앗지 않는다. + if ( + game?.phase === "answer" || + game?.phase === "feedback" || + game?.phase === "results" || + game?.phase === "ready" + ) + focusTarget.current?.focus(); + }, [game?.phase, game?.index]); + + const run = async (question: Question) => { + if (playing.current) return; + // 다시 듣기로 입력 DOM이 사라질 때 compositionend가 생략되는 브라우저도 + // 있으므로 재생 경계에서 조합 잠금을 해제한다. 입력 값 자체는 유지한다. + composing.current = false; + playing.current = true; + const currentToken = ++token.current; + dispatch({ type: "PLAY" }); + try { + await engine.play(question, !introDone.current, () => { + if (currentToken === token.current) dispatch({ type: "STARTED" }); + }); + if (currentToken !== token.current) return; + introDone.current = true; + dispatch({ type: "ENDED" }); + } catch (error) { + if (currentToken !== token.current) return; + // 내부 URL·예외 스택 대신 이해할 수 있는 복구 동작을 안내한다. + const detail = + error instanceof Error && error.name === "AbortError" + ? "듣기를 중단했습니다. 다시 시도해 주세요." + : "소리를 재생하지 못했어요. 브라우저의 소리 허용과 연결 상태를 확인한 뒤 다시 시도해 주세요."; + dispatch({ type: "ERROR", message: detail }); + } finally { + if (currentToken === token.current) playing.current = false; + } + }; + + const begin = () => { + const questions = createQuestions( + (seed ?? Date.now()) + attempt, + mode, + environment, + ); + introDone.current = false; + composing.current = false; + setInput(""); + setGame(initialGame(questions)); + // 사용자 클릭 호출 스택에서 엔진을 열어 자동 재생 정책을 준수한다. + void run(questions[0]!); + }; + const home = () => { + token.current++; + playing.current = false; + engine.stop(); + composing.current = false; + setGame(null); + setInput(""); + setAttempt((a) => a + 1); + requestAnimationFrame(() => startButton.current?.focus()); + }; + const next = () => { + composing.current = false; + setInput(""); + dispatch({ type: "NEXT" }); + }; + const question = game?.questions[game.index]; + const result = game ? summarize(game) : null; + const busy = game?.phase === "loading" || game?.phase === "playing"; + + // 상태와 엔진 연결은 공유하고 각 화면에는 컨트롤 하나만 배치한다. + // 위치가 바뀌어도 기존 접근성 이름·label 연결·사용자가 정한 음량을 유지한다. + const volumeControl = ( +
+ + { + const value = Number(event.target.value); + setVolume(value); + engine.setVolume(value / 100); + }} + /> + {volume}% +
+ ); + + return ( + <> + + 본문으로 건너뛰기 + +
+
+ +

단어를 맞춰라

+ v0.1 +
+
+ {game ? ( + + ) : ( + + 플레이 방법 + + )} + + 소리에 집중하는 시간 +
+
+ +
+ {!game ? ( + <> +
+
+

+ 귀를 기울이면, 발견하는 즐거움 +

+

+ 소음 속 작은 단어, +
+ 들리시나요? +

+

+ 도시의 소리 사이에 에스가 숨겨 둔 단어. +
+ 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. +

+
+ 10개의 질문 + 5단계 도전 + 나만의 속도로 +
+
+
+
+ + 에스의 사운드 룸 + + {ENVIRONMENTS.length}가지 환경 +
+
+
+ + +
행운의 단어를 찾아보거라!
+ +
+ + 소음 사이, 숨겨진 한마디 +
+
+
+ +
+
+ 01 +
+

나의 소리 탐험 준비

+

편한 방식으로 시작하세요. 난이도는 조금씩 높아져요.

+
+ 약 3–5분 +
+
+
+ 어떻게 답할까요? +
+ {( + [ + { + value: "mixed", + name: "골고루 풀기", + sub: "선택 + 직접 입력", + icon: "◈", + }, + { + value: "choice", + name: "보기에서 선택", + sub: "4개 단어 중 하나", + icon: "☷", + }, + { + value: "text", + name: "직접 입력", + sub: "기억한 단어 쓰기", + icon: "✎", + }, + ] as const + ).map((option) => ( + + ))} +
+
+
+ + +

+ {ENVIRONMENTS.find((e) => e.id === environment) + ?.description ?? + "길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요."} +

+
+ 길거리 + 카페 + 지하철 + +5개 환경 +
+
+
+ {volumeControl} +
+

+ 편안한 작은 음량으로 시작해 + 보세요. +

+ +
+
+ +
+

작은 단어를 발견하는 방법

+
+
+ 01 +

먼저, 귀 기울이기

+

+ 에스의 안내와 소음 속 문장을 들어요. +
+ 처음에는 또렷하게, 점점 더 작게. +

+
+
+ 02 +

기억한 단어 찾기

+

+ 소리가 끝나면 질문이 나타나요. +
+ 보기에서 고르거나 직접 입력해요. +

+
+
+ 03 +

나의 발견 돌아보기

+

+ 열 문제 뒤 나의 결과를 확인해요. +
+ 놓쳤다면 언제든 다시 들어도 좋아요. +

+
+
+
+ + ) : game.phase === "results" && result ? ( +
+ 열 번의 귀 기울임, 하나의 발견 +
+ +
+

+ 오늘의 소리 탐험을 마쳤어요. +

+

+ 작은 소리에 집중한 당신, 다음 탐험에서도 만나요. +

+
+
+ {result.score.toLocaleString()} + 총 점수 +
+
+ {result.accuracy}% + 정답률 · {result.correct}/10 +
+
+ + {game.bestStreak} + 회 + + 최고 연속 정답 +
+
+ + + + + + + + + + {result.byLevel.map((row) => ( + + + + + + ))} + +
난이도SNR맞힌 문제
{row.level}단계 + {formatSnr(LEVELS[row.level - 1]!.db)} + + {row.correct} / {row.total} +
+

단계 × 100점 + 연속 정답 보너스 (최대 40점)

+ +
+ ) : question && result ? ( +
+
+ 나의 소리 탐험 + + 문제 {String(game.index + 1).padStart(2, "0")} / 10 + +
+ +
+ +
+
+ {question.pattern.title} + + {question.mode === "choice" ? "객관식" : "주관식"} + +
+ {game.phase === "answer" ? ( + <> +

+ {question.pattern.question} +

+

+ {question.mode === "choice" + ? "가장 또렷하게 기억나는 단어를 골라 주세요." + : "기억나는 단어 하나를 입력해 주세요."} +

+ {question.mode === "choice" ? ( +
+ {question.options.map((option, i) => ( + + ))} +
+ ) : ( +
{ + event.preventDefault(); + if (composing.current) return; + dispatch({ type: "ANSWER", input }); + }} + > + + { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + (event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229) + ) + event.preventDefault(); + }} + autoComplete="off" + maxLength={60} + value={input} + onChange={(event) => setInput(event.target.value)} + placeholder="여기에 단어를 적어 주세요" + /> + +
+ )} + + ) : game.phase === "feedback" ? ( +
+ +

+ {game.answers.at(-1)!.correct + ? "정답이에요!" + : "다음엔 찾을 수 있어요."} +

+

+ 숨겨진 단어는 {question.word.answer} +

+
{question.sentence}
+

+ 내 답: {game.answers.at(-1)!.input} · + + {game.answers.at(-1)!.points}점 +

+ +
+ ) : ( +
+ +

+ {busy + ? "소리 사이에 집중해 보세요." + : "다음 단어를 만나 볼까요?"} +

+

+ 문장이 끝나면 질문이 나타나요. +
+ 작은 단어 하나를 기억해 주세요. +

+

+ {game.phase === "loading" + ? "소리를 준비하고 있어요…" + : game.phase === "playing" + ? "에스의 목소리를 듣고 있어요…" + : "준비되면 문장 듣기를 눌러 주세요."} +

+
+ )} + {game.error && ( +
+

{game.error}

+ +
+ )} + {game.phase !== "feedback" && !game.error && ( +
+ + {busy && ( + + )} + 다시 들어도 점수는 줄어들지 않아요. +
+ )} +
+
+
+ ) : null} + {game?.phase === "results" && volumeControl} +
+
+

Designed by OZ at Heat-T Medical Inc.

+

청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.

+ 개인정보 수집 없이, 오직 플레이만. +
+ + ); +} diff --git a/src/audio/dsp.ts b/src/audio/dsp.ts new file mode 100644 index 0000000..e38bc2f --- /dev/null +++ b/src/audio/dsp.ts @@ -0,0 +1,49 @@ +import type { Environment } from "../content"; + +// 디지털 오디오의 상대 음량 계산과 배경 합성은 장치와 분리한다. 따라서 +// 브라우저 권한 없이도 수학적 비율과 동일 seed의 재현성을 검사할 수 있다. +export function dbToGain(db: number): number { + return 10 ** (db / 20); +} +export function normalizeRms( + samples: Float32Array, + target = 0.12, +): Float32Array { + const rms = Math.sqrt( + samples.reduce((sum, n) => sum + n * n, 0) / samples.length, + ); + if (!Number.isFinite(rms) || rms < 1e-8) + throw new Error("무음 오디오는 재생할 수 없습니다."); + return Float32Array.from(samples, (sample) => (sample * target) / rms); +} +export function synthesizeBackground( + preset: Environment, + rate: number, + seconds: number, +): Float32Array { + let seed = preset.seed; + let low = 0; + const samples = new Float32Array(Math.floor(rate * seconds)); + for (let i = 0; i < samples.length; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + const white = seed / 2147483648 - 1; + // 저역 통과 잡음은 웅성임, 백색 잡음은 마찰·빗소리, 느린 진폭 변조는 + // 왕래와 진동을 표현한다. 실제 장소의 녹음이나 사람의 음성은 사용하지 않는다. + low = 0.985 * low + 0.015 * white; + const t = i / rate; + const movement = 0.65 + 0.35 * Math.sin(2 * Math.PI * preset.pulse * t); + const hum = Math.sin(2 * Math.PI * preset.tone * t) * 0.05; + const fade = Math.min( + 1, + i / (rate * 0.025), + (samples.length - 1 - i) / (rate * 0.025), + ); + samples[i] = + fade === 0 + ? 0 + : (low * preset.rumble * 5 + white * preset.hiss * 0.28 + hum) * + movement * + fade; + } + return normalizeRms(samples); +} diff --git a/src/audio/engine.ts b/src/audio/engine.ts new file mode 100644 index 0000000..ff9a7f5 --- /dev/null +++ b/src/audio/engine.ts @@ -0,0 +1,216 @@ +import type { Question } from "../game"; +import { dbToGain, normalizeRms, synthesizeBackground } from "./dsp"; + +// 인터페이스를 주입하면 화면 테스트가 실제 스피커를 켜지 않아도 재생 완료와 +// 실패 시점을 제어할 수 있다. 실제 엔진은 사용자의 클릭에서만 생성된다. +export interface AudioPort { + play( + question: Question, + intro: boolean, + onStarted: () => void, + ): Promise; + stop(): void; + dispose(): void; + setVolume(value: number): void; +} +export class AudioEngine implements AudioPort { + private context?: AudioContext; + private master?: GainNode; + private volume = 0.35; + private active?: { + controller: AbortController; + nodes: AudioNode[]; + sources: AudioBufferSourceNode[]; + timer?: ReturnType; + finish: (error?: unknown) => void; + }; + + constructor( + private factory: () => AudioContext = () => { + if (!globalThis.AudioContext) + throw new Error("이 브라우저는 Web Audio를 지원하지 않습니다."); + return new AudioContext(); + }, + private fetcher: typeof fetch = (...args) => fetch(...args), + ) {} + + play( + question: Question, + intro: boolean, + onStarted: () => void, + ): Promise { + if (this.active) return Promise.reject(new Error("이미 재생 중입니다.")); + return new Promise((resolve, reject) => { + const task: NonNullable = { + controller: new AbortController(), + nodes: [], + sources: [], + finish: (error) => { + if (this.active !== task) return; + this.active = undefined; + clearTimeout(task.timer); + task.controller.abort(); + // onended를 먼저 떼어 정리 중 stop()이 완료 이벤트를 중복 전달하지 + // 않게 한다. 이미 종료된 소스의 stop 예외는 자원 정리를 막지 않는다. + task.sources.forEach((source) => { + source.onended = null; + try { + source.stop(); + } catch { + /* 이미 종료된 소스는 안전하게 무시한다. */ + } + }); + task.nodes.forEach((node) => node.disconnect()); + if (error) reject(error); + else resolve(); + }, + }; + this.active = task; + task.timer = setTimeout( + () => + task.finish( + new Error("오디오 준비 시간이 초과되었습니다. 다시 시도해 주세요."), + ), + 15000, + ); + try { + // resume은 fetch보다 먼저, 사용자 이벤트의 동기 호출 경로에서 실행한다. + // 장치 생성도 지연하여 시작 화면을 열기만 했을 때 소리가 나지 않는다. + this.context ??= this.factory(); + const context = this.context; + const resumed = context.resume(); + void (async () => { + await resumed; + if (this.active !== task) return; + if (context.state !== "running") + throw new Error("오디오 장치를 시작하지 못했습니다."); + this.master ??= context.createGain(); + this.master.gain.value = this.volume; + this.master.disconnect(); + this.master.connect(context.destination); + const load = async (url: string) => { + const response = await this.fetcher(url, { + signal: task.controller.signal, + }); + if (!response.ok) + throw new Error("음성 파일을 불러오지 못했습니다."); + const decoded = await context.decodeAudioData( + await response.arrayBuffer(), + ); + const buffer = context.createBuffer( + 1, + decoded.length, + decoded.sampleRate, + ); + // 두 경로 모두 RMS .03으로 맞추어 최대 음량에서도 혼합 여유를 둔다. + // 고정 음량 파일을 다시 듣는 동안에는 외부 합성 API를 부르지 않는다. + buffer.copyToChannel( + normalizeRms(decoded.getChannelData(0), 0.03), + 0, + ); + return buffer; + }; + const [introBuffer, voiceBuffer] = await Promise.all([ + intro ? load("/audio/intro.wav") : Promise.resolve(null), + load(question.audioUrl), + ]); + // fetch를 무시하는 환경에서도 이 토큰 검사로 취소된 세션의 부활을 막는다. + if (this.active !== task) return; + const start = context.currentTime + 0.05; + const voiceStart = + start + (introBuffer ? introBuffer.duration + 0.35 : 0.2); + const end = voiceStart + voiceBuffer.duration; + const background = context.createBuffer( + 1, + Math.ceil((end - start + 0.2) * context.sampleRate), + context.sampleRate, + ); + background.copyToChannel( + normalizeRms( + synthesizeBackground( + question.environment, + context.sampleRate, + background.duration, + ), + 0.03, + ), + 0, + ); + const gain = (value: number) => { + const node = context.createGain(); + node.gain.setValueAtTime(value, start); + node.connect(this.master!); + task.nodes.push(node); + return node; + }; + const backgroundGain = gain(1); + const voiceGain = gain(dbToGain(question.db)); + const source = ( + buffer: AudioBuffer, + destination: AudioNode, + at: number, + ) => { + const node = context.createBufferSource(); + node.buffer = buffer; + node.connect(destination); + task.sources.push(node); + task.nodes.push(node); + node.start(at); + return node; + }; + backgroundGain.gain.setValueAtTime(0, start); + backgroundGain.gain.linearRampToValueAtTime(1, start + 0.15); + backgroundGain.gain.setValueAtTime(1, end - 0.06); + backgroundGain.gain.linearRampToValueAtTime(0, end); + source(background, backgroundGain, start); + if (introBuffer) { + const introGain = gain(1.8); + introGain.gain.linearRampToValueAtTime( + 0.12, + start + introBuffer.duration, + ); + source(introBuffer, introGain, start); + } + const target = source(voiceBuffer, voiceGain, voiceStart); + target.onended = () => task.finish(); + clearTimeout(task.timer); + // 탭 중단이나 장치 전환으로 ended가 오지 않는 경우도 무한 대기하지 않는다. + task.timer = setTimeout( + () => + task.finish( + new Error("재생이 중단되었습니다. 다시 들어 주세요."), + ), + (end - context.currentTime + 5) * 1000, + ); + onStarted(); + })().catch(task.finish); + } catch (error) { + task.finish(error); + } + }); + } + stop(): void { + this.active?.finish(new DOMException("재생을 취소했습니다.", "AbortError")); + } + dispose(): void { + this.stop(); + this.master?.disconnect(); + this.master = undefined; + if (this.context) + void this.context.close().catch(() => { + /* 닫힌 장치의 재정리는 사용자 오류가 아니다. */ + }); + this.context = undefined; + } + setVolume(value: number): void { + this.volume = Number.isFinite(value) + ? Math.min(1, Math.max(0, value)) + : 0.35; + if (this.master && this.context) + this.master.gain.setTargetAtTime( + this.volume, + this.context.currentTime, + 0.02, + ); + } +} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..e129499 --- /dev/null +++ b/src/content.ts @@ -0,0 +1,324 @@ +// 데이터는 화면·채점·음성 생성기가 공유하는 계약이다. 오답은 기계적으로 +// 만들어낸 비단어 대신 검토한 표제어만 저장한다. 변형의 의미는 WORDS.md에 설명한다. +export type ChangeRule = "초성 교체" | "모음 교체" | "받침 교체" | "음절 교체"; +// 변형 규칙의 편집상 혼동 난이도는 음량 난이도와 독립된 메타데이터다. +// 아직 검증하지 않은 경험적 임상 척도로 오해하지 않도록 별도 이름을 쓴다. +export const RULE_DIFFICULTY: Record = { + "음절 교체": 1, + "초성 교체": 2, + "모음 교체": 3, + "받침 교체": 4, +}; +export interface WordEntry { + id: string; + answer: string; + distractors: { word: string; rule: ChangeRule }[]; +} +// 제목이나 표현만 다른 패턴을 늘리지 않도록 필요한 청취 연산을 명시한다. +export type ListeningMechanic = + | "direct-target" + | "distractor-password" + | "ignore-then-remember" + | "first" + | "last" + | "middle" + | "repetition" + | "correction" + | "conditional-cue" + | "spatial-right-cue"; +export interface Pattern { + id: string; + mechanic: ListeningMechanic; + title: string; + sentence: string; + question: string; +} +export interface Environment { + id: string; + name: string; + description: string; + seed: number; + rumble: number; + hiss: number; + pulse: number; + tone: number; +} +export const WORDS: WordEntry[] = [ + { + id: "apple", + answer: "사과", + distractors: [ + { word: "다과", rule: "초성 교체" }, + { word: "사고", rule: "모음 교체" }, + { word: "사자", rule: "음절 교체" }, + ], + }, + { + id: "sea", + answer: "바다", + distractors: [ + { word: "사다", rule: "초성 교체" }, + { word: "보다", rule: "모음 교체" }, + { word: "받다", rule: "받침 교체" }, + ], + }, + { + id: "tree", + answer: "나무", + distractors: [ + { word: "나루", rule: "초성 교체" }, + { word: "너무", rule: "모음 교체" }, + { word: "나물", rule: "받침 교체" }, + ], + }, + { + id: "hat", + answer: "모자", + distractors: [ + { word: "포자", rule: "초성 교체" }, + { word: "모조", rule: "모음 교체" }, + { word: "목자", rule: "받침 교체" }, + ], + }, + { + id: "umbrella", + answer: "우산", + distractors: [ + { word: "부산", rule: "초성 교체" }, + { word: "오산", rule: "모음 교체" }, + { word: "우상", rule: "받침 교체" }, + ], + }, + { + id: "market", + answer: "시장", + distractors: [ + { word: "지장", rule: "초성 교체" }, + { word: "사장", rule: "모음 교체" }, + { word: "신장", rule: "받침 교체" }, + ], + }, + { + id: "oil", + answer: "기름", + distractors: [ + { word: "이름", rule: "초성 교체" }, + { word: "거름", rule: "모음 교체" }, + { word: "기틀", rule: "음절 교체" }, + ], + }, + { + id: "sound", + answer: "소리", + distractors: [ + { word: "오리", rule: "초성 교체" }, + { word: "수리", rule: "모음 교체" }, + { word: "소금", rule: "음절 교체" }, + ], + }, + { + id: "meat", + answer: "고기", + distractors: [ + { word: "보기", rule: "초성 교체" }, + { word: "고개", rule: "모음 교체" }, + { word: "공기", rule: "받침 교체" }, + ], + }, + { + id: "furniture", + answer: "가구", + distractors: [ + { word: "하구", rule: "초성 교체" }, + { word: "가교", rule: "모음 교체" }, + { word: "강구", rule: "받침 교체" }, + ], + }, +]; + +// 질문 의도가 서로 다르므로 같은 표제어라도 위치·반복·정정 등을 구별해서 +// 들어야 한다. 문장과 질문을 함께 저장하여 오디오와 UI의 의미가 어긋나지 않는다. +export const PATTERNS: Pattern[] = [ + { + id: "luck", + mechanic: "direct-target", + title: "행운의 단어", + sentence: "오늘의 행운을 담은 단어는 ‘{word}’입니다.", + question: "오늘의 행운을 담은 단어는 무엇인가요?", + }, + { + id: "password", + mechanic: "distractor-password", + title: "비밀 암호", + sentence: "연습 암호는 ‘{distractor1}’, 진짜 암호는 ‘{word}’입니다.", + question: "진짜 암호는 무엇인가요?", + }, + { + id: "remember", + mechanic: "ignore-then-remember", + title: "기억의 조각", + sentence: + "‘{distractor1}’. 이 단어는 잊으세요. 이제 ‘{word}’, 이 단어를 기억하세요.", + question: "앞의 단어를 잊고 기억할 단어는 무엇인가요?", + }, + { + id: "last", + mechanic: "last", + title: "마지막 단어", + sentence: + "세 단어 중 마지막을 고르세요. ‘{distractor1}’, ‘{distractor2}’, ‘{word}’.", + question: "세 단어 중 마지막 단어는 무엇인가요?", + }, + { + id: "first", + mechanic: "first", + title: "첫 번째 단어", + sentence: + "세 단어 중 첫 번째를 고르세요. ‘{word}’, ‘{distractor1}’, ‘{distractor2}’.", + question: "세 단어 중 첫 번째 단어는 무엇인가요?", + }, + { + id: "repeat", + mechanic: "repetition", + title: "두 번의 힌트", + sentence: + "두 번 들린 단어를 고르세요. ‘{word}’, ‘{distractor1}’, ‘{word}’, ‘{distractor2}’.", + question: "두 번 반복된 단어는 무엇인가요?", + }, + { + id: "correction", + mechanic: "correction", + title: "바로잡은 말", + sentence: + "정답은 ‘{distractor1}’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘{word}’입니다.", + question: "정정해서 알려준 단어는 무엇인가요?", + }, + { + id: "middle", + mechanic: "middle", + title: "가운데 단어", + sentence: + "세 단어 중 가운데를 고르세요. ‘{distractor1}’, ‘{word}’, ‘{distractor2}’.", + question: "세 단어 중 가운데 단어는 무엇인가요?", + }, + { + id: "message", + mechanic: "conditional-cue", + title: "신호의 조건", + sentence: + "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘{distractor1}’, ‘{word}’. 지금은 빨간 불입니다.", + question: "지금 신호에 맞는 단어는 무엇인가요?", + }, + { + id: "mission", + mechanic: "spatial-right-cue", + title: "오른쪽 단서", + sentence: "오른쪽 표지는 ‘{word}’, 왼쪽 표지는 ‘{distractor1}’입니다.", + question: "오른쪽 표지의 단어는 무엇인가요?", + }, +]; + +// 정답뿐 아니라 실제 보기의 오답도 들려주어 순서와 의미를 구별하게 한다. +// UI와 오프라인 생성기가 이 함수를 함께 사용하여 WAV 원문이 어긋나지 않는다. +// 따옴표 안에 표제어를 넣으므로 동사·부사 오답도 활용형으로 바꾸지 않는다. +export function renderSentence(pattern: Pattern, word: WordEntry): string { + return pattern.sentence + .replaceAll("{word}", word.answer) + .replaceAll("{distractor1}", word.distractors[0]!.word) + .replaceAll("{distractor2}", word.distractors[1]!.word); +} + +// 녹음 파일을 흉내 내는 외부 자산 대신 잡음·저음·맥박·음색을 조합한다. +// seed를 고정하여 같은 환경을 다시 들을 때 배경 자체가 바뀌지 않도록 한다. +export const ENVIRONMENTS: Environment[] = [ + { + id: "street", + name: "길거리", + description: "지나가는 차와 도시의 웅성임", + seed: 11, + rumble: 0.8, + hiss: 0.2, + pulse: 0.3, + tone: 65, + }, + { + id: "mall", + name: "쇼핑몰", + description: "넓은 실내의 겹쳐지는 소리", + seed: 22, + rumble: 0.4, + hiss: 0.4, + pulse: 0.7, + tone: 210, + }, + { + id: "theater", + name: "극장", + description: "어두운 공간의 낮은 울림", + seed: 33, + rumble: 1, + hiss: 0.1, + pulse: 0.2, + tone: 48, + }, + { + id: "department", + name: "백화점", + description: "잔잔한 실내음과 안내 신호", + seed: 44, + rumble: 0.3, + hiss: 0.3, + pulse: 0.4, + tone: 330, + }, + { + id: "market", + name: "시장", + description: "빠르게 오가는 분주한 소리", + seed: 55, + rumble: 0.5, + hiss: 0.5, + pulse: 1.1, + tone: 170, + }, + { + id: "subway", + name: "지하철", + description: "선로를 따라 이어지는 진동", + seed: 66, + rumble: 1, + hiss: 0.4, + pulse: 2.4, + tone: 90, + }, + { + id: "cafe", + name: "카페", + description: "부드러운 웅성임과 잔의 울림", + seed: 77, + rumble: 0.3, + hiss: 0.2, + pulse: 0.6, + tone: 420, + }, + { + id: "rain", + name: "비 오는 거리", + description: "고르게 쏟아지는 빗소리", + seed: 88, + rumble: 0.2, + hiss: 1, + pulse: 0.1, + tone: 120, + }, +]; +// 값은 음압(dB SPL)이 아닌 디지털 RMS 기준 음성/배경의 상대 비율이다. +export const LEVELS = [ + { id: 1, name: "소리에 익숙해지기", db: 6 }, + { id: 2, name: "귀 기울이기", db: 3 }, + { id: 3, name: "소음 사이로", db: 0 }, + { id: 4, name: "작은 단서 찾기", db: -3 }, + { id: 5, name: "깊이 집중하기", db: -6 }, +]; +export const INTRO = + "으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라."; diff --git a/src/game.ts b/src/game.ts new file mode 100644 index 0000000..ae00afe --- /dev/null +++ b/src/game.ts @@ -0,0 +1,171 @@ +import type { Environment, Pattern, WordEntry } from "./content"; +import { + ENVIRONMENTS, + LEVELS, + PATTERNS, + WORDS, + renderSentence, +} from "./content"; + +// 화면과 오디오의 비동기 수명은 reducer의 명시적 단계로 합의한다. +// 단계에 맞지 않는 이벤트는 무시하여 빠른 더블 클릭이 중복 채점되지 않는다. +export type Mode = "mixed" | "choice" | "text"; +export interface Question { + id: string; + pattern: Pattern; + word: WordEntry; + sentence: string; + options: string[]; + mode: "choice" | "text"; + level: number; + db: number; + environment: Environment; + audioUrl: string; +} +export interface Answer { + questionId: string; + level: number; + correct: boolean; + input: string; + points: number; +} +export interface GameState { + questions: Question[]; + index: number; + answers: Answer[]; + streak: number; + bestStreak: number; + phase: "ready" | "loading" | "playing" | "answer" | "feedback" | "results"; + error: string | null; +} +export type Action = + | { type: "PLAY" | "STARTED" | "ENDED" | "NEXT" } + | { type: "ERROR"; message: string } + | { type: "ANSWER"; input: string }; +export function createQuestions( + seed: number, + mode: Mode, + environment = "rotate", +): Question[] { + // 작은 정수 PRNG와 Fisher–Yates를 사용하면 편향된 sort(random)을 피하면서 + // 테스트나 오류 재현에 같은 seed를 재사용할 수 있다. 암호학 용도는 아니다. + let cursor = seed >>> 0; + const random = () => { + cursor = (Math.imul(cursor, 1664525) + 1013904223) >>> 0; + return cursor / 4294967296; + }; + const shuffle = (values: readonly T[]): T[] => { + const result = [...values]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j]!, result[i]!]; + } + return result; + }; + const words = shuffle(WORDS); + return shuffle(PATTERNS).map((pattern, index) => { + const word = words[index % words.length]!; + const level = LEVELS[Math.floor(index / 2)]!; + return { + id: `${pattern.id}-${word.id}`, + pattern, + word, + sentence: renderSentence(pattern, word), + options: shuffle([word.answer, ...word.distractors.map((d) => d.word)]), + mode: mode === "mixed" ? (index % 2 ? "text" : "choice") : mode, + level: level.id, + db: level.db, + environment: + ENVIRONMENTS.find((e) => e.id === environment) ?? + ENVIRONMENTS[index % ENVIRONMENTS.length]!, + audioUrl: `/audio/${pattern.id}-${word.id}.wav`, + }; + }); +} + +// 자유 입력을 과하게 추측하면 부분 일치가 정답이 되므로 표기 차이만 흡수한다. +// 한글 분해형 입력을 NFC로 합치고 공백·문장부호만 지운다. 단어의 내용은 유지한다. +export function normalizeAnswer(value: string): string { + return value.normalize("NFC").replace(/[\s\p{P}]/gu, ""); +} +export function isCorrect(input: string, answer: string): boolean { + const normalized = normalizeAnswer(input); + return normalized.length > 0 && normalized === normalizeAnswer(answer); +} +export function initialGame(questions: Question[]): GameState { + return { + questions, + index: 0, + answers: [], + streak: 0, + bestStreak: 0, + phase: "ready", + error: null, + }; +} +export function gameReducer(state: GameState, action: Action): GameState { + switch (action.type) { + case "PLAY": + return state.phase === "ready" || state.phase === "answer" + ? { ...state, phase: "loading", error: null } + : state; + case "STARTED": + return state.phase === "loading" ? { ...state, phase: "playing" } : state; + case "ENDED": + return state.phase === "playing" ? { ...state, phase: "answer" } : state; + case "ERROR": + return state.phase === "loading" || state.phase === "playing" + ? { ...state, phase: "ready", error: action.message } + : state; + case "ANSWER": { + if (state.phase !== "answer" || !normalizeAnswer(action.input)) + return state; + const question = state.questions[state.index]!; + const correct = isCorrect(action.input, question.word.answer); + const streak = correct ? state.streak + 1 : 0; + // 난이도 점수에 제한된 연속 보너스를 더한다. 재청취 횟수는 감점하지 + // 않아 접근성·학습 목적의 반복 청취가 불이익이 되지 않도록 한다. + const points = correct + ? question.level * 100 + Math.min(streak - 1, 4) * 10 + : 0; + const answer = { + questionId: question.id, + level: question.level, + correct, + input: action.input, + points, + }; + return { + ...state, + phase: "feedback", + streak, + bestStreak: Math.max(streak, state.bestStreak), + answers: [...state.answers, answer], + }; + } + case "NEXT": + if (state.phase !== "feedback") return state; + return state.index === state.questions.length - 1 + ? { ...state, phase: "results" } + : { ...state, index: state.index + 1, phase: "ready", error: null }; + } +} + +// 빈 세션의 비율을 0으로 정의하여 NaN이 UI에 노출되지 않게 한다. 단계별 +// 분모는 실제로 응답한 문항 수이므로 중간 상태에서도 집계 의미가 일정하다. +export function summarize(state: GameState) { + const correct = state.answers.filter((a) => a.correct).length; + return { + score: state.answers.reduce((sum, a) => sum + a.points, 0), + correct, + accuracy: state.answers.length + ? Math.round((correct / state.answers.length) * 100) + : 0, + byLevel: LEVELS.map((level) => ({ + level: level.id, + total: state.answers.filter((a) => a.level === level.id).length, + correct: state.answers.filter((a) => a.level === level.id && a.correct) + .length, + })), + }; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..b0fc11c --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +// 정적 SPA의 유일한 진입점이다. 개발 StrictMode에서 정리 함수를 재실행하여 +// 오디오 수명과 구독 누수를 조기에 드러내고 사용자 데이터는 서버로 보내지 않는다. +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..a6c7367 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,1212 @@ +/* 밝은 종이색과 진한 녹색을 바탕으로 소리 탐험의 차분한 성격을 표현한다. + 시스템 한글 글꼴만 사용하므로 외부 요청 없이 읽을 수 있으며, 터치 영역과 + 키보드 초점은 색상뿐 아니라 윤곽·크기로도 구별되게 한다. */ +:root { + font-family: + "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif; + color: #24392f; + background: #f8f9f4; + font-synthesis: none; + text-rendering: optimizeLegibility; + line-height: 1.6; + font-weight: 400; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +a, +input, +select { + -webkit-tap-highlight-color: transparent; +} +button, +select, +input[type="range"], +input[type="radio"] { + cursor: pointer; +} +button { + color: inherit; +} +button:disabled { + cursor: wait; + opacity: 0.6; +} +a { + color: inherit; + text-decoration: none; +} +:focus-visible { + outline: 3px solid #9b4b18; + outline-offset: 5px; +} +h1, +h2, +h3, +p { + margin: 0; +} +/* 한국어 제목은 좁은 화면에서도 어절 사이에서 줄바꿈해 질문을 읽기 쉽게 한다. */ +h1, +h2, +h3 { + word-break: keep-all; +} +h2, +h3 { + line-height: 1.35; + letter-spacing: -0.045em; +} +h2[tabindex]:focus { + outline-offset: 8px; +} +.wrap { + max-width: 1160px; + margin: 0 auto; + padding-inline: 32px; +} +.header { + height: 108px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e0e4db; +} +.brand { + display: flex; + align-items: center; + gap: 12px; +} +.brand h1 { + font-size: 22px; + letter-spacing: -0.07em; +} +.brand-mark { + background: #244b3c; + width: 37px; + height: 37px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + transform: rotate(-6deg); +} +.brand-mark i { + width: 3px; + height: 11px; + background: #d8edb7; + border-radius: 4px; +} +.brand-mark i:nth-child(2) { + height: 21px; +} +.brand-mark i:nth-child(3) { + height: 16px; +} +.version { + font-size: 11px; + background: #e8ede2; + border: 1px solid #d7dfce; + border-radius: 6px; + padding: 1px 6px; + font-weight: 600; +} +.header-actions { + display: flex; + align-items: center; + gap: 18px; + font-size: 12px; + color: #5c685e; +} +.header-actions a { + font-weight: 600; +} +.little-dot, +.eyebrow > span { + display: inline-block; + width: 6px; + height: 6px; + background: #537b51; + border-radius: 50%; +} +.skip-link { + position: fixed; + top: -100px; + left: 15px; + padding: 12px; + background: white; + z-index: 10; +} +.skip-link:focus { + top: 12px; +} + +/* 첫 화면은 설명과 캐릭터를 서로 다른 열에 둔다. 작은 화면에서는 문서 + 순서대로 쌓아 시각적 읽기 순서와 스크린리더의 탐색 순서를 일치시킨다. */ +.hero { + padding: 64px 0 48px; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 45px; + align-items: center; +} +.eyebrow { + font-size: 12px; + font-weight: 650; + letter-spacing: 0.035em; + color: #44664c; +} +.eyebrow > span { + margin-right: 7px; +} +.hero h2 { + font-size: clamp(36px, 4vw, 49px); + line-height: 1.4; + margin: 22px 0 23px; + font-weight: 750; + letter-spacing: -0.065em; +} +.hero em { + color: #4c7750; + font-style: normal; + position: relative; +} +.hero em::after { + content: ""; + position: absolute; + height: 9px; + background: #d9e6bc; + left: 0; + right: 0; + bottom: 2px; + z-index: -1; + border-radius: 50%; +} +.lead { + color: #687164; + font-size: 15px; + line-height: 1.9; +} +.hero-meta { + display: flex; + gap: 0; + margin-top: 28px; + font-size: 12px; + color: #626d60; +} +.hero-meta span + span::before { + content: "·"; + padding: 0 12px; + color: #718066; +} +.sound-room { + position: relative; + height: 340px; + background: #edf0e3; + border: 1px solid #e0e5d2; + border-radius: 24px; + overflow: hidden; + display: flex; + align-items: center; + flex-direction: column; +} +.room-top { + display: flex; + justify-content: space-between; + width: 100%; + padding: 22px 24px; + font-size: 11px; + color: #57664e; + z-index: 1; +} +.room-top i { + display: inline-block; + width: 5px; + height: 5px; + background: #61814b; + border-radius: 50%; + margin-right: 5px; +} +.orbit { + position: absolute; + border: 1px solid #dce3cb; + width: 320px; + height: 320px; + border-radius: 50%; + top: 67px; +} +.orbit-two { + width: 440px; + height: 440px; + top: 6px; +} +.speech-bubble { + background: #fffef8; + border: 1px solid #e0e5d4; + font-size: 12px; + font-weight: 600; + padding: 8px 15px; + border-radius: 12px 12px 12px 2px; + z-index: 1; + transform: rotate(-4deg); + margin: 0 0 13px 86px; +} +.floating-note { + position: absolute; + color: #8c7357; + font-size: 29px; +} +.note-one { + left: 18%; + top: 36%; + transform: rotate(-20deg); +} +.note-two { + right: 17%; + top: 43%; + color: #849763; +} +.mascot { + flex-shrink: 0; + width: 121px; + height: 131px; + background: #567454; + border-radius: 48% 48% 42% 40%; + position: relative; + transform: rotate(-7deg); + box-shadow: + inset -10px -5px 0 #496846, + 0 10px 0 -4px #d3dcc1; +} +.mascot .ear { + position: absolute; + width: 18px; + height: 40px; + background: #2b4436; + top: 40px; + border-radius: 9px; + border: 4px solid #dfcea8; +} +.mascot .ear.left { + left: -10px; +} +.mascot .ear.right { + right: -10px; +} +.mascot::before { + content: ""; + position: absolute; + border: 5px solid #dfcea8; + border-bottom: 0; + width: 107px; + height: 73px; + top: -5px; + left: 7px; + border-radius: 60px 60px 0 0; +} +.mascot .eye { + position: absolute; + width: 8px; + height: 13px; + top: 44px; + background: #f6f0d5; + border-radius: 50%; +} +.eye.left { + left: 39px; +} +.eye.right { + right: 30px; +} +.smile { + position: absolute; + width: 19px; + height: 10px; + top: 62px; + left: 56px; + border-bottom: 3px solid #f6f0d5; + border-radius: 50%; +} +.mascot b { + position: absolute; + font-size: 28px; + color: #dce8be; + top: 79px; + left: 54px; + font-weight: 800; +} +.room-bottom { + position: absolute; + bottom: 16px; + text-align: center; + font-size: 10px; + color: #5c6d52; +} +.sound-wave { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + height: 56px; +} +.sound-wave i { + display: block; + width: 3px; + border-radius: 8px; + background: #b8c99a; + max-height: 39px; +} +.sound-wave i:nth-child(3n) { + background: #81986b; +} +.sound-wave.active i { + animation: pulse 0.85s ease-in-out infinite alternate; +} +@keyframes pulse { + to { + transform: scaleY(0.3); + } +} + +/* 선택 상태는 라디오·테두리·배경으로 중복 표현한다. 네이티브 라디오는 + 방향키 조작을 그대로 지원하며 카드 전체가 터치 대상이 된다. */ +.setup-card { + background: #fffefa; + border: 1px solid #dde2d3; + border-radius: 20px; + padding: 28px 32px 0; + box-shadow: 0 4px 14px #233b2410; +} +.setup-heading { + display: flex; + align-items: center; + gap: 14px; +} +.section-number { + color: #526641; + background: #eef2e4; + padding: 5px 8px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; +} +.setup-heading h2 { + font-size: 20px; +} +.setup-heading p { + color: #72776a; + font-size: 12px; + margin-top: 4px; +} +.round-badge { + margin-left: auto; + border: 1px solid #e0e5d9; + border-radius: 20px; + font-size: 11px; + padding: 4px 10px; + color: #69755e; + white-space: nowrap; +} +.setup-grid { + display: grid; + grid-template-columns: 1.25fr 1fr; + gap: 36px; + padding: 28px 0; +} +.mode-field { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} +legend, +.environment-field > label { + font-size: 12px; + font-weight: 650; + display: block; + margin-bottom: 11px; +} +.mode-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 9px; +} +.mode-option { + position: relative; + display: flex; + flex-direction: column; + padding: 14px 13px; + border: 1px solid #e1e5d8; + border-radius: 11px; + cursor: pointer; + background: #fffefa; +} +.mode-option.selected { + background: #f0f4e9; + border-color: #6c8659; + box-shadow: 0 0 0 1px #6c8659; +} +.mode-option input { + position: absolute; + right: 10px; + top: 10px; + accent-color: #4c704a; +} +.mode-icon { + font-size: 24px; + line-height: 1; + margin-bottom: 14px; + color: #456448; +} +.mode-option strong { + font-size: 12px; +} +.mode-option small { + color: #5f6956; + font-size: 10px; + margin-top: 3px; +} +.environment-field { + border-left: 1px solid #e6e9df; + padding-left: 32px; +} +.environment-field select { + width: 100%; + min-height: 46px; + background: #fbfcf7; + border: 1px solid #dbe0d1; + border-radius: 8px; + padding: 9px 12px; + color: #384b36; + font-size: 13px; +} +.environment-field p { + color: #707968; + font-size: 11px; + margin-top: 11px; +} +.environment-chips { + display: flex; + gap: 6px; + margin-top: 10px; + flex-wrap: wrap; +} +.environment-chips span { + font-size: 10px; + background: #f1f3e9; + border-radius: 5px; + padding: 2px 8px; + color: #586b4a; +} +.start-row { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid #e8ebe1; + padding: 19px 0; +} +.start-row p { + font-size: 12px; + color: #6b755f; +} +.start-row p span { + font-size: 19px; + margin-right: 7px; +} +.primary, +.secondary { + border: 0; + min-height: 48px; + border-radius: 9px; + padding: 12px 24px; + font-size: 14px; + font-weight: 650; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 36px; +} +.primary { + background: #335e44; + color: #fffefa; +} +.primary:hover { + background: #254b34; +} +.secondary { + background: #edf2e5; + color: #365538; +} +.secondary:hover { + background: #e0e9d6; +} +.text-button { + background: transparent; + border: 0; + padding: 10px; + text-decoration: underline; + font-size: 12px; +} +.howto { + padding: 44px 0 20px; +} +.howto > h2 { + font-size: 17px; + margin-bottom: 23px; +} +.steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 25px; +} +.steps article { + position: relative; + padding-left: 39px; +} +.steps article > span { + position: absolute; + left: 0; + top: 1px; + font-size: 11px; + width: 26px; + height: 26px; + background: #eaf0e1; + color: #5c724c; + border-radius: 50%; + display: grid; + place-items: center; +} +.steps h3 { + font-size: 14px; + margin-bottom: 8px; +} +.steps p { + font-size: 12px; + line-height: 1.8; + color: #6c7567; +} +.volume-row { + display: flex; + gap: 13px; + align-items: center; + justify-content: center; + padding: 35px 0; + color: #5c6e51; + font-size: 12px; +} +.volume-row label span { + padding-right: 5px; +} +.volume-row input { + width: 120px; + accent-color: #52794a; + min-height: 30px; +} +.volume-row output { + width: 32px; + font-variant-numeric: tabular-nums; +} +/* 시작 전 설정 순서를 따르고 좁은 환경 패널에서도 슬라이더 폭을 확보한다. */ +.setup-card .volume-row { + justify-content: flex-start; + padding: 0 0 22px; +} +.environment-panel .volume-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + padding: 16px 0 0; + text-align: left; +} +.environment-panel .volume-row label { + grid-column: 1 / -1; +} +.environment-panel .volume-row input { + width: 100%; + min-width: 0; +} +.footer { + padding-block: 23px 30px; + border-top: 1px solid #e0e4da; + font-size: 10px; + color: #68755f; + display: flex; + flex-wrap: wrap; + column-gap: 20px; + row-gap: 6px; + justify-content: space-between; +} +.footer > p:first-child { + font-size: 11px; + font-weight: 600; +} +.footer > span { + width: 100%; + color: #6c7566; +} + +/* 게임은 듣기·답변·피드백을 같은 카드 위치에 유지해 전환 때 시선 이동을 + 줄인다. 정답 문장은 채점 뒤에만 DOM에 생기며 CSS로 단순히 숨기지 않는다. */ +.game-layout { + padding-top: 40px; + padding-bottom: 32px; +} +.game-top { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + font-size: 13px; +} +.game-top b { + font-size: 22px; + margin-left: 8px; +} +progress { + width: 100%; + height: 6px; + border: 0; + display: block; + border-radius: 10px; + accent-color: #658450; + background: #e5eadb; +} +progress::-webkit-progress-bar { + border-radius: 10px; + background: #e5eadb; +} +progress::-webkit-progress-value { + background: #658450; + border-radius: 10px; +} +.game-columns { + display: grid; + grid-template-columns: 310px 1fr; + gap: 24px; + margin-top: 24px; +} +.environment-panel { + border-radius: 18px; + background: #edf1e2; + text-align: center; + padding: 26px; + border: 1px solid #dce5ce; +} +.environment-panel h2 { + font-size: 28px; + margin: 10px 0; +} +.environment-panel > p:not(.eyebrow) { + font-size: 12px; + color: #586e4d; +} +.mascot.small { + transform: scale(0.7) rotate(-7deg); + margin: 5px auto -12px; +} +.environment-panel .sound-wave { + margin-top: -5px; +} +.level-tag { + background: #fffdf5; + border: 1px solid #dae3cd; + display: inline-block; + padding: 5px 14px; + border-radius: 20px; + font-size: 12px; + margin: 15px 0 10px; +} +.db-note { + font-size: 11px; +} +.mini-stats { + display: flex; + justify-content: space-around; + font-size: 11px; + padding-top: 20px; + border-top: 1px solid #d8e0cb; + margin-top: 23px; +} +.mini-stats b { + display: block; + font-size: 23px; +} +.panel { + background: #fffefa; + border: 1px solid #dde3d4; + border-radius: 18px; +} +.question-panel { + padding: 28px 36px; + display: flex; + flex-direction: column; + min-height: 490px; +} +.question-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #566646; + padding-bottom: 25px; +} +.question-meta span:last-child { + background: #edf1e4; + padding: 2px 9px; + border-radius: 5px; +} +.question-panel h2 { + font-size: 25px; + margin: 12px 0; +} +.muted { + color: #68745e; + font-size: 13px; +} +.answers { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 28px 0 24px; +} +.answers button { + min-height: 68px; + background: #fffef9; + border: 1px solid #ccd7bf; + border-radius: 10px; + text-align: left; + padding: 13px 18px; + font-size: 20px; + font-weight: 600; +} +.answers button span { + margin-right: 15px; + font-size: 12px; + font-weight: 400; + color: #647653; +} +.answers button:hover { + background: #eef3e6; + border-color: #6a8656; +} +.question-panel form { + margin: 25px 0; +} +.question-panel form label { + font-size: 12px; + display: block; + margin-bottom: 7px; +} +.question-panel input { + width: 100%; + padding: 14px; + min-height: 52px; + border: 1px solid #bfcdb4; + border-radius: 8px; + background: #fffefa; + margin-bottom: 16px; +} +.question-panel form button { + width: 100%; +} +.listening { + text-align: center; + padding: 32px 0 22px; +} +.listening-icon { + font-size: 43px; + display: inline-block; + color: #6d8954; +} +.listening > p { + font-size: 13px; + color: #6c7961; + margin-top: 15px; +} +.listening [role="status"] { + font-size: 11px; + color: #4a6e46; +} +.play-controls { + margin-top: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px 10px; + padding-top: 10px; +} +.play-controls small { + display: block; + width: 100%; + text-align: center; + color: #606c55; + font-size: 11px; + margin-top: 8px; +} +.feedback { + text-align: center; + padding-top: 12px; +} +.feedback-icon { + display: inline-grid; + place-items: center; + background: #e5eed9; + color: #456f35; + font-size: 29px; + width: 52px; + height: 52px; + border-radius: 50%; +} +.incorrect .feedback-icon { + background: #f7ebd9; + color: #996324; +} +.feedback blockquote { + margin: 20px 0; + padding: 20px 14px; + background: #f3f5ec; + border-radius: 10px; + font-size: 15px; +} +.feedback .primary { + margin-top: 24px; +} +.error-box { + border: 1px solid #cbb798; + border-radius: 10px; + background: #fff4e7; + padding: 17px; + font-size: 13px; + margin-top: 16px; +} +.error-box button { + background: #735329; + color: white; + padding: 10px 18px; + border: 0; + border-radius: 6px; + margin-top: 10px; +} +.results-panel { + max-width: 750px; + margin: 45px auto 0; + padding: 32px 48px; + text-align: center; +} +.result-mascot { + height: 116px; +} +.results-panel h2 { + font-size: 28px; + margin: 12px 0; +} +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 26px 0; + padding: 22px 0; + background: #f0f4e8; + border-radius: 12px; +} +.stats strong { + display: block; + font-size: 32px; + letter-spacing: -0.04em; +} +.stats small { + font-size: 15px; + padding-left: 3px; +} +.stats span { + font-size: 11px; + color: #526346; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin: 20px 0; +} +th, +td { + border-bottom: 1px solid #e0e6d7; + padding: 12px; +} +thead { + color: #5c6f4e; +} +.results-panel .primary { + margin-top: 25px; +} + +/* 360px에서도 수평 스크롤 없이 본문·버튼·선택지가 들어가게 한다. 장식은 + 축소하되 입력 영역은 줄이지 않고 최소 44px 높이를 유지한다. */ +@media (max-width: 850px) { + .header-actions { + font-size: 0; + gap: 0; + } + .header-actions .little-dot { + display: none; + } + .header-actions a, + .header-actions button { + font-size: 12px; + } + .hero { + gap: 24px; + } + .hero h2 { + font-size: 36px; + } + .sound-room { + height: 320px; + } + .setup-grid { + gap: 20px; + } + .environment-field { + padding-left: 20px; + } + .mode-option { + padding-inline: 9px; + } + .game-columns { + grid-template-columns: 240px 1fr; + } + .question-panel { + padding: 24px; + } + .answers { + gap: 8px; + } +} +@media (max-width: 620px) { + .wrap { + padding-inline: 20px; + } + .header { + height: 80px; + } + .brand { + gap: 8px; + } + .brand h1 { + font-size: 18px; + } + .brand-mark { + width: 30px; + height: 32px; + } + .header-actions a { + font-size: 11px; + } + .version { + font-size: 9px; + } + .hero { + grid-template-columns: 1fr; + padding: 35px 0 26px; + gap: 28px; + } + .hero h2 { + font-size: 37px; + margin-block: 16px; + } + .hero .eyebrow { + font-size: 11px; + } + .lead { + font-size: 13px; + } + .hero-meta { + margin-top: 19px; + font-size: 11px; + } + .sound-room { + height: 280px; + } + .room-top { + padding: 17px 20px; + } + .speech-bubble { + margin-bottom: 2px; + } + .sound-room > .mascot { + transform: scale(0.85) rotate(-7deg); + } + .room-bottom { + bottom: 10px; + } + .setup-card { + padding: 21px 18px 0; + border-radius: 15px; + } + .setup-heading { + gap: 10px; + align-items: flex-start; + } + .setup-heading h2 { + font-size: 17px; + } + .setup-heading p { + font-size: 11px; + } + .round-badge { + display: none; + } + .setup-grid { + grid-template-columns: 1fr; + padding-block: 22px; + gap: 23px; + } + .mode-options { + gap: 6px; + } + .mode-option strong { + font-size: 11px; + } + .mode-option small { + font-size: 9px; + } + .mode-option input { + right: 6px; + } + .environment-field { + padding-left: 0; + border: 0; + } + .start-row { + flex-direction: column; + gap: 14px; + padding-block: 17px; + } + .start-row p { + font-size: 11px; + } + .start-row .primary { + width: 100%; + } + .howto { + padding-top: 31px; + } + .steps { + grid-template-columns: 1fr; + gap: 23px; + } + .steps p br { + display: none; + } + .howto h2 { + font-size: 16px; + } + .footer { + padding-block: 21px; + } + .footer > p:nth-child(2) { + font-size: 9px; + } + .game-layout { + padding-top: 24px; + } + .game-columns { + grid-template-columns: 1fr; + gap: 16px; + } + .environment-panel { + padding: 16px; + position: relative; + text-align: left; + } + .environment-panel .mascot, + .environment-panel .sound-wave { + display: none; + } + .environment-panel .db-note { + margin-top: 10px; + } + .environment-panel h2 { + font-size: 22px; + margin-block: 6px; + } + .environment-panel > .eyebrow { + font-size: 10px; + } + .level-tag { + position: absolute; + right: 15px; + top: 9px; + margin: 0; + font-size: 10px; + } + .mini-stats { + margin-top: 13px; + padding-top: 10px; + justify-content: flex-start; + gap: 22px; + } + .mini-stats b { + display: inline; + padding-left: 8px; + font-size: 16px; + } + .question-panel { + padding: 22px 20px; + min-height: 420px; + } + .question-panel h2 { + font-size: 22px; + } + .question-meta { + padding-bottom: 15px; + } + .answers button { + font-size: 18px; + min-height: 64px; + padding-inline: 13px; + } + .answers button span { + margin-right: 10px; + } + .results-panel { + margin-top: 26px; + padding: 25px 17px; + } + .results-panel h2 { + font-size: 23px; + } + .stats strong { + font-size: 26px; + } + .stats span { + font-size: 10px; + } + .results-panel th, + .results-panel td { + padding: 10px; + } + .volume-row { + padding: 26px 0; + gap: 10px; + } +} +/* 사용자 OS의 동작 줄이기 설정을 존중한다. 진행 상태는 텍스트로도 전달하여 + 파형 애니메이션을 없애도 기능·정보가 사라지지 않도록 한다. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} diff --git a/tests/App.test.tsx b/tests/App.test.tsx new file mode 100644 index 0000000..d631e63 --- /dev/null +++ b/tests/App.test.tsx @@ -0,0 +1,336 @@ +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(), + ); + }); +}); diff --git a/tests/assets.test.ts b/tests/assets.test.ts new file mode 100644 index 0000000..2090a98 --- /dev/null +++ b/tests/assets.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { INTRO, PATTERNS, WORDS } from "../src/content"; + +// 생성 바이너리를 눈으로 검토할 수 없으므로 문장·해시·PCM 포맷·실제 신호를 +// 함께 확인한다. manifest는 생성기가 남기며 100개 조합과 안내를 빠짐없이 묶는다. +describe("저장한 한국어 음성 자산", () => { + it("실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다", () => { + const manifest = JSON.parse( + readFileSync("public/audio/manifest.json", "utf8"), + ) as { toolVersions?: { espeak: string; ffmpeg: string } }; + expect(manifest.toolVersions?.espeak).toMatch(/eSpeak NG.*\d+\.\d+/); + expect(manifest.toolVersions?.ffmpeg).toMatch(/ffmpeg version \d+\.\d+/); + }); + it("모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다", () => { + const manifest = JSON.parse( + readFileSync("public/audio/manifest.json", "utf8"), + ) as { + generator: string; + files: { file: string; text: string; sha256: string }[]; + }; + expect(manifest.generator).toContain("eSpeak NG"); + const expected = [ + { file: "intro.wav", text: INTRO }, + ...PATTERNS.flatMap((p) => + WORDS.map((w) => ({ + file: `${p.id}-${w.id}.wav`, + text: p.sentence + .replaceAll("{word}", w.answer) + .replaceAll("{distractor1}", w.distractors[0]!.word) + .replaceAll("{distractor2}", w.distractors[1]!.word), + })), + ), + ]; + expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual( + expected, + ); + expect( + readdirSync("public/audio").filter((f) => f.endsWith(".wav")), + ).toHaveLength(101); + let size = 0; + for (const entry of manifest.files) { + const file = readFileSync(`public/audio/${entry.file}`); + size += file.length; + expect(createHash("sha256").update(file).digest("hex")).toBe( + entry.sha256, + ); + expect(file.toString("ascii", 0, 4)).toBe("RIFF"); + expect(file.toString("ascii", 8, 12)).toBe("WAVE"); + expect(file.readUInt16LE(22)).toBe(1); + expect(file.readUInt32LE(24)).toBe(16000); + expect(file.readUInt16LE(34)).toBe(16); + expect(file.length).toBeGreaterThan(16000); + const values = Array.from({ length: (file.length - 44) / 2 }, (_, i) => + file.readInt16LE(44 + i * 2), + ); + const rms = Math.sqrt( + values.reduce((sum, x) => sum + x * x, 0) / values.length, + ); + expect(rms).toBeGreaterThan(100); + // RMS 정규화 후 최고 난이도 아닌 가장 큰 +6dB에서도 클리핑 여유를 확인한다. + expect( + (values.reduce((peak, x) => Math.max(peak, Math.abs(x)), 0) / rms) * + 0.03 * + 2, + ).toBeLessThan(0.8); + } + expect(size).toBeLessThan(20 * 1024 * 1024); + }); +}); diff --git a/tests/audio.test.ts b/tests/audio.test.ts new file mode 100644 index 0000000..16e3b27 --- /dev/null +++ b/tests/audio.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from "vitest"; +import { AudioEngine } from "../src/audio/engine"; +import { createQuestions } from "../src/game"; + +// 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리 +// 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다. +function fixture() { + const param = () => ({ + value: 1, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + setTargetAtTime: vi.fn(), + }); + const node = () => ({ connect: vi.fn(), disconnect: vi.fn() }); + const gains: ReturnType[] = []; + function makeGain(): ReturnType & { + gain: ReturnType; + } { + const gain = { ...node(), gain: param() }; + gains.push(gain); + return gain; + } + function buffer(length = 16000, rate = 16000) { + const data = Float32Array.from( + { length }, + (_, i) => 0.2 * Math.sin(i / 20), + ); + return { + duration: length / rate, + length, + sampleRate: rate, + numberOfChannels: 1, + getChannelData: () => data, + copyToChannel: vi.fn(), + }; + } + const sources: ReturnType[] = []; + function makeSource(): ReturnType & { + buffer: AudioBuffer | null; + loop: boolean; + start: ReturnType void>>; + stop: ReturnType; + onended: null | (() => void); + } { + const source = { + ...node(), + buffer: null, + loop: false, + start: vi.fn(), + stop: vi.fn(), + onended: null as null | (() => void), + }; + sources.push(source); + return source; + } + const context = { + currentTime: 2, + sampleRate: 16000, + state: "suspended", + destination: {}, + resume: vi.fn(async () => { + context.state = "running"; + }), + close: vi.fn(async () => {}), + createGain: makeGain, + createBufferSource: makeSource, + createBuffer: (_channels: number, length: number, rate: number) => + buffer(length, rate), + decodeAudioData: vi.fn(async () => buffer()), + }; + const fetcher = vi.fn( + async () => + ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) as Response, + ); + const factory = vi.fn(() => context as unknown as AudioContext); + return { + engine: new AudioEngine(factory, fetcher), + context, + sources, + gains, + fetcher, + factory, + }; +} +const question = createQuestions(42, "choice")[0]!; +const flush = async () => { + for (let i = 0; i < 12; i++) await Promise.resolve(); +}; + +describe("오디오 재생 수명과 믹서", () => { + it("생성 시 무음이며 gesture의 play에서만 장치를 연다", async () => { + const f = fixture(); + expect(f.factory).not.toHaveBeenCalled(); + const started = vi.fn(); + const done = f.engine.play(question, true, started); + expect(f.factory).toHaveBeenCalledOnce(); + expect(f.context.resume).toHaveBeenCalledOnce(); + await flush(); + expect(started).toHaveBeenCalledOnce(); + expect(f.sources).toHaveLength(3); + expect(f.fetcher.mock.calls.map((call) => call[0])).toEqual([ + "/audio/intro.wav", + question.audioUrl, + ]); + // master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다. + expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith( + 1, + expect.any(Number), + ); + expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith( + 10 ** (question.db / 20), + expect.any(Number), + ); + expect(f.gains[3]!.gain.linearRampToValueAtTime).toHaveBeenCalledWith( + 0.12, + expect.any(Number), + ); + expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan( + f.sources[1]!.start.mock.calls[0]![0]!, + ); + f.sources[2]!.onended?.(); + await done; + expect(f.sources.every((s) => s.disconnect.mock.calls.length > 0)).toBe( + true, + ); + }); + it("동시 시작을 거부하고 정지하면 모든 노드와 대기 Promise를 취소한다", async () => { + const f = fixture(); + const done = f.engine.play(question, false, vi.fn()); + const cancelled = expect(done).rejects.toMatchObject({ + name: "AbortError", + }); + await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( + "재생 중", + ); + await flush(); + f.engine.stop(); + await cancelled; + expect(f.sources.every((s) => s.stop.mock.calls.length > 0)).toBe(true); + expect(f.sources.every((s) => s.onended === null)).toBe(true); + f.engine.dispose(); + expect(f.context.close).toHaveBeenCalledOnce(); + }); + it("로드 도중 취소한 이전 작업이 늦게 완료되어도 소리를 시작하지 않는다", async () => { + const f = fixture(); + let release!: (value: Response) => void; + f.fetcher.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const done = f.engine.play(question, false, vi.fn()); + const cancelled = expect(done).rejects.toMatchObject({ + name: "AbortError", + }); + await flush(); + f.engine.stop(); + await cancelled; + release({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + await flush(); + expect(f.sources).toHaveLength(0); + }); + it("HTTP·디코드·장치 실패를 전달하고 다음 시도는 다시 로드한다", async () => { + const f = fixture(); + f.fetcher.mockResolvedValueOnce({ ok: false } as Response); + await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( + "음성 파일", + ); + f.context.decodeAudioData.mockRejectedValueOnce(new Error("decode")); + await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( + "decode", + ); + f.context.resume.mockRejectedValueOnce(new Error("권한")); + await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow( + "권한", + ); + const done = f.engine.play(question, false, vi.fn()); + await flush(); + f.sources.at(-1)!.onended?.(); + await done; + }); + it("전체 음량은 두 신호의 비율을 보존하며 0부터 1 범위로 제한한다", async () => { + const f = fixture(); + f.engine.setVolume(0.3); + const done = f.engine.play(question, false, vi.fn()); + await flush(); + expect(f.gains[0]!.gain.value).toBe(0.3); + f.engine.setVolume(8); + expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(1, 2, 0.02); + f.engine.setVolume(-1); + expect(f.gains[0]!.gain.setTargetAtTime).toHaveBeenCalledWith(0, 2, 0.02); + f.sources.at(-1)!.onended?.(); + await done; + }); +}); diff --git a/tests/browser/game.spec.ts b/tests/browser/game.spec.ts new file mode 100644 index 0000000..c6cb907 --- /dev/null +++ b/tests/browser/game.spec.ts @@ -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(); +}); diff --git a/tests/browser/release.spec.ts b/tests/browser/release.spec.ts new file mode 100644 index 0000000..fe49247 --- /dev/null +++ b/tests/browser/release.spec.ts @@ -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"), + ); +}); diff --git a/tests/browser/ui.spec.ts b/tests/browser/ui.spec.ts new file mode 100644 index 0000000..4c5c321 --- /dev/null +++ b/tests/browser/ui.spec.ts @@ -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); +}); diff --git a/tests/build.test.ts b/tests/build.test.ts new file mode 100644 index 0000000..d2d83e0 --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// 실제 배포는 하지 않는다. worktree 내부 임시 폴더에 public 자산만 복사한 뒤 +// 정상·누락·변조를 검사하여 빌드 검증기가 실패를 정말 감지하는지 먼저 확인한다. +describe("production 자산 검증기", () => { + it("101개 음성 포함을 확인하고 누락·변조는 실패시킨다", () => { + mkdirSync(".cache", { recursive: true }); + const temporary = mkdtempSync(".cache/build-test-"); + const verify = () => + execFileSync( + "node", + [ + "--input-type=module", + "-e", + `import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`, + ], + { encoding: "utf8", stdio: "pipe" }, + ); + try { + cpSync("public", temporary, { recursive: true }); + expect(verify().trim()).toBe("101"); + writeFileSync(join(temporary, "audio/intro.wav"), "broken"); + expect(verify).toThrow(); + rmSync(join(temporary, "audio/intro.wav")); + expect(verify).toThrow(); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + it.each(["누락", "축약"])( + "dist의 런타임 notice %s을 실패시킨다", + (damage) => { + mkdirSync(".cache", { recursive: true }); + const temporary = mkdtempSync(".cache/notice-test-"); + const verify = () => + execFileSync( + "node", + [ + "--input-type=module", + "-e", + `import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`, + ], + { encoding: "utf8", stdio: "pipe" }, + ); + try { + cpSync("public", temporary, { recursive: true }); + expect(verify().trim()).toBe("101"); + const notice = join(temporary, "THIRD_PARTY_NOTICES.md"); + if (damage === "누락") rmSync(notice, { force: true }); + else + writeFileSync(notice, "React / ReactDOM / scheduler — MIT License"); + expect(verify).toThrow(/notice/i); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/tests/content.test.ts b/tests/content.test.ts new file mode 100644 index 0000000..2064aa9 --- /dev/null +++ b/tests/content.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + ENVIRONMENTS, + LEVELS, + PATTERNS, + RULE_DIFFICULTY, + WORDS, +} from "../src/content"; + +// 개수만 맞추는 더미 데이터를 방지하기 위해 식별자·템플릿·변형 규칙까지 +// 검증한다. 사전 등재 여부의 출처와 의미는 별도의 단어 목록 문서로 검토한다. +describe("저장된 문제·환경 데이터", () => { + it("열 패턴은 서로 다른 청취 연산 mechanic을 하나씩 가진다", () => { + expect(PATTERNS).toHaveLength(10); + expect(new Set(PATTERNS.map((p) => p.mechanic))).toEqual( + new Set([ + "direct-target", + "distractor-password", + "ignore-then-remember", + "first", + "last", + "middle", + "repetition", + "correction", + "conditional-cue", + "spatial-right-cue", + ]), + ); + }); + // 문구가 서로 다르다는 사실만으로는 청취 과제가 달라지지 않는다. 각 문장에 + // 실제 경쟁 단어가 있고, 순서·반복 횟수·취소·조건·방향으로 답이 정해져야 한다. + it.each([ + { + id: "luck", + mechanic: "direct-target", + order: [0], + cues: [/행운/], + question: /행운/, + }, + { + id: "password", + mechanic: "distractor-password", + order: [1, 0], + cues: [/연습 암호/, /진짜 암호/], + question: /진짜 암호/, + }, + { + id: "remember", + mechanic: "ignore-then-remember", + order: [1, 0], + cues: [/잊으세요.*이제.*기억하세요/], + question: /기억/, + }, + { + id: "first", + mechanic: "first", + order: [0, 1, 2], + cues: [/세 단어.*첫/], + question: /첫/, + }, + { + id: "last", + mechanic: "last", + order: [1, 2, 0], + cues: [/세 단어.*마지막/], + question: /마지막/, + }, + { + id: "middle", + mechanic: "middle", + order: [1, 0, 2], + cues: [/세 단어.*가운데/], + question: /가운데/, + }, + { + id: "repeat", + mechanic: "repetition", + order: [0, 1, 0, 2], + cues: [/두 번/], + question: /두 번/, + }, + { + id: "correction", + mechanic: "correction", + order: [1, 0], + cues: [/아니.*잘못.*정정/], + question: /정정/, + }, + { + id: "message", + mechanic: "conditional-cue", + order: [1, 0], + cues: [/초록 불이면 앞 단어/, /빨간 불이면 뒤 단어/, /지금은 빨간 불/], + question: /신호/, + }, + { + id: "mission", + mechanic: "spatial-right-cue", + order: [0, 1], + cues: [/오른쪽 표지.*왼쪽 표지/], + question: /오른쪽/, + }, + ])( + "$mechanic의 열 문장은 해당 청취 의도로만 답을 고를 수 있다", + ({ id, mechanic, order, cues, question }) => { + const pattern = PATTERNS.find((p) => p.id === id)!; + expect.soft(pattern.mechanic).toBe(mechanic); + expect.soft(pattern.question).toMatch(question); + cues.forEach((cue) => expect.soft(pattern.sentence).toMatch(cue)); + for (const entry of WORDS) { + const choices = [entry.answer, ...entry.distractors.map((d) => d.word)]; + const sentence = pattern.sentence + .replaceAll("{word}", choices[0]!) + .replaceAll("{distractor1}", choices[1]!) + .replaceAll("{distractor2}", choices[2]!); + const heard = [...sentence.matchAll(/‘([^’]+)’/g)].map( + (match) => match[1], + ); + expect.soft(heard).toEqual(order.map((index) => choices[index])); + expect(sentence).not.toMatch(/[{}]/); + // 인용 단어 목록 안에서 반복 목표는 정확히 두 번, 나머지는 한 번이다. + expect + .soft(heard.filter((word) => word === entry.answer)) + .toHaveLength(mechanic === "repetition" ? 2 : 1); + } + }, + ); + it("오답마다 서로 다른 변형 혼동 난이도를 추적할 수 있다", () => { + WORDS.forEach((entry) => { + const ranks = entry.distractors.map((d) => RULE_DIFFICULTY[d.rule]); + ranks.forEach((rank) => expect(rank).toBeGreaterThan(0)); + expect(new Set(ranks).size).toBe(3); + }); + }); + it("서로 다른 질문 의도를 가진 열 가지 이상 문장 패턴을 제공한다", () => { + expect(PATTERNS.length).toBeGreaterThanOrEqual(10); + expect(new Set(PATTERNS.map((p) => p.id)).size).toBe(PATTERNS.length); + expect(new Set(PATTERNS.map((p) => p.question)).size).toBe(PATTERNS.length); + PATTERNS.forEach((p) => expect(p.sentence).toContain("{word}")); + }); + it("네 개의 고유 사전 단어와 서로 다른 오답 규칙을 제공한다", () => { + expect(WORDS.length).toBeGreaterThanOrEqual(10); + WORDS.forEach((entry) => { + expect(entry.distractors).toHaveLength(3); + expect( + new Set([entry.answer, ...entry.distractors.map((d) => d.word)]).size, + ).toBe(4); + expect(new Set(entry.distractors.map((d) => d.rule)).size).toBe(3); + // 한글 완성형을 초성·중성·종성으로 분해하여 설명과 실제 변형이 일치하는지 본다. + for (const { word, rule } of entry.distractors) { + const split = (s: string) => + [...s].map((c) => { + const n = c.charCodeAt(0) - 0xac00; + return [Math.floor(n / 588), Math.floor(n / 28) % 21, n % 28]; + }); + const before = split(entry.answer).flat(); + const after = split(word).flat(); + const changed = before.flatMap((v, i) => + v === after[i] ? [] : [i % 3], + ); + if (rule !== "음절 교체") { + expect(changed).toHaveLength(1); + expect(changed[0]).toBe( + ["초성 교체", "모음 교체", "받침 교체"].indexOf(rule), + ); + } + } + }); + }); + it("여덟 환경과 단조롭게 낮아지는 다섯 상대 dB를 제공한다", () => { + expect(ENVIRONMENTS.map((e) => e.name)).toEqual([ + "길거리", + "쇼핑몰", + "극장", + "백화점", + "시장", + "지하철", + "카페", + "비 오는 거리", + ]); + expect(new Set(ENVIRONMENTS.map((e) => e.seed)).size).toBe(8); + expect(LEVELS).toHaveLength(5); + LEVELS.forEach((l, i) => { + if (i) expect(l.db).toBeLessThan(LEVELS[i - 1]!.db); + }); + expect(PATTERNS.length * WORDS.length).toBeGreaterThan(10); + }); +}); diff --git a/tests/dsp.test.ts b/tests/dsp.test.ts new file mode 100644 index 0000000..f508338 --- /dev/null +++ b/tests/dsp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { ENVIRONMENTS, LEVELS } from "../src/content"; +import { dbToGain, normalizeRms, synthesizeBackground } from "../src/audio/dsp"; + +// 청감이나 실제 음압은 장치마다 다르므로 테스트 대상은 디지털 RMS와 gain +// 비율이다. 배경·음성을 같은 기준으로 정규화해야 표기한 상대 dB가 성립한다. +const rms = (a: Float32Array) => + Math.sqrt(a.reduce((sum, n) => sum + n * n, 0) / a.length); +describe("상대 dB와 결정론적 배경 합성", () => { + it("20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다", () => { + for (const level of LEVELS) + expect(20 * Math.log10(dbToGain(level.db))).toBeCloseTo(level.db, 10); + expect(dbToGain(-6)).toBeCloseTo(0.501187, 5); + }); + it("평균 전력을 맞추고 원본 배열을 변경하지 않으며 무음은 오류로 알린다", () => { + const input = new Float32Array([0.4, -0.2, 0.1, -0.3]); + const output = normalizeRms(input); + expect(rms(output)).toBeCloseTo(0.12, 6); + expect(output).not.toBe(input); + expect(() => normalizeRms(new Float32Array(4))).toThrow("무음"); + }); + it("여덟 환경이 서로 다르고 같은 seed는 같은 안전 범위 신호를 만든다", () => { + const signatures = new Set(); + for (const preset of ENVIRONMENTS) { + const signal = synthesizeBackground(preset, 16000, 1); + expect(signal).toHaveLength(16000); + expect(signal).toEqual(synthesizeBackground(preset, 16000, 1)); + expect(rms(signal)).toBeCloseTo(0.12, 5); + expect(Math.max(...signal.map(Math.abs))).toBeLessThan(1); + // 루프 경계에서 불연속 클릭이 생기지 않도록 양 끝을 0으로 감싼다. + expect(signal[0]).toBe(0); + expect(signal.at(-1)).toBe(0); + signatures.add([...signal.slice(0, 100)].join(",")); + } + expect(signatures.size).toBe(8); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts new file mode 100644 index 0000000..48214c4 --- /dev/null +++ b/tests/game.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + createQuestions, + gameReducer, + initialGame, + isCorrect, + normalizeAnswer, + summarize, +} from "../src/game"; + +// 고정 seed로 재현 가능성을 확보하되 seed를 바꾸면 10문제를 넘는 조합이 +// 실제로 선택되는지 본다. 오디오 URL도 같은 문장 조합을 가리켜야 한다. +describe("출제와 정답 판정", () => { + it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => { + for (let seed = 0; seed < 20; seed++) { + for (const q of createQuestions(seed, "choice")) { + expect(q.sentence).not.toMatch(/[{}]/); + expect(q.sentence).toContain(`‘${q.word.answer}’`); + if (q.pattern.id !== "luck") { + expect(q.sentence).toContain(`‘${q.word.distractors[0]!.word}’`); + } + const expected = q.pattern.sentence + .replaceAll("{word}", q.word.answer) + .replaceAll("{distractor1}", q.word.distractors[0]!.word) + .replaceAll("{distractor2}", q.word.distractors[1]!.word); + expect(q.sentence).toBe(expected); + } + } + }); + it("열 문제에 모든 유형·다섯 난이도·두 응답 방식을 섞는다", () => { + const questions = createQuestions(42, "mixed"); + expect(questions).toHaveLength(10); + expect(new Set(questions.map((q) => q.pattern.id)).size).toBe(10); + expect(new Set(questions.map((q) => q.environment.id)).size).toBe(8); + expect(questions.map((q) => q.level)).toEqual([ + 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, + ]); + expect(new Set(questions.map((q) => q.mode))).toEqual( + new Set(["choice", "text"]), + ); + questions.forEach((q) => { + expect(q.options).toHaveLength(4); + expect(new Set(q.options).size).toBe(4); + expect(q.options.filter((o) => o === q.word.answer)).toHaveLength(1); + expect(q.sentence).not.toContain("{word}"); + expect(q.audioUrl).toBe(`/audio/${q.pattern.id}-${q.word.id}.wav`); + }); + expect(createQuestions(42, "mixed")).toEqual(questions); + expect( + new Set([...questions, ...createQuestions(91, "mixed")].map((q) => q.id)) + .size, + ).toBeGreaterThan(10); + expect( + new Set(questions.map((q) => q.options.indexOf(q.word.answer))).size, + ).toBeGreaterThan(1); + }); + it("선택한 응답 방식과 환경을 모든 문제에 적용한다", () => { + expect( + createQuestions(3, "text", "cafe").every( + (q) => q.mode === "text" && q.environment.id === "cafe", + ), + ).toBe(true); + expect(createQuestions(3, "choice").every((q) => q.mode === "choice")).toBe( + true, + ); + }); + it("한글 정규형·공백·문장부호만 정리하고 부분 정답은 인정하지 않는다", () => { + expect(normalizeAnswer(" “사 과.” ")).toBe("사과"); + expect(isCorrect("사과".normalize("NFD"), "사과")).toBe(true); + expect(isCorrect("사과 또는 바다", "사과")).toBe(false); + expect(isCorrect("", "사과")).toBe(false); + expect(isCorrect("사고", "사과")).toBe(false); + }); +}); + +describe("게임 상태·점수·경합 방지", () => { + it("재생 완료 이전에는 답변·다음 문제를 허용하지 않으며 중복 채점을 막는다", () => { + let state = initialGame(createQuestions(42, "choice")); + expect( + gameReducer(state, { + type: "ANSWER", + input: state.questions[0]!.word.answer, + }), + ).toBe(state); + state = gameReducer(state, { type: "PLAY" }); + expect(state.phase).toBe("loading"); + expect(gameReducer(state, { type: "PLAY" })).toBe(state); + expect(gameReducer(state, { type: "NEXT" })).toBe(state); + state = gameReducer(state, { type: "STARTED" }); + expect(state.phase).toBe("playing"); + state = gameReducer(state, { type: "ENDED" }); + expect(state.phase).toBe("answer"); + state = gameReducer(state, { + type: "ANSWER", + input: state.questions[0]!.word.answer, + }); + expect(state.phase).toBe("feedback"); + expect(gameReducer(state, { type: "ANSWER", input: "오답" })).toBe(state); + expect(state.answers[0]!.points).toBe(100); + }); + it("오디오 오류 후 재시도와 다시 듣기는 점수를 변경하지 않는다", () => { + let state = initialGame(createQuestions(4, "mixed")); + state = gameReducer(state, { type: "PLAY" }); + state = gameReducer(state, { + type: "ERROR", + message: "음성 파일을 읽지 못했습니다.", + }); + expect(state.phase).toBe("ready"); + expect(state.error).toContain("음성"); + state = gameReducer(state, { type: "PLAY" }); + expect(state.error).toBeNull(); + state = gameReducer(gameReducer(state, { type: "STARTED" }), { + type: "ENDED", + }); + state = gameReducer(state, { type: "PLAY" }); + expect(state.phase).toBe("loading"); + expect(state.answers).toHaveLength(0); + }); + it("열 문제를 끝내면 정답률·연속 정답·단계별 결과를 집계한다", () => { + let state = initialGame(createQuestions(5, "choice")); + expect(summarize(state).accuracy).toBe(0); + for (let i = 0; i < 10; i++) { + state = gameReducer(state, { type: "PLAY" }); + state = gameReducer(state, { type: "STARTED" }); + state = gameReducer(state, { type: "ENDED" }); + state = gameReducer(state, { + type: "ANSWER", + input: i === 2 ? "오답" : state.questions[i]!.word.answer, + }); + state = gameReducer(state, { type: "NEXT" }); + } + expect(state.phase).toBe("results"); + expect(state.bestStreak).toBe(7); + expect(state.streak).toBe(7); + const result = summarize(state); + expect(result.accuracy).toBe(90); + expect(result.correct).toBe(9); + expect(result.score).toBe(2990); + expect(result.byLevel).toEqual( + [1, 2, 3, 4, 5].map((level) => ({ + level, + total: 2, + correct: level === 2 ? 1 : 2, + })), + ); + expect(gameReducer(state, { type: "PLAY" })).toBe(state); + }); +}); diff --git a/tests/playwright-config.test.ts b/tests/playwright-config.test.ts new file mode 100644 index 0000000..c18a7c3 --- /dev/null +++ b/tests/playwright-config.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +// args에서 플래그만 지워도 Playwright 기본값이 sandbox를 끌 수 있다. +// 실제 설정을 새 프로세스로 읽어 환경변수 캐시와 개발자 환경에 영향받지 않는다. +function launchOptions(optIn?: string) { + const env: NodeJS.ProcessEnv = { + ...process.env, + CHROME_PATH: "/usr/bin/google-chrome", + }; + delete env.PLAYWRIGHT_NO_SANDBOX; + if (optIn !== undefined) env.PLAYWRIGHT_NO_SANDBOX = optIn; + return JSON.parse( + execFileSync( + process.execPath, + [ + "--experimental-strip-types", + "--input-type=module", + "-e", + "import config from './playwright.config.ts'; console.log(JSON.stringify(config.use.launchOptions));", + ], + { encoding: "utf8", env }, + ), + ) as { + chromiumSandbox?: boolean; + args?: string[]; + executablePath?: string; + }; +} + +describe("브라우저 sandbox 기본 정책", () => { + it.each([undefined, "", "0", "false"])( + "opt-in=%s이면 sandbox를 켠다", + (value) => { + const options = launchOptions(value); + expect.soft(options.chromiumSandbox).toBe(true); + expect.soft(options.args ?? []).not.toContain("--no-sandbox"); + expect(options.executablePath).toBe("/usr/bin/google-chrome"); + }, + ); + it("명시적인 PLAYWRIGHT_NO_SANDBOX=1에서만 sandbox를 끈다", () => { + expect(launchOptions("1").chromiumSandbox).toBe(false); + }); +}); diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..3bc007f --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +import { readFileSync, readdirSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// 사용자 지정 문자열은 편집자가 회사 이름을 추측해 정규화하지 못하도록 +// 화면 테스트와 문서 계약에서 원문 그대로 고정한다. +describe("릴리스 문서 계약", () => { + it("Node 최소 버전은 잠긴 의존성이 요구하는 22.22.2와 일치한다", () => { + const metadata = JSON.parse(readFileSync("package.json", "utf8")); + const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + expect.soft(metadata.engines.node).toBe(">=22.22.2"); + expect.soft(lock.packages[""].engines.node).toBe(">=22.22.2"); + expect(lock.packages["node_modules/jsdom"].engines.node).toContain( + "^22.22.2", + ); + for (const path of ["README.md", "docs/DEPLOYMENT.md"]) { + const document = readFileSync(path, "utf8"); + expect.soft(document, path).toContain("22.22.2"); + expect.soft(document, path).not.toContain("22.12"); + } + }); + it("프로젝트는 비공개 권리 유보이며 저장소 주소를 명시한다", () => { + const metadata = JSON.parse(readFileSync("package.json", "utf8")); + const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); + expect.soft(metadata.private).toBe(true); + expect.soft(metadata.license).toBe("UNLICENSED"); + expect.soft(lock.packages[""].license).toBe("UNLICENSED"); + expect.soft(metadata.repository).toEqual({ + type: "git", + url: "https://source.ibit.kr/OZ-Group/Find-Word.git", + }); + expect + .soft(metadata.homepage) + .toBe("https://source.ibit.kr/OZ-Group/Find-Word"); + const license = readFileSync("LICENSE", "utf8"); + expect(license).toMatch(/private/i); + expect(license).toContain("All rights reserved."); + expect(readFileSync("README.md", "utf8")).toContain(metadata.homepage); + }); + it("잠금 파일의 모든 런타임 의존성 notice 전문을 배포용 사본에 보존한다", () => { + const lock = JSON.parse(readFileSync("package-lock.json", "utf8")) as { + packages: Record; + }; + const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8"); + expect(readFileSync("public/THIRD_PARTY_NOTICES.md", "utf8")).toBe(notice); + for (const [path, pkg] of Object.entries(lock.packages)) { + if (!path || pkg.dev) continue; + const name = path.split("node_modules/").at(-1)!; + const license = readFileSync(`${path}/LICENSE`, "utf8").trim(); + expect(notice).toContain(`## ${name} ${pkg.version}\n\n${license}`); + } + }); + it("브랜드 문구를 사용자 원문 그대로 문서에도 보존한다", () => { + const credit = "Designed by OZ at Heat-T Medical Inc."; + for (const path of [ + "README.md", + ".agent/TASK.md", + ".agent/ACCEPTANCE.md", + ".agent/CONTEXT.md", + ]) { + expect.soft(readFileSync(path, "utf8"), path).toContain(credit); + } + for (const path of [ + "README.md", + ...["docs", ".agent"].flatMap((dir) => + readdirSync(dir) + .filter((file) => file.endsWith(".md")) + .map((file) => `${dir}/${file}`), + ), + ]) { + // 잘못된 표기를 다시 권장하는 문서가 남아 있으면 다음 편집 때 회귀한다. + expect + .soft(readFileSync(path, "utf8"), path) + .not.toContain(["HearT", "M"].join("-")); + } + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..998cae8 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// 각 시나리오의 DOM과 이벤트 구독을 제거하여 이전 문제의 상태가 다음 +// 테스트에 누출되는 일을 막는다. 오디오 성공 여부는 테스트가 직접 제어한다. +afterEach(cleanup); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..576aec1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client", "vitest/globals", "node"] + }, + "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..57c9323 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +// 테스트는 DOM을 흉내 내되 오디오 장치를 열지 않는다. 실제 브라우저 검증은 +// 별도의 Playwright 경로에서 수행하여 순수 로직과 장치 의존 실패를 구분한다. +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.test.{ts,tsx}"], + restoreMocks: true, + }, +});