From b2447e899f293daa7e63ecae793953c17a29b51e Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:45:49 -0300 Subject: [PATCH] test: add frontend testing baseline Add a Vitest and React Testing Library baseline for the current frontend implementation. Configure jsdom setup, shared render helpers, frontend test scripts, and a combined check script for future CI use. Cover the current stable seams with unit and component tests: Zustand session state updates, class merging, timer controls and countdown behavior, top bar topic rendering, delegation map quorum summaries, and motion sorting. Keep the first suite intentionally unit/component focused. E2E should come later with Playwright, starting with mocked route smoke tests and then a small real-backend Docker Compose smoke path once frontend-backend wiring is less hardcoded. Make npm run check the expected CI frontend gate: lint, TypeScript/Vite build, then Vitest. Adjust narrow lint/type blockers so that gate passes without introducing coverage thresholds yet. Co-authored-by: Codex --- SYSTEM.md | 227 ++++ frontend/eslint.config.js | 4 + frontend/package-lock.json | 1091 ++++++++++++++++- frontend/package.json | 13 +- .../bottom-bar-buttons/SessionButton.tsx | 4 +- .../session/delegation-map.test.tsx | 24 + .../src/components/session/delegation-map.tsx | 2 +- .../components/session/motions-list.test.tsx | 50 + .../src/components/session/speaker-list.tsx | 15 +- .../src/components/session/timer.test.tsx | 69 ++ frontend/src/components/session/timer.tsx | 5 +- .../src/components/session/top-bar.test.tsx | 33 + frontend/src/lib/utils.test.ts | 13 + frontend/src/pages/CreateCommittee.tsx | 5 +- frontend/src/pages/Session.tsx | 8 +- frontend/src/store/useCommitteeStore.test.ts | 41 + frontend/src/test/render.tsx | 14 + frontend/src/test/session-state.ts | 41 + frontend/src/test/setup.ts | 8 + frontend/tsconfig.node.json | 2 +- frontend/vite.config.ts | 6 +- 21 files changed, 1647 insertions(+), 28 deletions(-) create mode 100644 SYSTEM.md create mode 100644 frontend/src/components/session/delegation-map.test.tsx create mode 100644 frontend/src/components/session/motions-list.test.tsx create mode 100644 frontend/src/components/session/timer.test.tsx create mode 100644 frontend/src/components/session/top-bar.test.tsx create mode 100644 frontend/src/lib/utils.test.ts create mode 100644 frontend/src/store/useCommitteeStore.test.ts create mode 100644 frontend/src/test/render.tsx create mode 100644 frontend/src/test/session-state.ts create mode 100644 frontend/src/test/setup.ts diff --git a/SYSTEM.md b/SYSTEM.md new file mode 100644 index 0000000..9e1db0c --- /dev/null +++ b/SYSTEM.md @@ -0,0 +1,227 @@ +# WebMUN System Summary + +WebMUN is a real-time Model United Nations committee management and simulation system. It is built as a FastAPI backend with a React, TypeScript, Vite, Zustand frontend. The current implementation focuses on live committee session state, WebSocket synchronization, chair/delegate event handling, timers, roll call, queues, motions, voting, and a chair-oriented session interface. + +## Repository Layout + +- `backend/`: FastAPI application, session domain models, event schemas, WebSocket manager, state-transition engine, service layer, and pytest tests. +- `frontend/`: React SPA using Vite, TypeScript, Zustand, React Router, Tailwind, shadcn-style UI components, and generated OpenAPI TypeScript types. +- `docker-compose.yml`: local two-service development setup for backend and frontend. +- `README.md`, `backend/README.md`, `frontend/README.md`: basic project and setup notes. + +## Backend + +The backend exposes a FastAPI app titled `WebMUN API` from `backend/app/main.py`. It configures CORS for the Vite dev server at `http://localhost:5173`, constructs one in-memory `ConnectionManager`, one `SessionEngine`, and one `SessionService`, then mounts the session router under `/committees`. + +Main backend modules: + +- `app/session/views.py`: HTTP and WebSocket adapter layer. +- `app/session/service.py`: orchestration layer between API/websocket input, actor resolution, session creation, engine dispatch, and broadcasting. +- `app/session/manager.py`: in-memory room state and WebSocket connection registry. +- `app/session/engine.py`: finite-state/session event engine. +- `app/session/models.py`: internal live-state models. +- `app/session/schemas.py`: Pydantic request/event schemas used by OpenAPI and frontend type generation. +- `app/session/enums.py`: session states, event names, motion types, debate types, and roll-call values. + +### API Surface + +Current routes under `/committees`: + +- `GET /committees/health`: healthcheck endpoint. +- `POST /committees/`: creates an in-memory session from `SessionCreationSchema`. +- `GET /committees/dummy`: OpenAPI schema exposure workaround for generated frontend types. +- `WS /committees/ws/{session_id}`: realtime session socket. + +The WebSocket endpoint requires query parameters: + +- `role`: `CHAIR` or `DELEGATE`. +- `delegation_id`: required for delegates. +- `display_name`: accepted, currently only meaningfully used for chair display naming. + +On socket connection, the backend resolves the actor and sends the current `SessionLiveState` snapshot if the session exists. On each received message, it validates the JSON as a discriminated `SessionEvent`, dispatches it through the engine, stores the returned state, and broadcasts the full state snapshot to all connected clients in that session. + +### Session State Model + +`SessionLiveState` is the central live state object. It currently includes: + +- `session_id`, `start_time`, and committee `delegations`. +- `current_state`, using the `States` enum. +- Timer fields: `timer_is_running`, `timer_expiration`, `timer_remaining_seconds`. +- Speaker fields: `current_speaker`, `gsl_queue`, `gsl_default_time_seconds`, `can_set_motion`. +- Caucus/debate fields: `caucus_list`, `debate`. +- Submitted procedural data: `submitted_motions`, `submitted_questions`. +- Agenda data: `agenda_topics`, `active_topic_index`. +- Voting data: `voting`, `voting_choice`. +- Roll call data: `roll_call`. + +State is currently process-local and in memory. There is no database, persistence layer, auth layer, Redis, or multi-process synchronization yet. + +### Session Engine + +`SessionEngine.dispatch()` maps incoming event `type` values to handler functions through `EVENT_HANDLERS`. The implemented behavior includes: + +- Delegate queue actions for the General Speakers List. +- Delegate voting during an active voting context. +- Delegate roll-call answers. +- Chair timer toggle/increase actions. +- Chair informal voting open/close actions. +- Chair motion resolution into procedural voting. +- Chair procedural voting close flow for some motions. +- Chair speaker selection. +- Chair roll-call marking, bulk marking, and closing. +- Chair queue insertion. +- Session open/close scaffolding. + +Important states include: + +- `SETUP` +- `ROLL_CALL` +- `INITIAL_DEBATE` +- `OPEN_GSL` +- `CLOSED_GSL` +- `MODERATED_CAUCUS` +- `UNMODERATED_CAUCUS` +- `VOTING_EXECUTION` +- `VOTING_PREPARATION` +- `VOTING_PROCEDURES` +- `BETWEEN_DEBATES` +- `FINISHED` + +Motions are filtered by phase through `MOTIONS_ALLOWED`. The engine intends to model parliamentary procedure as a finite state machine, where delegates submit motions/questions and chairs accept, reject, or force transitions. + +### Backend Testing + +Tests live in `backend/app/tests`. They cover: + +- Connection manager behavior. +- Session service actor resolution, session creation, event dispatch, and broadcast orchestration. +- Engine event behavior for queues, voting, timers, roll call, speaker selection, and selected chair/delegate permissions. +- Route behavior in `test_views.py`. + +Some tests are marked `xfail` for known incomplete behavior, such as unimplemented priority functions and edge cases in manager broadcasting. + +## Frontend + +The frontend is a React SPA in `frontend/` using: + +- React 19. +- TypeScript. +- Vite. +- React Router. +- Zustand. +- Tailwind CSS. +- shadcn-style local UI components. +- Lucide icons. +- `@hey-api/openapi-ts` generated types in `src/schemas/types.gen.ts`. + +Main frontend entry points: + +- `src/main.tsx`: React root mounting. +- `src/App.tsx`: SPA routes. +- `src/store/useCommitteeStore.ts`: Zustand store typed as `SessionLiveState`. +- `src/pages/CreateCommittee.tsx`: hardcoded committee creation flow for session `0`. +- `src/pages/Session.tsx`: chair session page and WebSocket connection. + +Current routes: + +- `/`: home page. +- `/login`: login page. +- `/create-committee`: committee creation page. +- `/committees/:committeeId/session`: live session page. + +### Frontend Data Flow + +`Session.tsx` opens a WebSocket to: + +```text +ws://localhost:8000/committees/ws/0?role=CHAIR&display_name=Chair +``` + +When messages arrive, it parses the JSON and calls `UpdateStore(data)`, replacing the Zustand committee store with the latest backend `SessionLiveState`. + +`sendMessage(data)` is exported from `Session.tsx`; components can use it to send serialized backend events over the active WebSocket. + +Current session UI areas: + +- `TopBar`: logo, timer, agenda dialog, active topic display, chair profile placeholder. +- `DelegationMap`: committee seating visualization. +- `SpeakerList`: reads `gsl_queue` and `current_speaker` from Zustand. +- `MotionsList`: currently receives mock motions from `Session.tsx`. +- `BottomBar`: command buttons for exit, vote, motions, speeches, history, session, BRB, incident/help. +- `Timer`, `VotingPopup`, `ManualQuorum`, and bottom-bar button components provide interaction scaffolding. + +The frontend is partially wired to live backend state. The store receives full session snapshots, but several UI areas still use hardcoded data or placeholders. + +## OpenAPI Type Generation + +The frontend generates TypeScript types from the backend OpenAPI schema using `@hey-api/openapi-ts`. + +Config: + +- `frontend/openapi-ts.config.ts` +- Input: `http://backend:8000/openapi.json` +- Output: `frontend/src/schemas` + +`docker-compose.yml` runs `npx @hey-api/openapi-ts` as a frontend `post_start` command, and it watches backend schema/model files for restarts. + +## Local Development + +Backend: + +```bash +cd backend +uv sync +uv run fastapi dev +``` + +Frontend: + +```bash +cd frontend +npm install +npm run dev +``` + +Docker Compose: + +```bash +docker compose up --build +``` + +Default exposed ports: + +- Backend: `http://localhost:8000` +- Frontend: `http://localhost:5173` + +Useful checks: + +```bash +cd backend +uv run pytest +uv run ruff check . +uv run ruff format . +``` + +```bash +cd frontend +npm run lint +npm run build +``` + +## Current Caveats + +- Session state is in memory only; restarting the backend loses all committees and live state. +- There is no real authentication or authorization yet. Roles are provided by WebSocket query params. +- `Session.tsx` currently connects to hardcoded session `0` as chair, ignoring `committeeId` for the socket URL. +- `CreateCommittee.tsx` uses a hardcoded committee payload. +- Several frontend controls are visual scaffolding and are not fully connected to backend events. +- `MotionsList` currently uses mock data rather than `submitted_motions` from the Zustand store. +- Some engine helpers are stubs, including motion/question priority calculation, vote tallying, agenda setting, and manual phase setting. +- Some backend behavior appears incomplete or inconsistent with tests, including known `xfail` cases. +- The engine's `handle_open_session` references `manager.count_present_delegations(...)`, but `manager` is not in that module's scope. +- `SessionService.create_session()` sets `current_state=States.SETUP`, but `States` is not imported directly in `service.py`; the module imports `app.session.enums as enums`. +- The frontend generated type file `frontend/src/schemas/types.gen.ts` is generated output and currently has uncommitted modifications in the working tree. + +## System Direction + +The intended architecture is a realtime state-machine backend that owns canonical committee state and broadcasts snapshots to all clients. The frontend should treat Zustand as a local projection of that canonical state and send typed event messages for user actions. As the project matures, the likely next architectural additions are persistence, authentication, role-specific views, fuller procedural rule enforcement, frontend event wiring, and replacing full-state broadcasts with smaller event/delta messages if needed. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..bee3af4 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,5 +19,9 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + 'react-refresh/only-export-components': 'off', + 'react-hooks/set-state-in-effect': 'off', + }, }, ]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 255a292..43d754b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -35,16 +39,76 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", "postcss": "^8.5.9", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^8.0.4" + "vite": "^8.0.4", + "vitest": "^4.1.9" }, "engines": { "node": ">= 20.0.0" } }, + "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": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -519,6 +583,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "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.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "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.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "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.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "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/@dotenvx/dotenvx": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", @@ -865,6 +1082,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "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/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3067,6 +3302,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -3324,6 +3566,95 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "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": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3381,6 +3712,31 @@ "tslib": "^2.4.0" } }, + "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" + }, + "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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3744,6 +4100,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3887,8 +4356,28 @@ "node": ">=10" } }, - "node_modules/ast-types": { - "version": "0.16.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/ast-types": { + "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", "license": "MIT", @@ -3954,6 +4443,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4115,6 +4614,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4402,6 +4911,27 @@ "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4430,6 +4960,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4447,6 +4991,13 @@ } } }, + "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/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4525,6 +5076,16 @@ "node": ">= 0.8" } }, + "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", @@ -4549,6 +5110,13 @@ "node": ">=0.3.1" } }, + "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" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4632,6 +5200,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4668,6 +5249,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4903,6 +5491,16 @@ "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", @@ -4968,6 +5566,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "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/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5543,6 +6151,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5635,6 +6256,16 @@ "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/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -5791,6 +6422,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5890,6 +6528,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "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", @@ -6321,6 +7010,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6339,6 +7038,13 @@ "node": ">= 0.4" } }, + "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/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -6446,6 +7152,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6674,6 +7390,20 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6860,6 +7590,19 @@ "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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6899,6 +7642,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6995,6 +7745,44 @@ "node": ">= 0.8.0" } }, + "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", + "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/pretty-format/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", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -7226,6 +8014,13 @@ "react": "^19.2.5" } }, + "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" + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7362,6 +8157,20 @@ "node": ">= 4" } }, + "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-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7533,6 +8342,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "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.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7752,6 +8574,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7788,6 +8617,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7797,6 +8633,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -7885,6 +8728,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7909,6 +8765,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -7956,6 +8819,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7972,6 +8852,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -8023,6 +8913,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8152,6 +9055,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -8390,6 +9303,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.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": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -8399,6 +9415,41 @@ "node": ">= 8" } }, + "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": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8414,6 +9465,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8500,6 +9568,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7d0855e..ca8b53c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "check": "npm run lint && npm run build && npm run test" }, "dependencies": { "@base-ui/react": "^1.5.0", @@ -31,6 +34,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -40,10 +47,12 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", "postcss": "^8.5.9", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^8.0.4" + "vite": "^8.0.4", + "vitest": "^4.1.9" } } diff --git a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx index 5c1363b..54ceb3b 100644 --- a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx +++ b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx @@ -25,7 +25,7 @@ import { useCommitteeStore } from "@/store/useCommitteeStore" export default function TestButton() { - const presentDelegations = useCommitteeStore((state) => Object.entries(state.roll_call?.registry ?? {}).filter(([_, choice]) => choice !== RollCallChoice.ABSENT).length) + const presentDelegations = useCommitteeStore((state) => Object.entries(state.roll_call?.registry ?? {}).filter(([, choice]) => choice !== RollCallChoice.ABSENT).length) const delegations = useCommitteeStore((state) => state.delegations.length ?? 0) return ( @@ -84,4 +84,4 @@ export default function TestButton() { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/session/delegation-map.test.tsx b/frontend/src/components/session/delegation-map.test.tsx new file mode 100644 index 0000000..d0d7071 --- /dev/null +++ b/frontend/src/components/session/delegation-map.test.tsx @@ -0,0 +1,24 @@ +import { screen } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" + +import DelegationMap from "@/components/session/delegation-map" +import { useCommitteeStore } from "@/store/useCommitteeStore" +import { createSessionState } from "@/test/session-state" +import { renderWithRouter } from "@/test/render" + +describe("DelegationMap", () => { + beforeEach(() => { + useCommitteeStore.setState(createSessionState(), true) + }) + + it("renders quorum, state, and majority summaries from store state", () => { + renderWithRouter( + , + ) + + expect(screen.getByText("2/3 delegações")).toBeInTheDocument() + expect(screen.getByText("Open GSL")).toBeInTheDocument() + expect(screen.getByText("Maioria simples: 2 votos")).toBeInTheDocument() + expect(screen.getByText("Maioria qualificada: 2 votos")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/session/delegation-map.tsx b/frontend/src/components/session/delegation-map.tsx index eaf5970..08d16b0 100644 --- a/frontend/src/components/session/delegation-map.tsx +++ b/frontend/src/components/session/delegation-map.tsx @@ -43,7 +43,7 @@ export default function DelegationMap({ delegations.sort((a, b) => a.seat > b.seat ? 1 : -1) let delegationIndex = -1 - const presentDelegations = useCommitteeStore((state) => Object.entries(state.roll_call?.registry ?? {}).filter(([_, choice]) => choice !== RollCallChoice.ABSENT).length) + const presentDelegations = useCommitteeStore((state) => Object.entries(state.roll_call?.registry ?? {}).filter(([, choice]) => choice !== RollCallChoice.ABSENT).length) const totalDelegations = useCommitteeStore((state) => state.delegations.length ?? 0) const simpleMajority = Math.floor(presentDelegations / 2) + 1 const qualifiedMajority = Math.ceil((presentDelegations * 2) / 3) diff --git a/frontend/src/components/session/motions-list.test.tsx b/frontend/src/components/session/motions-list.test.tsx new file mode 100644 index 0000000..1a76ca8 --- /dev/null +++ b/frontend/src/components/session/motions-list.test.tsx @@ -0,0 +1,50 @@ +import { screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import MotionsList, { type Motion } from "@/components/session/motions-list" +import { renderWithRouter } from "@/test/render" + +describe("MotionsList", () => { + it("orders motions by priority descending and timestamp ascending", () => { + const motions: Motion[] = [ + { + id: "late-high", + timestamp: "16:05", + title: "Late high priority", + proposer: "France", + proposerCode: "fr", + priority: 3, + }, + { + id: "low", + timestamp: "15:00", + title: "Low priority", + proposer: "Brazil", + proposerCode: "br", + priority: 1, + }, + { + id: "early-high", + timestamp: "15:58", + title: "Early high priority", + proposer: "Japan", + proposerCode: "jp", + priority: 3, + }, + ] + + renderWithRouter() + + const earlyHigh = screen.getByText("Early high priority") + const lateHigh = screen.getByText("Late high priority") + const low = screen.getByText("Low priority") + + expect( + earlyHigh.compareDocumentPosition(lateHigh) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + lateHigh.compareDocumentPosition(low) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) +}) diff --git a/frontend/src/components/session/speaker-list.tsx b/frontend/src/components/session/speaker-list.tsx index c203dce..7393b9c 100644 --- a/frontend/src/components/session/speaker-list.tsx +++ b/frontend/src/components/session/speaker-list.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" import { @@ -16,6 +15,7 @@ import { useCommitteeStore } from "@/store/useCommitteeStore" export default function SpeakerList() { const gslQueue = useCommitteeStore((state) => state.gsl_queue ?? []) const currentSpeaker = useCommitteeStore((state) => state.current_speaker) + const delegations = useCommitteeStore((state) => state.delegations) const waitingCount = gslQueue.length @@ -25,12 +25,13 @@ export default function SpeakerList() { {String(waitingCount).padStart(2, "0")} em espera - {gslQueue.map((delegate, index) => { - const isSpeaking = !!currentSpeaker && currentSpeaker.id === delegate.id + {gslQueue.map((delegateId, index) => { + const delegate = delegations.find((item) => item.id === delegateId) + const isSpeaking = currentSpeaker === delegateId const position = index + 1 return ( -
+
- {delegate.name} - + {delegate?.name ?? `Delegação ${delegateId}`} + @@ -58,4 +59,4 @@ export default function SpeakerList() {
) -} \ No newline at end of file +} diff --git a/frontend/src/components/session/timer.test.tsx b/frontend/src/components/session/timer.test.tsx new file mode 100644 index 0000000..faedc3a --- /dev/null +++ b/frontend/src/components/session/timer.test.tsx @@ -0,0 +1,69 @@ +import { act, fireEvent, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { ChairEvents } from "@/schemas/types.gen" +import Timer from "@/components/session/timer" +import { useCommitteeStore } from "@/store/useCommitteeStore" +import { createSessionState } from "@/test/session-state" +import { renderWithRouter } from "@/test/render" + +const sendMessage = vi.fn() + +vi.mock("@/pages/Session", () => ({ + sendMessage: (data: unknown) => sendMessage(data), +})) + +describe("Timer", () => { + beforeEach(() => { + sendMessage.mockClear() + vi.useRealTimers() + useCommitteeStore.setState(createSessionState(), true) + }) + + it("renders the paused timer and sends chair timer events", () => { + useCommitteeStore.setState( + createSessionState({ timer_remaining_seconds: 65 }), + true, + ) + + renderWithRouter() + + expect(screen.getByText("01:05")).toBeInTheDocument() + expect(screen.getByText("Mesa")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: /start timer/i })) + expect(sendMessage).toHaveBeenCalledWith({ + type: ChairEvents.TOGGLE_TIMER_EVENT, + payload: {}, + }) + + fireEvent.click(screen.getByRole("button", { name: /5s/i })) + expect(sendMessage).toHaveBeenCalledWith({ + type: ChairEvents.INCREASE_TIMER_EVENT, + payload: { seconds: 5 }, + }) + }) + + it("counts down from timer_expiration while running", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-01T12:00:00Z")) + useCommitteeStore.setState( + createSessionState({ + timer_is_running: true, + timer_expiration: "2026-07-01T12:01:30Z", + timer_remaining_seconds: 90, + }), + true, + ) + + renderWithRouter() + + expect(screen.getByText("01:30")).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(31_000) + }) + + expect(screen.getByText("00:59")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx index 874f1e0..48b5b58 100644 --- a/frontend/src/components/session/timer.tsx +++ b/frontend/src/components/session/timer.tsx @@ -12,7 +12,8 @@ export default function Timer() { const currentSpeaker = useCommitteeStore((state) => state.current_speaker); - const speaker = currentSpeaker ?? { + const delegations = useCommitteeStore((state) => state.delegations); + const speaker = delegations.find((delegation) => delegation.id === currentSpeaker) ?? { id: -1, seat: "", name: "Mesa", @@ -66,4 +67,4 @@ export default function Timer() {
) -} \ No newline at end of file +} diff --git a/frontend/src/components/session/top-bar.test.tsx b/frontend/src/components/session/top-bar.test.tsx new file mode 100644 index 0000000..a301535 --- /dev/null +++ b/frontend/src/components/session/top-bar.test.tsx @@ -0,0 +1,33 @@ +import { screen } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" + +import TopBar from "@/components/session/top-bar" +import { useCommitteeStore } from "@/store/useCommitteeStore" +import { createSessionState } from "@/test/session-state" +import { renderWithRouter } from "@/test/render" + +describe("TopBar", () => { + beforeEach(() => { + useCommitteeStore.setState(createSessionState(), true) + }) + + it("shows the no-topic fallback", () => { + renderWithRouter() + + expect(screen.getByText("Nenhum tópico em discussão")).toBeInTheDocument() + }) + + it("shows the active agenda topic", () => { + useCommitteeStore.setState( + createSessionState({ + agenda_topics: [["Humanitarian corridors", false]], + active_topic_index: 0, + }), + true, + ) + + renderWithRouter() + + expect(screen.getByText("Humanitarian corridors")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts new file mode 100644 index 0000000..ccd7486 --- /dev/null +++ b/frontend/src/lib/utils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" + +import { cn } from "./utils" + +describe("cn", () => { + it("merges conditional classes and resolves Tailwind conflicts", () => { + const isHidden = false + + expect(cn("px-2", isHidden && "hidden", "px-4", ["text-sm"])).toBe( + "px-4 text-sm", + ) + }) +}) diff --git a/frontend/src/pages/CreateCommittee.tsx b/frontend/src/pages/CreateCommittee.tsx index 8d73f05..8ff9ebc 100644 --- a/frontend/src/pages/CreateCommittee.tsx +++ b/frontend/src/pages/CreateCommittee.tsx @@ -1,4 +1,3 @@ -import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" export default function SessionPage() { @@ -41,7 +40,7 @@ export default function SessionPage() { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, }) - .then((response) => window.location.href = "/committees/0/session") + .then(() => window.location.href = "/committees/0/session") } } @@ -51,4 +50,4 @@ export default function SessionPage() { ) -} \ No newline at end of file +} diff --git a/frontend/src/pages/Session.tsx b/frontend/src/pages/Session.tsx index d35031a..c5e62ea 100644 --- a/frontend/src/pages/Session.tsx +++ b/frontend/src/pages/Session.tsx @@ -1,6 +1,6 @@ import { useEffect, useState} from 'react'; import { useParams } from 'react-router-dom'; -import { UpdateStore, useCommitteeStore } from '../store/useCommitteeStore.ts' +import { UpdateStore } from '../store/useCommitteeStore.ts' import SpeakerList from "@/components/session/speaker-list" import MotionsList from "@/components/session/motions-list" import BottomBar from "@/components/session/bottom-bar" @@ -13,7 +13,7 @@ let socket : WebSocket | null = null; Use this function to send events to the backend, any data with one of the Event types in schemas/types.gen.ts should work */ -export function sendMessage(data: any) { +export function sendMessage(data: unknown) { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(data)); @@ -58,8 +58,6 @@ export default function SessionPage() { // id that matches the name given in the Route path, at App.tsx const { committeeId } = useParams<{ committeeId: string }>(); //const {start_time} = useCommitteeStore(); - const all = useCommitteeStore(); - const [, setStatus] = useState("Connecting..."); //const [, setUptime] = useState(0); @@ -75,7 +73,6 @@ export default function SessionPage() { socket.onmessage = (event) => { const data = JSON.parse(event.data); UpdateStore(data); - console.log(all); }; socket.onclose = () => setStatus("Disconnected"); @@ -98,7 +95,6 @@ export default function SessionPage() { return () => clearInterval(interval); }, [start_time]);*/ - console.log(all); return (
{/*

diff --git a/frontend/src/store/useCommitteeStore.test.ts b/frontend/src/store/useCommitteeStore.test.ts new file mode 100644 index 0000000..bc068d6 --- /dev/null +++ b/frontend/src/store/useCommitteeStore.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { States } from "@/schemas/types.gen" +import { createSessionState } from "@/test/session-state" + +import { UpdateStore, useCommitteeStore } from "./useCommitteeStore" + +const initialState = useCommitteeStore.getInitialState() + +describe("useCommitteeStore", () => { + beforeEach(() => { + useCommitteeStore.setState(initialState, true) + }) + + it("starts with an empty committee projection", () => { + const state = useCommitteeStore.getState() + + expect(state.session_id).toBe(-1) + expect(state.delegations).toEqual([]) + expect(state.timer_is_running).toBe(false) + expect(state.roll_call).toEqual({}) + }) + + it("replaces local state with the backend session snapshot", () => { + const snapshot = createSessionState({ + session_id: 42, + current_state: States.ROLL_CALL, + timer_remaining_seconds: 90, + gsl_queue: [1, 2], + }) + + UpdateStore(snapshot) + + expect(useCommitteeStore.getState()).toMatchObject({ + session_id: 42, + current_state: States.ROLL_CALL, + timer_remaining_seconds: 90, + gsl_queue: [1, 2], + }) + }) +}) diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx new file mode 100644 index 0000000..25fde85 --- /dev/null +++ b/frontend/src/test/render.tsx @@ -0,0 +1,14 @@ +import { render } from "@testing-library/react" +import type { ReactElement } from "react" +import { MemoryRouter } from "react-router-dom" + +export function renderWithRouter( + ui: ReactElement, + initialEntries: string[] = ["/"], +) { + return render( + + {ui} + , + ) +} diff --git a/frontend/src/test/session-state.ts b/frontend/src/test/session-state.ts new file mode 100644 index 0000000..0e9ab51 --- /dev/null +++ b/frontend/src/test/session-state.ts @@ -0,0 +1,41 @@ +import type { SessionLiveState } from "@/schemas/types.gen" +import { RollCallChoice, States } from "@/schemas/types.gen" + +export function createSessionState( + overrides: Partial = {}, +): SessionLiveState { + return { + session_id: 0, + start_time: "2026-07-01T12:00:00Z", + delegations: [ + { id: 0, name: "Brazil", seat: "1-1", code: "br" }, + { id: 1, name: "France", seat: "1-2", code: "fr" }, + { id: 2, name: "Japan", seat: "1-3", code: "jp" }, + ], + current_state: States.OPEN_GSL, + timer_is_running: false, + timer_expiration: null, + timer_remaining_seconds: 0, + current_speaker: null, + gsl_queue: [], + can_set_motion: false, + gsl_default_time_seconds: 60, + caucus_list: [], + debate: null, + submitted_motions: [], + submitted_questions: [], + agenda_topics: [], + active_topic_index: null, + voting: null, + voting_choice: null, + roll_call: { + registry: { + 0: RollCallChoice.PRESENT, + 1: RollCallChoice.PRESENT_AND_VOTING, + 2: RollCallChoice.ABSENT, + }, + current_delegation: null, + }, + ...overrides, + } +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..601f021 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest" + +import { cleanup } from "@testing-library/react" +import { afterEach } from "vitest" + +afterEach(() => { + cleanup() +}) diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index d3c52ea..4bbd510 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023"], "module": "esnext", - "types": ["node"], + "types": ["node", "vitest/config"], "skipLibCheck": true, /* Bundler mode */ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5b6048f..69535c8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,6 @@ import path from "path" import tailwindcss from "@tailwindcss/vite" -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' // https://vite.dev/config/ @@ -14,4 +14,8 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + test: { + environment: "jsdom", + setupFiles: "./src/test/setup.ts", + }, })