From 0b623ec39eafff33c35507ce78d4dd424e739e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 02:56:16 +0000 Subject: [PATCH] feat: add lobby UI, build integration, and test suite (Phases 9, 11, 12) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 20 + Makefile | 5 +- README.md | 19 + frontend/package-lock.json | 1156 ++++++++++++++++- frontend/package.json | 11 +- frontend/src/__tests__/CardChip.test.tsx | 35 + frontend/src/__tests__/GamePage.test.tsx | 86 ++ frontend/src/__tests__/MoveBar.test.tsx | 66 + .../src/__tests__/NarrationPanel.test.tsx | 38 + frontend/src/__tests__/PhaseBar.test.tsx | 38 + frontend/src/__tests__/PlayerZone.test.tsx | 32 + frontend/src/__tests__/gameReducer.test.ts | 78 ++ frontend/src/__tests__/useGameSocket.test.ts | 92 ++ frontend/src/hooks/useLobbySock.ts | 163 +++ frontend/src/pages/LobbyPage.tsx | 138 +- frontend/src/pages/RoomPage.tsx | 219 +++- frontend/src/test/setup.ts | 1 + frontend/vite.config.ts | 2 + frontend/vitest.config.ts | 12 + src/commander_ai_lab/web/static/index.html | 1 + 20 files changed, 2203 insertions(+), 9 deletions(-) create mode 100644 frontend/src/__tests__/CardChip.test.tsx create mode 100644 frontend/src/__tests__/GamePage.test.tsx create mode 100644 frontend/src/__tests__/MoveBar.test.tsx create mode 100644 frontend/src/__tests__/NarrationPanel.test.tsx create mode 100644 frontend/src/__tests__/PhaseBar.test.tsx create mode 100644 frontend/src/__tests__/PlayerZone.test.tsx create mode 100644 frontend/src/__tests__/gameReducer.test.ts create mode 100644 frontend/src/__tests__/useGameSocket.test.ts create mode 100644 frontend/src/hooks/useLobbySock.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7c31cf..46d0ee6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,26 @@ jobs: - name: Run tests run: pytest -v --tb=short tests/ || true + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - name: Run frontend tests + working-directory: frontend + run: npx vitest run + java-tests: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index ccff5f8..87357e0 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ UVICORN ?= uvicorn PORT ?= 8000 SRC = src/commander_ai_lab -.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build +.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend test: $(PYTHON) -m pytest tests/ -q @@ -69,3 +69,6 @@ frontend-dev: frontend-build: cd frontend && npm run build + +test-frontend: + cd frontend && npx vitest run diff --git a/README.md b/README.md index 20e63c9..e1eeb34 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,25 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`: } ``` +## Frontend Development + +The UI is a React + TypeScript SPA in the `frontend/` directory. + +### Dev server (with hot reload) +```bash +cd frontend +npm install +npm run dev +``` +Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first. + +### Production build +```bash +cd frontend +npm run build +``` +FastAPI serves the built assets from `frontend/dist/`. + ## Known Limitations - Winner's life total not captured (Forge quiet mode only reports losers) - Win condition classification is heuristic-based on Forge loss reason strings diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 03ac1cb..7ea0918 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -24,13 +27,22 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.0.0", "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -44,6 +56,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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", @@ -236,6 +309,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -284,6 +367,159 @@ "node": ">=6.9.0" } }, + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "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.0.2", + "@csstools/css-calc": "^3.1.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.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "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/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -475,6 +711,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "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.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -914,6 +1168,103 @@ "dev": true, "license": "MIT" }, + "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/@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", + "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": "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/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -925,6 +1276,32 @@ "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", + "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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1290,6 +1667,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1330,6 +1820,17 @@ "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": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1394,6 +1895,26 @@ "dev": true, "license": "Python-2.0" }, + "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/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1451,6 +1972,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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1563,6 +2094,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", @@ -1699,6 +2240,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", @@ -1719,6 +2281,20 @@ "dev": true, "license": "MIT" }, + "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", @@ -1737,6 +2313,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/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1744,6 +2327,16 @@ "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", @@ -1768,6 +2361,14 @@ "dev": true, "license": "MIT" }, + "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.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", @@ -1775,6 +2376,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1972,6 +2593,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", @@ -1982,6 +2613,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "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", @@ -2240,6 +2881,19 @@ "hermes-estree": "0.25.1" } }, + "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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2277,6 +2931,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/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2339,6 +3003,13 @@ "node": ">=0.12.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", @@ -2376,6 +3047,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@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.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.3", + "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.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "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", @@ -2761,6 +3483,34 @@ "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": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2798,6 +3548,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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", @@ -2893,6 +3653,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2956,6 +3727,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -2983,6 +3767,13 @@ "dev": true, "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", @@ -3196,6 +3987,36 @@ "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", + "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/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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3248,6 +4069,14 @@ "react": "^19.2.4" } }, + "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/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", @@ -3322,6 +4151,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3429,6 +4282,19 @@ "queue-microtask": "^1.2.2" } }, + "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", @@ -3474,6 +4340,13 @@ "node": ">=8" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3484,6 +4357,33 @@ "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/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "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/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3546,6 +4446,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3607,6 +4514,23 @@ "node": ">=0.8" } }, + "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.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3624,6 +4548,36 @@ "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.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3637,6 +4591,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "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.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -3716,6 +4696,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "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", @@ -3850,6 +4840,136 @@ } } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "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.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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/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/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", @@ -3866,6 +4986,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", @@ -3876,6 +5013,23 @@ "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", diff --git a/frontend/package.json b/frontend/package.json index 5fd6674..7bd143b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "clsx": "^2.1.1", @@ -17,6 +19,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -26,10 +31,12 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.0.0", "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.0" } } diff --git a/frontend/src/__tests__/CardChip.test.tsx b/frontend/src/__tests__/CardChip.test.tsx new file mode 100644 index 0000000..fe8635d --- /dev/null +++ b/frontend/src/__tests__/CardChip.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import CardChip from '../components/CardChip'; +import type { Card } from '../types/game'; + +function makeCard(overrides: Partial = {}): Card { + return { + name: 'Sol Ring', + type: 'Artifact', + tapped: false, + is_commander: false, + oracle: 'Tap: Add CC.', + cmc: 1, + ...overrides, + }; +} + +describe('CardChip', () => { + it('applies opacity class when tapped', () => { + render(); + const chip = screen.getByText(/Sol Ring/); + expect(chip.closest('span')).toHaveClass('opacity-55'); + }); + + it('shows star for commander cards', () => { + render(); + expect(screen.getByText('★')).toBeInTheDocument(); + }); + + it('has hover border class', () => { + render(); + const chip = screen.getByText(/Sol Ring/).closest('span'); + expect(chip).toHaveClass('hover:border-[#ffdc32]'); + }); +}); diff --git a/frontend/src/__tests__/GamePage.test.tsx b/frontend/src/__tests__/GamePage.test.tsx new file mode 100644 index 0000000..f813802 --- /dev/null +++ b/frontend/src/__tests__/GamePage.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import GamePage from '../pages/GamePage'; +import type { GameState, Card } from '../types/game'; + +const mockGameState: GameState = { + game_id: 'test-game', + turn: 1, + current_phase: 'main1', + active_seat: 0, + players: [ + { + seat: 0, name: 'Alice', life: 40, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7, + }, + { + seat: 1, name: 'Bob', life: 38, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6, + }, + { + seat: 2, name: 'Charlie', life: 40, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7, + }, + { + seat: 3, name: 'Diana', life: 35, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5, + }, + ], + stack: [], + game_over: false, + winner: null, +}; + +const mockHand: Card[] = []; +const mockSendMove = vi.fn(); +const mockSendConcede = vi.fn(); + +vi.mock('../hooks/useGameSocket', () => ({ + useGameSocket: () => ({ + gameState: mockGameState, + narration: [], + thinking: new Set(), + hand: mockHand, + gameOver: null, + status: 'Connected', + sendMove: mockSendMove, + sendConcede: mockSendConcede, + }), +})); + +describe('GamePage integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all 4 PlayerZone components', () => { + render( + + + } /> + + , + ); + + // P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText + expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1); + }); + + it('renders PhaseBar with current phase', () => { + render( + + + } /> + + , + ); + + // main1 should be highlighted + const main1Buttons = screen.getAllByText('main1'); + expect(main1Buttons.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/__tests__/MoveBar.test.tsx b/frontend/src/__tests__/MoveBar.test.tsx new file mode 100644 index 0000000..5385b59 --- /dev/null +++ b/frontend/src/__tests__/MoveBar.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import MoveBar from '../components/MoveBar'; + +describe('MoveBar', () => { + it('calls onPass when pass button is clicked', async () => { + const onPass = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText('Pass Priority')); + expect(onPass).toHaveBeenCalledOnce(); + }); + + it('shows confirm dialog on concede', async () => { + const onConcede = vi.fn(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + render( + , + ); + + await userEvent.click(screen.getByText('Concede')); + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?'); + expect(onConcede).toHaveBeenCalledOnce(); + + confirmSpy.mockRestore(); + }); + + it('does not concede if confirm is cancelled', async () => { + const onConcede = vi.fn(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render( + , + ); + + await userEvent.click(screen.getByText('Concede')); + expect(onConcede).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); +}); diff --git a/frontend/src/__tests__/NarrationPanel.test.tsx b/frontend/src/__tests__/NarrationPanel.test.tsx new file mode 100644 index 0000000..0051e26 --- /dev/null +++ b/frontend/src/__tests__/NarrationPanel.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import NarrationPanel from '../components/NarrationPanel'; +import type { NarrationLine } from '../types/game'; + +describe('NarrationPanel', () => { + it('renders only 120 lines when given more', () => { + const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({ + text: `Line ${i}`, + isSystem: false, + timestamp: Date.now(), + })); + + render(); + + // Should not render the first 30 lines (0-29) + expect(screen.queryByText('Line 0')).not.toBeInTheDocument(); + expect(screen.queryByText('Line 29')).not.toBeInTheDocument(); + + // Should render lines 30-149 + expect(screen.getByText('Line 30')).toBeInTheDocument(); + expect(screen.getByText('Line 149')).toBeInTheDocument(); + }); + + it('has a scroll container ref', () => { + const lines: NarrationLine[] = [ + { text: 'Hello world', isSystem: false, timestamp: Date.now() }, + ]; + + const { container } = render( + , + ); + + // The container div has overflow-y-auto + const panel = container.firstChild as HTMLElement; + expect(panel).toHaveClass('overflow-y-auto'); + }); +}); diff --git a/frontend/src/__tests__/PhaseBar.test.tsx b/frontend/src/__tests__/PhaseBar.test.tsx new file mode 100644 index 0000000..1f8d7e7 --- /dev/null +++ b/frontend/src/__tests__/PhaseBar.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import PhaseBar from '../components/PhaseBar'; +import type { Player } from '../types/game'; + +const players: Player[] = [ + { + seat: 0, name: 'Alice', life: 40, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7, + }, + { + seat: 1, name: 'Bob', life: 40, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7, + }, +]; + +describe('PhaseBar', () => { + it('highlights the active phase tab', () => { + render(); + + const main1 = screen.getByText('main1'); + expect(main1).toHaveClass('bg-accent-blue'); + }); + + it('does not highlight inactive phase tabs', () => { + render(); + + const untap = screen.getByText('untap'); + expect(untap).not.toHaveClass('bg-accent-blue'); + expect(untap).toHaveClass('bg-[#252a42]'); + }); + + it('shows active player name', () => { + render(); + + expect(screen.getByText(/P0: Alice/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/PlayerZone.test.tsx b/frontend/src/__tests__/PlayerZone.test.tsx new file mode 100644 index 0000000..be79b98 --- /dev/null +++ b/frontend/src/__tests__/PlayerZone.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import PlayerZone from '../components/PlayerZone'; +import type { Player } from '../types/game'; + +function makePlayer(overrides: Partial = {}): Player { + return { + seat: 0, name: 'Alice', life: 40, eliminated: false, + hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7, + ...overrides, + }; +} + +describe('PlayerZone life colors', () => { + it('uses life-high color for life >= 30', () => { + render(); + const life = screen.getByText('35'); + expect(life).toHaveClass('text-life-high'); + }); + + it('uses life-med color for life >= 15 and < 30', () => { + render(); + const life = screen.getByText('20'); + expect(life).toHaveClass('text-life-med'); + }); + + it('uses life-low color for life < 15', () => { + render(); + const life = screen.getByText('10'); + expect(life).toHaveClass('text-life-low'); + }); +}); diff --git a/frontend/src/__tests__/gameReducer.test.ts b/frontend/src/__tests__/gameReducer.test.ts new file mode 100644 index 0000000..8309a0e --- /dev/null +++ b/frontend/src/__tests__/gameReducer.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { gameReducer, initialState } from '../store/gameReducer'; +import type { GameState, Card, NarrationLine } from '../types/game'; + +const mockGameState: GameState = { + game_id: 'g1', + turn: 1, + current_phase: 'main1', + active_seat: 0, + players: [], + stack: [], + game_over: false, + winner: null, +}; + +const mockCard: Card = { + name: 'Sol Ring', + type: 'Artifact', + tapped: false, + is_commander: false, + oracle: '', + cmc: 1, +}; + +const mockLine: NarrationLine = { + text: 'Test narration', + isSystem: false, + timestamp: Date.now(), +}; + +describe('gameReducer', () => { + it('SET_STATE sets the game state', () => { + const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState }); + expect(result.gameState).toEqual(mockGameState); + }); + + it('SET_HAND sets the hand cards', () => { + const cards = [mockCard]; + const result = gameReducer(initialState, { type: 'SET_HAND', cards }); + expect(result.hand).toEqual(cards); + }); + + it('ADD_NARRATION appends a line', () => { + const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine }); + expect(result.narration).toHaveLength(1); + expect(result.narration[0].text).toBe('Test narration'); + }); + + it('ADD_NARRATION caps at 120 lines', () => { + let state = initialState; + for (let i = 0; i < 130; i++) { + state = gameReducer(state, { + type: 'ADD_NARRATION', + line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() }, + }); + } + expect(state.narration).toHaveLength(120); + expect(state.narration[0].text).toBe('line 10'); + }); + + it('SET_THINKING adds and removes seats', () => { + let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true }); + expect(state.thinkingSeats.has(2)).toBe(true); + + state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false }); + expect(state.thinkingSeats.has(2)).toBe(false); + }); + + it('GAME_OVER sets winner and reason', () => { + const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' }); + expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' }); + }); + + it('SET_STATUS sets status text', () => { + const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' }); + expect(result.status).toBe('Connected'); + }); +}); diff --git a/frontend/src/__tests__/useGameSocket.test.ts b/frontend/src/__tests__/useGameSocket.test.ts new file mode 100644 index 0000000..8e55e1e --- /dev/null +++ b/frontend/src/__tests__/useGameSocket.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useGameSocket } from '../hooks/useGameSocket'; + +// Mock WebSocket +class MockWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + static instances: MockWebSocket[] = []; + url: string; + readyState = 1; // OPEN + onopen: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + sent: string[] = []; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + // Simulate connection open + setTimeout(() => this.onopen?.(new Event('open')), 0); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.readyState = 3; + } + + simulateMessage(data: unknown) { + this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) })); + } +} + +describe('useGameSocket', () => { + beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('dispatches state on WS message', () => { + const { result } = renderHook(() => useGameSocket('game-1')); + + const ws = MockWebSocket.instances[0]; + const mockState = { + game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0, + players: [], stack: [], game_over: false, winner: null, + }; + + act(() => { + ws.simulateMessage({ type: 'state', payload: mockState }); + }); + + expect(result.current.gameState).toEqual(mockState); + }); + + it('dispatches hand cards on WS message', () => { + const { result } = renderHook(() => useGameSocket('game-1')); + const ws = MockWebSocket.instances[0]; + + const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }]; + + act(() => { + ws.simulateMessage({ type: 'hand', cards }); + }); + + expect(result.current.hand).toEqual(cards); + }); + + it('sends heartbeat ping every 15s', () => { + renderHook(() => useGameSocket('game-1')); + const ws = MockWebSocket.instances[0]; + + act(() => { + vi.advanceTimersByTime(15000); + }); + + expect(ws.sent).toContain(JSON.stringify({ type: 'ping' })); + }); +}); diff --git a/frontend/src/hooks/useLobbySock.ts b/frontend/src/hooks/useLobbySock.ts new file mode 100644 index 0000000..9c2c43a --- /dev/null +++ b/frontend/src/hooks/useLobbySock.ts @@ -0,0 +1,163 @@ +import { useReducer, useRef, useEffect, useCallback } from 'react'; + +export interface Seat { + seat: number; + name: string; + ready: boolean; + isAi: boolean; +} + +export interface RoomState { + roomId: string; + roomName: string; + host: string; + seats: (Seat | null)[]; +} + +export interface ChatMessage { + role: 'PLAYER' | 'AI' | 'SYSTEM'; + name: string; + text: string; + timestamp: number; +} + +type LobbyAction = + | { type: 'SET_ROOM'; payload: RoomState } + | { type: 'ADD_CHAT'; message: ChatMessage } + | { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] } + | { type: 'LAUNCH'; gameId: string }; + +interface LobbyState { + roomState: RoomState | null; + chat: ChatMessage[]; + launchedGameId: string | null; +} + +const initialState: LobbyState = { + roomState: null, + chat: [], + launchedGameId: null, +}; + +function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState { + switch (action.type) { + case 'SET_ROOM': + return { ...state, roomState: action.payload }; + case 'ADD_CHAT': + return { ...state, chat: [...state.chat, action.message].slice(-50) }; + case 'SET_CHAT_HISTORY': + return { ...state, chat: action.messages.slice(-50) }; + case 'LAUNCH': + return { ...state, launchedGameId: action.gameId }; + default: + return state; + } +} + +export function useLobbySock(roomId: string, playerName: string) { + const [state, dispatch] = useReducer(lobbyReducer, initialState); + const wsRef = useRef(null); + const reconnectTimeout = useRef | null>(null); + const heartbeatInterval = useRef | null>(null); + const intentionalClose = useRef(false); + + const connect = useCallback(() => { + const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + switch (msg.type) { + case 'lobby_state': + dispatch({ type: 'SET_ROOM', payload: msg.payload }); + break; + case 'chat': + dispatch({ + type: 'ADD_CHAT', + message: { + role: msg.role ?? 'PLAYER', + name: msg.name ?? '', + text: msg.text, + timestamp: Date.now(), + }, + }); + break; + case 'chat_history': + dispatch({ + type: 'SET_CHAT_HISTORY', + messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({ + role: m.role ?? 'SYSTEM', + name: m.name ?? '', + text: m.text, + timestamp: Date.now(), + })), + }); + break; + case 'launch': + dispatch({ type: 'LAUNCH', gameId: msg.game_id }); + break; + case 'pong': + break; + } + }; + + ws.onclose = () => { + if (!intentionalClose.current) { + reconnectTimeout.current = setTimeout(() => connect(), 3000); + } + }; + }, [roomId, playerName]); + + useEffect(() => { + intentionalClose.current = false; + connect(); + + return () => { + intentionalClose.current = true; + if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current); + wsRef.current?.close(); + }; + }, [connect]); + + // Heartbeat ping every 15s + useEffect(() => { + heartbeatInterval.current = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'ping' })); + } + }, 15000); + + return () => { + if (heartbeatInterval.current) clearInterval(heartbeatInterval.current); + }; + }, []); + + const sendReady = useCallback((seat: number) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'ready', seat })); + } + }, []); + + const sendChat = useCallback((text: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'chat', text })); + } + }, []); + + const sendLaunch = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'launch' })); + } + }, []); + + return { + roomState: state.roomState, + chat: state.chat, + launchedGameId: state.launchedGameId, + sendReady, + sendChat, + sendLaunch, + }; +} diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 1cb2ab7..9b9222f 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,7 +1,141 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface RoomSummary { + roomId: string; + name: string; + host: string; + playerCount: number; +} + export default function LobbyPage() { + const navigate = useNavigate(); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + + // Create room form state + const [roomName, setRoomName] = useState(''); + const [hostName, setHostName] = useState(''); + const [aiSlots, setAiSlots] = useState(0); + const [password, setPassword] = useState(''); + const [creating, setCreating] = useState(false); + + useEffect(() => { + fetch('/api/v1/lobby/rooms') + .then((res) => res.json()) + .then((data) => setRooms(data)) + .catch(() => setRooms([])) + .finally(() => setLoading(false)); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + try { + const res = await fetch('/api/v1/lobby/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: roomName, + host: hostName, + ai_slots: aiSlots, + password: password || undefined, + }), + }); + const data = await res.json(); + navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`); + } catch { + setCreating(false); + } + }; + return ( -
-

Lobby — coming soon

+
+

Commander AI Lab — Lobby

+ +
+ {/* Room List */} +
+

Open Rooms

+ {loading ? ( +

Loading...

+ ) : rooms.length === 0 ? ( +

No rooms available. Create one!

+ ) : ( +
    + {rooms.map((room) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Create Room Form */} +
+

Create Room

+
+
+ + setRoomName(e.target.value)} + required + className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue" + /> +
+
+ + setHostName(e.target.value)} + required + className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue" + /> +
+
+ + +
+
+ + setPassword(e.target.value)} + className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue" + /> +
+ +
+
+
); } diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index 18cfd27..8371f4f 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -1,7 +1,220 @@ -export default function RoomPage() { +import { useState, useEffect, useRef } from 'react'; +import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; +import { useLobbySock } from '../hooks/useLobbySock'; +import type { ChatMessage } from '../hooks/useLobbySock'; + +const ROLE_COLORS: Record = { + PLAYER: 'text-accent-blue', + AI: 'text-accent-purple', + SYSTEM: 'text-text-muted', +}; + +function ChatBadge({ role }: { role: string }) { return ( -
-

Room — coming soon

+ + [{role}] + + ); +} + +export default function RoomPage() { + const { roomId } = useParams<{ roomId: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const isSpectator = searchParams.get('spectate') === '1'; + const nameFromUrl = searchParams.get('name') ?? ''; + const [playerName, setPlayerName] = useState(nameFromUrl); + const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl); + const [chatInput, setChatInput] = useState(''); + const chatEndRef = useRef(null); + + const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } = + useLobbySock(roomId!, nameConfirmed ? playerName : ''); + + // Redirect when game launches + useEffect(() => { + if (launchedGameId) { + navigate(`/game/${launchedGameId}`); + } + }, [launchedGameId, navigate]); + + // Auto-scroll chat + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chat.length]); + + // Determine own seat + const mySeat = roomState?.seats.findIndex( + (s) => s && !s.isAi && s.name === playerName, + ) ?? -1; + const isHost = mySeat === 0; + + if (!nameConfirmed) { + return ( +
+
+

Enter your name

+ setPlayerName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true); + }} + className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue" + autoFocus + /> + +
+
+ ); + } + + if (!roomState) { + return ( +
+ Connecting to room... +
+ ); + } + + const handleSendChat = () => { + if (chatInput.trim()) { + sendChat(chatInput.trim()); + setChatInput(''); + } + }; + + return ( +
+ {/* Header */} +
+
+

{roomState.roomName}

+ Host: {roomState.host} +
+ {isSpectator && ( + + SPECTATING + + )} +
+ +
+ {/* Seat Grid */} +
+
+ {[0, 1, 2, 3].map((seatIdx) => { + const seat = roomState.seats[seatIdx]; + return ( +
+ {seat ? ( + <> + + {seat.isAi ? '🤖 AI' : seat.name} + + + Seat {seatIdx} + + {!seat.isAi && ( + + {seat.ready ? 'READY' : 'Not Ready'} + + )} + + ) : ( + <> + Waiting... + + Seat {seatIdx} + + + )} +
+ ); + })} +
+ + {/* Action buttons */} + {!isSpectator && ( +
+ {mySeat >= 0 && ( + + )} + {isHost && ( + + )} +
+ )} +
+ + {/* Chat Panel */} +
+
+ Chat +
+
+ {chat.map((msg: ChatMessage, i: number) => ( +
+ + {msg.name && {msg.name}:} + {msg.text} +
+ ))} +
+
+ {!isSpectator && ( +
+ setChatInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSendChat(); + }} + placeholder="Type a message..." + className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue" + /> +
+ )} +
+
); } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 92bd3ee..c5999cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,8 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { + // Dev-only proxy — in production FastAPI serves the built assets + // and handles /api and /ws routes directly. proxy: { '/api': { target: 'http://localhost:8000', diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..519ab7d --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,12 @@ +/// +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, +}) diff --git a/src/commander_ai_lab/web/static/index.html b/src/commander_ai_lab/web/static/index.html index 76e6cfd..8a51c2f 100644 --- a/src/commander_ai_lab/web/static/index.html +++ b/src/commander_ai_lab/web/static/index.html @@ -1,3 +1,4 @@ +