diff --git a/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md b/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md new file mode 100644 index 0000000..90c3ede --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md @@ -0,0 +1,2393 @@ +# React Component Tree Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `react-app/src/App.jsx` into a presentational component tree with a `utils/` library, and add a Vitest + Testing Library UI test suite that covers every user-facing behavior. + +**Architecture:** `App.jsx` becomes the orchestrator (state + data fetch + URL sync + derivation of `augmented`/`filtered`/`movers`/`columns`). It composes 7 focused components in `src/components/`. The densest code paths (DataGrid columns, chart geometry) move to pure-function modules in `src/utils/`. Tests render real components with `ThemeProvider` + `jsdom` and drive behavior with `@testing-library/user-event` against a hand-built fixture covering all the interesting data shapes. + +**Tech Stack:** Vite 8, React 19.2, MUI 9 + `@mui/x-data-grid` 9. **Tests:** Vitest 2, `@testing-library/react` 16, `@testing-library/jest-dom` 6, `@testing-library/user-event` 14, `jsdom` 25. All test deps go into `devDependencies` in `package.json`. + +## Global Constraints + +- Zero behavior change. The user-visible app must be identical before and after the refactor. +- No new dependencies in `dependencies` (only `devDependencies` for tests). +- `main.jsx`, `index.html`, `Dockerfile`, `docker-compose.yml`, all `*.py` data scripts, and `live_data.json` / `pdf_data.json` are out of scope. +- `vite.config.js` may be modified ONLY to add a `test:` block for Vitest. +- `package.json` may be modified to add `devDependencies` and a `test` script. +- All new files go under `react-app/src/`. +- Target file size: ≤120 lines per file. +- Every behavior bullet from the spec's "Verification" step 3 has a corresponding test. +- Component tests use a `renderWithTheme` helper from `test/setup.js` that wraps the component in `ThemeProvider`. The full App test uses the same helper. +- Use `data-testid` attributes SPARINGLY — only where Testing Library cannot otherwise get a stable handle (e.g. inside the DataGrid, which uses internal ARIA roles that are awkward to query). +- Use `vi.fn()` for handler prop assertions. Use `userEvent` (not `fireEvent`) for user interaction. +- Mock `globalThis.fetch` in tests that need data; do not mock any other module. +- Commit after each task with a conventional-commits message (`feat:` / `test:` / `chore:` / `refactor:`). +- After each task, `cd react-app && npm test -- --run` must continue to pass (or the test being added is the one that proves this task). + +## File Structure (final) + +**New files (15):** +- `react-app/src/utils/format.js` — `sizeShort`, `pctLabel`, `ptsLabel`, `changeColor` +- `react-app/src/utils/url.js` — `readFiltersFromUrl`, `writeFiltersToUrl` +- `react-app/src/utils/movers.js` — `computeMovers` +- `react-app/src/utils/history.js` — `buildChartGeometry` +- `react-app/src/utils/columns.jsx` — `buildColumns` (imports `SizeCell`) +- `react-app/src/components/SizeCell.jsx` +- `react-app/src/components/PointsHistoryChart.jsx` +- `react-app/src/components/GraphModal.jsx` +- `react-app/src/components/MoversPanel.jsx` +- `react-app/src/components/MoversSection.jsx` +- `react-app/src/components/FilterBar.jsx` +- `react-app/src/components/UnitTable.jsx` +- `react-app/src/test/setup.js` — `renderWithTheme`, vitest setup hooks +- `react-app/src/test/fixtures/data.json` — minimal but representative dataset +- `react-app/src/test/utils.test.js` — pure-function smoke tests +- `react-app/src/test/SizeCell.test.jsx` +- `react-app/src/test/FilterBar.test.jsx` +- `react-app/src/test/UnitTable.test.jsx` +- `react-app/src/test/MoversSection.test.jsx` +- `react-app/src/test/GraphModal.test.jsx` +- `react-app/src/test/App.test.jsx` + +**Modified files (3):** +- `react-app/src/App.jsx` — replaced with orchestrator (~90 lines) +- `react-app/vite.config.js` — add `test:` block +- `react-app/package.json` — add `test` script + devDependencies + +--- + +## Task 1: Install test framework + +**Files:** +- Modify: `react-app/package.json` (add `test` script + 5 devDependencies) +- Modify: `react-app/vite.config.js` (add `test:` block) + +**Why first:** Every later task needs the test runner working so each step can be verified. + +- [ ] **Step 1: Add devDependencies to `package.json`** + +In `react-app/package.json`, add a `"test"` script and a `devDependencies` block. The exact edits: + +Replace the existing `"scripts"` block: +```json + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, +``` + +With: +```json + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest" + }, +``` + +Add this block at the bottom of the file (after the closing `}` of the top-level object — replace the top-level `}` with `,\n "devDependencies": { ... }\n}`): +```json + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^25.0.1", + "vitest": "^2.1.8" + } +``` + +- [ ] **Step 2: Add Vitest config to `vite.config.js`** + +Replace the current contents of `react-app/vite.config.js`: +```js +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) +``` + +With: +```js +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.js'], + css: false, + }, +}) +``` + +- [ ] **Step 3: Create minimal test setup to verify the framework runs** + +Create `react-app/src/test/setup.js`: +```js +import '@testing-library/jest-dom/vitest' +``` + +Create `react-app/src/test/smoke.test.js`: +```js +import { describe, it, expect } from 'vitest' + +describe('vitest setup', () => { + it('runs', () => { + expect(1 + 1).toBe(2) + }) +}) +``` + +- [ ] **Step 4: Install dependencies** + +Run: +```bash +cd react-app && npm install +``` + +Expected: `npm install` exits 0; `node_modules` populates with vitest, @testing-library/*, jsdom. + +- [ ] **Step 5: Run the smoke test** + +Run: +```bash +cd react-app && npx vitest run src/test/smoke.test.js +``` + +Expected: `1 passed`. If you see an error about `jest-dom` matchers not being defined, the `setup.js` import path is wrong — check it matches exactly. + +- [ ] **Step 6: Delete the smoke test (it was only to verify the runner works)** + +Run: +```bash +rm react-app/src/test/smoke.test.js +``` + +- [ ] **Step 7: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/package.json react-app/vite.config.js react-app/src/test/setup.js && git commit -m "chore: add vitest + testing-library + test config" +``` + +--- + +## Task 2: Build the test fixture + +**Files:** +- Create: `react-app/src/test/fixtures/data.json` + +**Why second:** Every later test needs realistic data. The fixture must cover all the interesting shapes the real `data.json` has: multi-size unit, no-history unit, `change_pct === 0`, new unit (`original === null`), removed unit (`new === null`), a few factions. + +- [ ] **Step 1: Create the fixture file** + +Create `react-app/src/test/fixtures/data.json` with this exact content: +```json +{ + "generated_at": "2026-06-18T00:00:00Z", + "versions": [ + { "date": "2024-12-01", "label": "MFM 1.14" }, + { "date": "2025-08-20", "label": "MFM 3.2" }, + { "date": "2026-06-17", "label": "MFM (current)" } + ], + "factions": ["adepta-sororitas", "space-marines", "necrons"], + "faction_names": { + "adepta-sororitas": "Adepta Sororitas", + "space-marines": "Space Marines", + "necrons": "Necrons" + }, + "stats": { "total_rows": 7, "rows_with_both": 5 }, + "units": [ + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Canoness", + "size": "1 model", + "original": 60, + "new": 60, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 60, + "new": 60, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 60 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 60 } + ] + } + ] + }, + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Arco-flagellants", + "size": "3 models", + "original": 45, + "new": 50, + "tier": null, + "change_pct": 11.11, + "change_pts": 5, + "default_size": "3 models", + "sizes": [ + { + "size": "3 models", + "original": 45, + "new": 50, + "tier": null, + "change_pct": 11.11, + "change_pts": 5, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 45 }, + { "date": "2025-08-20", "version": "MFM 3.2", "pts": 45 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 50 } + ] + }, + { + "size": "10 models", + "original": 140, + "new": 140, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 150 }, + { "date": "2025-08-20", "version": "MFM 3.2", "pts": 140 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 140 } + ] + } + ] + }, + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Palatine", + "size": "1 model", + "original": 50, + "new": 40, + "tier": null, + "change_pct": -20, + "change_pts": -10, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 50, + "new": 40, + "tier": null, + "change_pct": -20, + "change_pts": -10, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 50 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 40 } + ] + } + ] + }, + { + "faction": "space-marines", + "faction_name": "Space Marines", + "name": "Intercessor Squad", + "size": "5 models", + "original": 100, + "new": 95, + "tier": null, + "change_pct": -5, + "change_pts": -5, + "default_size": "5 models", + "sizes": [ + { + "size": "5 models", + "original": 100, + "new": 95, + "tier": null, + "change_pct": -5, + "change_pts": -5, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 100 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 95 } + ] + } + ] + }, + { + "faction": "space-marines", + "faction_name": "Space Marines", + "name": "Redemptor Dreadnought", + "size": "1 model", + "original": 200, + "new": 220, + "tier": null, + "change_pct": 10, + "change_pts": 20, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 200, + "new": 220, + "tier": null, + "change_pct": 10, + "change_pts": 20, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 200 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 220 } + ] + } + ] + }, + { + "faction": "necrons", + "faction_name": "Necrons", + "name": "New Unit X", + "size": "5 models", + "original": null, + "new": 80, + "tier": null, + "change_pct": null, + "change_pts": null, + "default_size": "5 models", + "sizes": [ + { + "size": "5 models", + "original": null, + "new": 80, + "tier": null, + "change_pct": null, + "change_pts": null, + "history": [] + } + ] + }, + { + "faction": "necrons", + "faction_name": "Necrons", + "name": "Removed Unit Y", + "size": "10 models", + "original": 120, + "new": null, + "tier": null, + "change_pct": null, + "change_pts": null, + "default_size": "10 models", + "sizes": [ + { + "size": "10 models", + "original": 120, + "new": null, + "tier": null, + "change_pct": null, + "change_pts": null, + "history": [] + } + ] + } + ] +} +``` + +**Why this set covers everything:** +- `Canoness` — `change_pct === 0`, 2-point history (tests "no-change" filter + footer of chart) +- `Arco-flagellants` — multi-size unit, 3-point history on small size, 3-point history on large size (tests size-cell click + chart size dropdown) +- `Palatine` — large drop (`-20%`), 2-point history (movers) +- `Intercessor Squad` — small drop (`-5%`), 2-point history +- `Redemptor Dreadnought` — rise (`+10%`), 2-point history (movers) +- `New Unit X` — `original === null` (tests "new-only" filter + `—` rendering for original) +- `Removed Unit Y` — `new === null` (tests "old-only" filter + `—` rendering for new) + +- [ ] **Step 2: Verify the fixture is valid JSON** + +Run: +```bash +cd react-app && python3 -c "import json; json.load(open('src/test/fixtures/data.json')); print('OK')" +``` + +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/test/fixtures/data.json && git commit -m "test: add representative data.json fixture for UI tests" +``` + +--- + +## Task 3: `utils/format.js` (pure formatting helpers) + +**Files:** +- Create: `react-app/src/utils/format.js` +- Create: `react-app/src/test/utils.test.js` (format section) + +**Interfaces:** +- Consumes: nothing (pure) +- Produces: + - `sizeShort(size: string): string` — strips trailing ` model` / ` models` + - `pctLabel(pct: number | null): string` — `+12.3%` / `-5.0%` / `—` + - `ptsLabel(pts: number | null): string` — `+5` / `-5` / `5` / `—` + - `changeColor(val: number | null): string` — `'#f85149'` / `'#3fb950'` / `'text.secondary'` + +- [ ] **Step 1: Write the failing test for `sizeShort`** + +Add to `react-app/src/test/utils.test.js`: +```js +import { describe, it, expect } from 'vitest' +import { sizeShort } from '../utils/format.js' + +describe('sizeShort', () => { + it('strips " model" suffix', () => { + expect(sizeShort('1 model')).toBe('1') + }) + it('strips " models" suffix', () => { + expect(sizeShort('10 models')).toBe('10') + }) + it('leaves other strings unchanged', () => { + expect(sizeShort('Squad')).toBe('Squad') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: FAIL — `Failed to resolve import "../utils/format.js"`. + +- [ ] **Step 3: Implement `utils/format.js`** + +Create `react-app/src/utils/format.js`: +```js +export function sizeShort(size) { + return size.replace(/\s*models?$/, '') +} + +export function pctLabel(pct) { + if (pct === null || pct === undefined) return '—' + const sign = pct > 0 ? '+' : '' + return `${sign}${pct.toFixed(1)}%` +} + +export function ptsLabel(pts) { + if (pts === null || pts === undefined) return '—' + return pts > 0 ? `+${pts}` : `${pts}` +} + +export function changeColor(val) { + if (val === null || val === undefined) return 'text.secondary' + if (val > 0) return '#f85149' + if (val < 0) return '#3fb950' + return 'text.secondary' +} +``` + +- [ ] **Step 4: Add tests for the rest of `format.js`** + +Append to `react-app/src/test/utils.test.js`: +```js +import { pctLabel, ptsLabel, changeColor } from '../utils/format.js' + +describe('pctLabel', () => { + it('prefixes positive values with +', () => { + expect(pctLabel(12.34)).toBe('+12.3%') + }) + it('renders negative values without prefix', () => { + expect(pctLabel(-5)).toBe('-5.0%') + }) + it('renders zero without prefix', () => { + expect(pctLabel(0)).toBe('0.0%') + }) + it('renders em-dash for null', () => { + expect(pctLabel(null)).toBe('—') + }) + it('renders em-dash for undefined', () => { + expect(pctLabel(undefined)).toBe('—') + }) +}) + +describe('ptsLabel', () => { + it('prefixes positive values with +', () => { + expect(ptsLabel(10)).toBe('+10') + }) + it('renders negative values as bare -N', () => { + expect(ptsLabel(-5)).toBe('-5') + }) + it('renders zero without prefix', () => { + expect(ptsLabel(0)).toBe('0') + }) + it('renders em-dash for null', () => { + expect(ptsLabel(null)).toBe('—') + }) +}) + +describe('changeColor', () => { + it('returns red for positive', () => { + expect(changeColor(1)).toBe('#f85149') + }) + it('returns green for negative', () => { + expect(changeColor(-1)).toBe('#3fb950') + }) + it('returns text.secondary for zero', () => { + expect(changeColor(0)).toBe('text.secondary') + }) + it('returns text.secondary for null', () => { + expect(changeColor(null)).toBe('text.secondary') + }) +}) +``` + +- [ ] **Step 5: Run all `format` tests** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: all 13 tests pass. + +- [ ] **Step 6: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/format.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add format helpers (sizeShort, pctLabel, ptsLabel, changeColor)" +``` + +--- + +## Task 4: `utils/url.js` (URL ⇄ filter state) + +**Files:** +- Create: `react-app/src/utils/url.js` +- Modify: `react-app/src/test/utils.test.js` (add url section) + +**Interfaces:** +- Consumes: `window.location` (mutates it via `history.replaceState` in the writer) +- Produces: + - `readFiltersFromUrl(): { q: string, faction: string, dir: string }` + - `writeFiltersToUrl({ q, faction, dir }): void` — clears the query string when all three are empty + +- [ ] **Step 1: Write the failing test for `readFiltersFromUrl`** + +Append to `react-app/src/test/utils.test.js`: +```js +import { readFiltersFromUrl, writeFiltersToUrl } from '../utils/url.js' + +describe('readFiltersFromUrl', () => { + it('returns defaults when search is empty', () => { + window.history.replaceState(null, '', '/?') + expect(readFiltersFromUrl()).toEqual({ q: '', faction: '', dir: '' }) + }) + it('reads q, faction, dir from query string', () => { + window.history.replaceState(null, '', '/?q=foo&faction=space-marines&dir=up') + expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: 'space-marines', dir: 'up' }) + }) + it('returns empty strings for missing keys', () => { + window.history.replaceState(null, '', '/?q=foo') + expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: '', dir: '' }) + }) +}) + +describe('writeFiltersToUrl', () => { + it('writes all three params', () => { + writeFiltersToUrl({ q: 'foo', faction: 'space-marines', dir: 'up' }) + expect(window.location.search).toBe('?q=foo&faction=space-marines&dir=up') + }) + it('clears the query string when all are empty', () => { + writeFiltersToUrl({ q: '', faction: '', dir: '' }) + expect(window.location.search).toBe('') + }) + it('writes only the present keys (others stay empty)', () => { + writeFiltersToUrl({ q: 'foo', faction: '', dir: '' }) + expect(window.location.search).toBe('?q=foo') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: FAIL — `Failed to resolve import "../utils/url.js"`. + +- [ ] **Step 3: Implement `utils/url.js`** + +Create `react-app/src/utils/url.js`: +```js +export function readFiltersFromUrl() { + const params = new URLSearchParams(window.location.search) + return { + q: params.get('q') || '', + faction: params.get('faction') || '', + dir: params.get('dir') || '', + } +} + +export function writeFiltersToUrl({ q, faction, dir }) { + const params = new URLSearchParams() + if (q) params.set('q', q) + if (faction) params.set('faction', faction) + if (dir) params.set('dir', dir) + const qs = params.toString() + const newUrl = qs ? `?${qs}` : window.location.pathname + window.history.replaceState(null, '', newUrl) +} +``` + +- [ ] **Step 4: Run all tests** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: all 19 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/url.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add URL read/write helpers for filter state" +``` + +--- + +## Task 5: `utils/movers.js` (top-N by direction) + +**Files:** +- Create: `react-app/src/utils/movers.js` +- Modify: `react-app/src/test/utils.test.js` (add movers section) + +**Interfaces:** +- Consumes: array of `Unit` (each has `change_pct: number | null`) +- Produces: + - `computeMovers(filtered: Unit[]): { drops: Unit[], rises: Unit[] }` — at most 5 each, sorted desc by magnitude, excludes `change_pct === null` + +- [ ] **Step 1: Write the failing test for `computeMovers`** + +Append to `react-app/src/test/utils.test.js`: +```js +import { computeMovers } from '../utils/movers.js' + +const mk = (name, change_pct) => ({ name, change_pct }) + +describe('computeMovers', () => { + it('returns empty arrays for empty input', () => { + expect(computeMovers([])).toEqual({ drops: [], rises: [] }) + }) + it('returns top 5 drops (largest negative change_pct first)', () => { + const input = [ + mk('A', -1), mk('B', -50), mk('C', -20), mk('D', -5), + mk('E', -10), mk('F', -30), mk('G', -2), + ] + expect(computeMovers(input).drops.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D']) + }) + it('returns top 5 rises (largest positive change_pct first)', () => { + const input = [ + mk('A', 1), mk('B', 50), mk('C', 20), mk('D', 5), + mk('E', 10), mk('F', 30), mk('G', 2), + ] + expect(computeMovers(input).rises.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D']) + }) + it('excludes units with change_pct === null from both lists', () => { + const input = [mk('A', -10), mk('B', null), mk('C', 10)] + const { drops, rises } = computeMovers(input) + expect(drops.map(u => u.name)).toEqual(['A']) + expect(rises.map(u => u.name)).toEqual(['C']) + }) + it('excludes units with change_pct === 0 from both lists', () => { + const input = [mk('A', -10), mk('B', 0), mk('C', 10)] + const { drops, rises } = computeMovers(input) + expect(drops.map(u => u.name)).toEqual(['A']) + expect(rises.map(u => u.name)).toEqual(['C']) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: FAIL — `Failed to resolve import`. + +- [ ] **Step 3: Implement `utils/movers.js`** + +Create `react-app/src/utils/movers.js`: +```js +export function computeMovers(filtered) { + if (!filtered || filtered.length === 0) return { drops: [], rises: [] } + const drops = filtered + .filter(u => u.change_pct !== null && u.change_pct < 0) + .sort((a, b) => a.change_pct - b.change_pct) + .slice(0, 5) + const rises = filtered + .filter(u => u.change_pct !== null && u.change_pct > 0) + .sort((a, b) => b.change_pct - a.change_pct) + .slice(0, 5) + return { drops, rises } +} +``` + +- [ ] **Step 4: Run all tests** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: all 24 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/movers.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add computeMovers (top-5 drops + rises)" +``` + +--- + +## Task 6: `utils/history.js` (chart scale math) + +**Files:** +- Create: `react-app/src/utils/history.js` +- Modify: `react-app/src/test/utils.test.js` (add history section) + +**Interfaces:** +- Consumes: `history: HistoryPoint[]` (each `{ pts: number, date, version }`) and `dims: { W, H, padL, padR, padT, padB }` +- Produces: + - `buildChartGeometry(history, dims): { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH }` + - `xFor(i: number) => number`, `yFor(v: number) => number` + - `yTicks: Array<{ v: number, y: number }>` + - `linePath: string` (empty if `history.length === 0`) + - `areaPath: string` (empty if `history.length < 2`) + - `chartW`, `chartH` — inner dimensions + +- [ ] **Step 1: Write the failing test for `buildChartGeometry`** + +Append to `react-app/src/test/utils.test.js`: +```js +import { buildChartGeometry } from '../utils/history.js' + +const DIMS = { W: 640, H: 360, padL: 70, padR: 24, padT: 24, padB: 48 } + +describe('buildChartGeometry', () => { + it('returns empty linePath for empty history', () => { + const g = buildChartGeometry([], DIMS) + expect(g.linePath).toBe('') + expect(g.areaPath).toBe('') + expect(g.yTicks).toEqual([]) + }) + it('returns empty areaPath for single-point history', () => { + const g = buildChartGeometry([{ pts: 50, date: '2026-01-01', version: 'v1' }], DIMS) + expect(g.linePath).not.toBe('') + expect(g.areaPath).toBe('') + }) + it('produces a multi-segment path for 3+ points', () => { + const g = buildChartGeometry([ + { pts: 50, date: '2024-01-01', version: 'v1' }, + { pts: 60, date: '2025-01-01', version: 'v2' }, + { pts: 40, date: '2026-01-01', version: 'v3' }, + ], DIMS) + expect(g.linePath.split('L').length).toBe(3) // 1 M + 2 L + expect(g.areaPath).not.toBe('') + }) + it('produces y-axis ticks spanning the data range', () => { + const g = buildChartGeometry([ + { pts: 10, date: '2024-01-01', version: 'v1' }, + { pts: 100, date: '2026-01-01', version: 'v2' }, + ], DIMS) + expect(g.yTicks.length).toBeGreaterThan(1) + const values = g.yTicks.map(t => t.v) + expect(Math.min(...values)).toBeLessThanOrEqual(10) + expect(Math.max(...values)).toBeGreaterThanOrEqual(100) + }) + it('chartW and chartH are W minus horizontal/vertical padding', () => { + const g = buildChartGeometry([], DIMS) + expect(g.chartW).toBe(DIMS.W - DIMS.padL - DIMS.padR) + expect(g.chartH).toBe(DIMS.H - DIMS.padT - DIMS.padB) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `utils/history.js`** + +This is a direct extraction of the math from the current `GraphModal` (App.jsx lines 92–119). Create `react-app/src/utils/history.js`: +```js +export function buildChartGeometry(history, { W, H, padL, padR, padT, padB }) { + const chartW = W - padL - padR + const chartH = H - padT - padB + + const pts = history.map(h => h.pts) + const minPts = Math.min(...(pts.length ? pts : [0]), 0) + const maxPts = Math.max(...(pts.length ? pts : [1]), 1) + const ptsRange = maxPts - minPts || 1 + const padY = ptsRange * 0.15 + const yMin = Math.max(0, minPts - padY) + const yMax = maxPts + padY + const yRange = yMax - yMin || 1 + + const n = history.length + const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW + const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH + + const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ') + const areaPath = history.length > 1 + ? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z` + : '' + + const yTicks = [] + const tickCount = Math.min(4, Math.ceil(yRange / 10)) + for (let i = 0; i <= tickCount; i++) { + const v = yMin + (yRange * i / tickCount) + yTicks.push({ v: Math.round(v), y: yFor(v) }) + } + + return { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH } +} +``` + +- [ ] **Step 4: Run all tests** + +Run: +```bash +cd react-app && npx vitest run src/test/utils.test.js +``` + +Expected: all 29 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/history.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add buildChartGeometry (pure SVG scale math)" +``` + +--- + +## Task 7: `components/SizeCell.jsx` + +**Files:** +- Create: `react-app/src/components/SizeCell.jsx` +- Create: `react-app/src/test/SizeCell.test.jsx` + +**Interfaces:** +- Consumes: `{ row: { size: string, sizes?: Array<{ size: string }> }, onSelect: (row, sizeLabel) => void }` +- Produces: a single-size `` when `row.sizes.length <= 1`; a ` { e.stopPropagation(); onSelect(row, e.target.value) }} + onClick={(e) => e.stopPropagation()} + variant="outlined" + IconComponent={() => null} + sx={{ + minWidth: 'auto', + height: 24, + '& .MuiSelect-select': { py: 0.15, px: 0.5, fontSize: { xs: '0.65rem', sm: '0.75rem' }, fontWeight: 600, paddingRight: '0.5px !important' }, + '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(59,130,246,0.3)' }, + '&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' }, + }} + renderValue={(v) => sizeShort(v)} + > + {sizes.map((s) => ( + + {sizeShort(s.size)} + + ))} + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/SizeCell.test.jsx +``` + +Expected: all 4 tests pass. If the `Select` combobox isn't found, the click on the combobox may be blocked by an overlay — wrap the component in a parent that has `pointer-events: auto` (the test wrapper already does). If `getByRole('option', { name: '10' })` fails, the menu may not have opened — use `await user.click(...)` and ensure the menu transition is awaited (Vitest with jsdom does not actually animate). + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/SizeCell.jsx react-app/src/test/SizeCell.test.jsx && git commit -m "feat(components): extract SizeCell from App" +``` + +--- + +## Task 8: `components/PointsHistoryChart.jsx` + +**Files:** +- Create: `react-app/src/components/PointsHistoryChart.jsx` +- (No dedicated test file — the chart is exercised by `GraphModal.test.jsx` in Task 9, which renders the chart end-to-end.) + +**Interfaces:** +- Consumes: `{ history: HistoryPoint[], W?: number = 640, H?: number = 360 }` +- Produces: the SVG (grid, area, line, points, labels) + +- [ ] **Step 1: Implement `PointsHistoryChart.jsx`** + +Direct extraction of the chart rendering from the current `GraphModal` (App.jsx lines 161–180). Create `react-app/src/components/PointsHistoryChart.jsx`: +```jsx +import React from 'react' +import { Box } from '@mui/material' +import { buildChartGeometry } from '../utils/history.js' + +const PADL = 70, PADR = 24, PADT = 24, PADB = 48 + +const fmtDate = (d) => { + const dt = new Date(d) + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) +} + +export function PointsHistoryChart({ history, W = 640, H = 360 }) { + const { xFor, yFor, yTicks, linePath, areaPath } = buildChartGeometry( + history, + { W, H, padL: PADL, padR: PADR, padT: PADT, padB: PADB }, + ) + + return ( + + + + {yTicks.map((t, i) => ( + + + {t.v} + + ))} + {areaPath && } + + {history.map((h, i) => ( + + + {h.pts} + {fmtDate(h.date)} + + ))} + + + + ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/PointsHistoryChart.jsx && git commit -m "feat(components): extract PointsHistoryChart from GraphModal" +``` + +--- + +## Task 9: `components/GraphModal.jsx` + test + +**Files:** +- Create: `react-app/src/components/GraphModal.jsx` +- Create: `react-app/src/test/GraphModal.test.jsx` + +**Interfaces:** +- Consumes: `{ row: Unit | null, open: boolean, onClose: () => void }` +- Produces: the MUI Modal with header, size dropdown (only when multi-size), embedded ``, footer summary, "No historical data" message + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/GraphModal.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { GraphModal } from '../components/GraphModal.jsx' + +const arco = fixture.units.find(u => u.name === 'Arco-flagellants') +const palatine = fixture.units.find(u => u.name === 'Palatine') +const newUnit = fixture.units.find(u => u.name === 'New Unit X') + +function renderModal(row, onClose = vi.fn()) { + return render() +} + +describe('GraphModal', () => { + it('renders the unit name and faction in the header', () => { + renderModal(palatine) + // Modal renders; the name appears in the dialog + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Adepta Sororitas')).toBeInTheDocument() + }) + + it('shows the size dropdown for multi-size units and updates the chart', async () => { + const user = userEvent.setup() + renderModal(arco) + // The size dropdown should be present (MUI Select) + const selects = screen.getAllByRole('combobox') + expect(selects.length).toBeGreaterThan(0) + // The chart should render at least one circle per data point on the small size (3 points) + const svg = document.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg.querySelectorAll('circle').length).toBe(3) + }) + + it('shows "No historical data" when the unit has empty history', () => { + renderModal(newUnit) + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderModal(palatine, onClose) + // The close button is a small IconButton with ✕ text + await user.click(screen.getByRole('button', { name: '✕' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders nothing when row is null', () => { + const { container } = render( {}} />) + // No header text should be present + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // container should be effectively empty (no dialog rendered with meaningful content) + expect(container.textContent).toBe('') + }) + + it('shows first/last version footer for multi-point history', () => { + renderModal(palatine) // 2 history points + // Footer should show "MFM 1.14: 50pts" and "MFM (current): 40pts" + expect(screen.getByText(/MFM 1\.14: 50pts/)).toBeInTheDocument() + expect(screen.getByText(/MFM \(current\): 40pts/)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/GraphModal.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `GraphModal.jsx`** + +Create `react-app/src/components/GraphModal.jsx`: +```jsx +import React, { useState, useEffect } from 'react' +import { + Box, Typography, IconButton, Modal, Paper, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material' +import { PointsHistoryChart } from './PointsHistoryChart.jsx' +import { sizeShort } from '../utils/format.js' + +export function GraphModal({ row, open, onClose }) { + const [graphSize, setGraphSize] = useState(null) + + useEffect(() => { + if (row) setGraphSize(row.size) + }, [row]) + + if (!row) return null + + const sizes = row.sizes || [] + const activeSize = graphSize || row.size + const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0] + const history = activeSizeData?.history || [] + + return ( + + + + + {row.name} + {row.faction_name} + + + + + {sizes.length > 1 && ( + + Model count + + + )} + + {history.length > 0 ? ( + + ) : ( + + No historical data for this unit. + + )} + + {history.length > 1 && ( + + + {history[0].version}: {history[0].pts}pts + + + {history[history.length - 1].version}: {history[history.length - 1].pts}pts + + + )} + + + ) +} +``` + +Note the added `aria-label="✕"` on the close button — this makes the close button queryable in the test by accessible name. The current production code does not have this attribute; the visual rendering is unchanged (the `✕` character still displays). + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/GraphModal.test.jsx +``` + +Expected: all 6 tests pass. If `getByText('Adepta Sororitas')` finds multiple matches (the faction name also appears in the movers card on a full App render — but here we render GraphModal in isolation, so only one match is expected). If `getByText(/no historical data/i)` fails, ensure the message text is exact: `"No historical data for this unit."`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/GraphModal.jsx react-app/src/test/GraphModal.test.jsx && git commit -m "feat(components): extract GraphModal from App" +``` + +--- + +## Task 10: `components/MoversPanel.jsx` + `MoversSection.jsx` + tests + +**Files:** +- Create: `react-app/src/components/MoversPanel.jsx` +- Create: `react-app/src/components/MoversSection.jsx` +- Create: `react-app/src/test/MoversSection.test.jsx` + +**Interfaces:** +- `MoversPanel`: + - Consumes: `{ title: string, units: Unit[], accent: 'success' | 'error', onSelectUnit: (row) => void, showFaction: boolean }` + - Produces: one Paper with a list of clickable rows +- `MoversSection`: + - Consumes: `{ movers: { drops, rises }, onSelectUnit, showFaction }` + - Produces: Container with two `MoversPanel` instances (drops=success, rises=error) + +- [ ] **Step 1: Write the failing test for `MoversSection`** + +Create `react-app/src/test/MoversSection.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { MoversSection } from '../components/MoversSection.jsx' + +const palatine = fixture.units.find(u => u.name === 'Palatine') +const intercessor = fixture.units.find(u => u.name === 'Intercessor Squad') +const dread = fixture.units.find(u => u.name === 'Redemptor Dreadnought') + +describe('MoversSection', () => { + it('renders both panel titles', () => { + render( + {}} + showFaction={true} + />, + ) + expect(screen.getByText('↓ Cheaper')).toBeInTheDocument() + expect(screen.getByText('↑ Costlier')).toBeInTheDocument() + }) + + it('lists units in the drops panel under "Cheaper"', () => { + render( + {}} + showFaction={true} + />, + ) + // Both drops should be present + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Intercessor Squad')).toBeInTheDocument() + // The rises unit should also be in the document + expect(screen.getByText('Redemptor Dreadnought')).toBeInTheDocument() + }) + + it('hides faction name in movers when showFaction is false', () => { + render( + {}} + showFaction={false} + />, + ) + // The unit name still shows + expect(screen.getByText('Palatine')).toBeInTheDocument() + // The faction name should NOT show in the mover row + // Note: it might still appear elsewhere on the page in a real App, but in this + // isolated render of MoversSection, the only place it would appear is inside + // the panel rows. So we expect it not to be present. + const rows = screen.getAllByText(/Adepta Sororitas/) + expect(rows.length).toBe(0) + }) + + it('calls onSelectUnit with the clicked row', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render( + , + ) + await user.click(screen.getByText('Palatine')) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(palatine) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/MoversSection.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `MoversPanel.jsx`** + +Create `react-app/src/components/MoversPanel.jsx`: +```jsx +import React from 'react' +import { Box, Paper, Typography, Stack } from '@mui/material' +import { pctLabel } from '../utils/format.js' + +export function MoversPanel({ title, units, accent, onSelectUnit, showFaction }) { + return ( + + {title} + + {units.map((u, i) => ( + onSelectUnit(u)} + > + + {u.name} + {showFaction && ( + {u.faction_name} + )} + + + {u.original}→{u.new} ({pctLabel(u.change_pct)}) + + + ))} + + + ) +} +``` + +- [ ] **Step 4: Implement `MoversSection.jsx`** + +Create `react-app/src/components/MoversSection.jsx`: +```jsx +import React from 'react' +import { Box, Container } from '@mui/material' +import { MoversPanel } from './MoversPanel.jsx' + +export function MoversSection({ movers, onSelectUnit, showFaction }) { + return ( + + + + + + + ) +} +``` + +- [ ] **Step 5: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/MoversSection.test.jsx +``` + +Expected: all 4 tests pass. If `getByText('Palatine')` finds the unit name in both the mover row AND somewhere else, use `getAllByText` and check the first one is the clickable one. If the click on the unit name does not bubble to the parent Box, ensure the Typography does not have `pointer-events: none` — MUI Typography doesn't by default. + +- [ ] **Step 6: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/MoversPanel.jsx react-app/src/components/MoversSection.jsx react-app/src/test/MoversSection.test.jsx && git commit -m "feat(components): extract MoversPanel + MoversSection from App" +``` + +--- + +## Task 11: `components/FilterBar.jsx` + test + +**Files:** +- Create: `react-app/src/components/FilterBar.jsx` +- Create: `react-app/src/test/FilterBar.test.jsx` + +**Interfaces:** +- Consumes: `{ query, setQuery, faction, setFaction, dir, setDir, factions, factionNames, isMobile, totalRows }` +- Produces: the sticky Container with title, subtitle, search field, faction select, change select + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/FilterBar.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FilterBar } from '../components/FilterBar.jsx' + +const defaultProps = { + query: '', + setQuery: vi.fn(), + faction: '', + setFaction: vi.fn(), + dir: '', + setDir: vi.fn(), + factions: ['adepta-sororitas', 'space-marines'], + factionNames: { 'adepta-sororitas': 'Adepta Sororitas', 'space-marines': 'Space Marines' }, + isMobile: false, + totalRows: 7, +} + +describe('FilterBar', () => { + it('renders title, subtitle with row count, and all three controls', () => { + render() + expect(screen.getByText('Points Comparator')).toBeInTheDocument() + expect(screen.getByText(/7 units/)).toBeInTheDocument() + expect(screen.getByLabelText('Search')).toBeInTheDocument() + expect(screen.getByLabelText('Faction')).toBeInTheDocument() + expect(screen.getByLabelText('Change')).toBeInTheDocument() + }) + + it('calls setQuery when the user types in the search field', async () => { + const user = userEvent.setup() + const setQuery = vi.fn() + render() + const search = screen.getByLabelText('Search') + await user.type(search, 'palatine') + // Each character fires setQuery once + expect(setQuery).toHaveBeenCalled() + expect(setQuery).toHaveBeenLastCalledWith('e') // last char typed + }) + + it('calls setFaction when the user picks a faction', async () => { + const user = userEvent.setup() + const setFaction = vi.fn() + render() + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + expect(setFaction).toHaveBeenCalledWith('space-marines') + }) + + it('calls setDir when the user picks a change direction', async () => { + const user = userEvent.setup() + const setDir = vi.fn() + render() + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + expect(setDir).toHaveBeenCalledWith('down') + }) + + it('uses "Δ" as the change-select label on mobile', () => { + render() + // On mobile, the label is the symbol + expect(screen.getByLabelText('Δ')).toBeInTheDocument() + // The longer "Change" label should not be present + expect(screen.queryByLabelText('Change')).not.toBeInTheDocument() + }) + + it('includes an "All" option in both selects', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Faction')) + expect(screen.getByRole('option', { name: 'All' })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/FilterBar.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `FilterBar.jsx`** + +Direct extraction of the sticky filter band (App.jsx lines 371–416). Create `react-app/src/components/FilterBar.jsx`: +```jsx +import React from 'react' +import { + Box, Container, Typography, TextField, Select, MenuItem, InputLabel, + FormControl, Stack, +} from '@mui/material' + +export function FilterBar({ + query, setQuery, + faction, setFaction, + dir, setDir, + factions, factionNames, + isMobile, totalRows, +}) { + return ( + + + + Points Comparator + + + Codex vs. MFM v4.3 · {totalRows.toLocaleString()} units · green = cheaper · red = costlier + + + setQuery(e.target.value)} + placeholder="Unit name…" + sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }} + /> + + + Faction + + + + {isMobile ? 'Δ' : 'Change'} + + + + + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/FilterBar.test.jsx +``` + +Expected: all 6 tests pass. If `screen.getByLabelText('Search')` fails, MUI's `TextField` derives the label-id from the `label` prop. If `getByRole('option', { name: 'Space Marines' })` doesn't work, use `getByText` instead — MUI MenuItem text is accessible. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/FilterBar.jsx react-app/src/test/FilterBar.test.jsx && git commit -m "feat(components): extract FilterBar from App" +``` + +--- + +## Task 12: `utils/columns.jsx` + +**Files:** +- Create: `react-app/src/utils/columns.jsx` +- (No dedicated test file — exercised by `UnitTable.test.jsx` in Task 13 and `App.test.jsx` in Task 15.) + +**Interfaces:** +- Consumes: `{ isMobile: boolean, showFactionCol: boolean, onSelectSize: (row, sizeLabel) => void }` +- Produces: `GridColDef[]` — Faction (optional, unshifted), Unit, #, Old, New, Δ pts (desktop only, spliced at index 4), Δ % + +- [ ] **Step 1: Implement `columns.jsx`** + +Direct extraction of the column-definitions block (App.jsx lines 291–365). Create `react-app/src/utils/columns.jsx`: +```jsx +import React from 'react' +import { Box, Typography } from '@mui/material' +import { SizeCell } from '../components/SizeCell.jsx' +import { pctLabel, ptsLabel, changeColor } from './format.js' + +export function buildColumns({ isMobile, showFactionCol, onSelectSize }) { + const cols = [ + { + field: 'name', headerName: 'Unit', flex: 3, minWidth: 80, + renderCell: (p) => ( + + + {p.row.name} + + + ), + }, + { + field: 'size', headerName: '#', flex: 0.6, minWidth: 36, + renderCell: (p) => , + }, + { + field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.original ?? '—'} + + ), + }, + { + field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.new ?? '—'} + + ), + }, + { + field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {pctLabel(p.row.change_pct)} + + ), + }, + ] + + // Δ pts column — desktop only + if (!isMobile) { + cols.splice(4, 0, { + field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {ptsLabel(p.row.change_pts)} + + ), + }) + } + + if (showFactionCol) { + cols.unshift({ + field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80, + renderCell: (p) => ( + + {p.row.faction_name} + + ), + }) + } + return cols +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/columns.jsx && git commit -m "feat(utils): extract buildColumns from App" +``` + +--- + +## Task 13: `components/UnitTable.jsx` + test + +**Files:** +- Create: `react-app/src/components/UnitTable.jsx` +- Create: `react-app/src/test/UnitTable.test.jsx` + +**Interfaces:** +- Consumes: `{ rows: Unit[], columns: GridColDef[], filteredCount: number, onCellClick: (row) => void }` +- Produces: Paper with the count header + DataGrid. The size-column click is suppressed (returns early). + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/UnitTable.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { UnitTable } from '../components/UnitTable.jsx' +import { buildColumns } from '../utils/columns.jsx' + +const columns = buildColumns({ isMobile: false, showFactionCol: true, onSelectSize: () => {} }) + +describe('UnitTable', () => { + it('renders the count header with the filtered count', () => { + render( {}} />) + // "X units" is rendered (X being the filteredCount) + expect(screen.getByText(new RegExp(`${fixture.units.length} units`))).toBeInTheDocument() + }) + + it('renders all unit names in the grid', () => { + render( {}} />) + for (const u of fixture.units) { + expect(screen.getByText(u.name)).toBeInTheDocument() + } + }) + + it('calls onCellClick with the clicked row when a non-size cell is clicked', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const palatine = fixture.units.find(u => u.name === 'Palatine') + render() + // Click the Old cell for Palatine (60) + const oldCell = screen.getByText('60') + await user.click(oldCell) + expect(onCellClick).toHaveBeenCalledTimes(1) + expect(onCellClick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Palatine' })) + }) + + it('does NOT call onCellClick when the size cell is clicked (size cell handles its own clicks)', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const arco = fixture.units.find(u => u.name === 'Arco-flagellants') + render() + // The size column shows a Select for multi-size units + const combobox = screen.getByRole('combobox') + await user.click(combobox) + // No onCellClick yet + expect(onCellClick).not.toHaveBeenCalled() + }) + + it('renders em-dash for new units (null original)', () => { + const newUnit = fixture.units.find(u => u.name === 'New Unit X') + render( {}} />) + // At least one em-dash should be in the document + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThan(0) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/UnitTable.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `UnitTable.jsx`** + +Direct extraction of the DataGrid Paper (App.jsx lines 465–502). Create `react-app/src/components/UnitTable.jsx`: +```jsx +import React from 'react' +import { Box, Container, Paper, Typography } from '@mui/material' +import { DataGrid } from '@mui/x-data-grid' + +export function UnitTable({ rows, columns, filteredCount, onCellClick }) { + return ( + + + + + {filteredCount.toLocaleString()} units + + + Click a unit for points history → + + + `${row.faction}|${row.name}`} + density="compact" + autoHeight + hideFooter + disableColumnMenu + onCellClick={(p) => { + if (p.field === 'size') return + onCellClick(p.row) + }} + sx={{ + border: 'none', + width: '100%', + '& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }, + '& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 }, + '& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } }, + '& .MuiDataGrid-columnSeparator': { display: 'none' }, + '& .MuiDataGrid-iconButtonContainer': { display: 'none' }, + '& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } }, + '& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' }, + '& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' }, + }} + /> + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/UnitTable.test.jsx +``` + +Expected: all 5 tests pass. If `getByText('60')` matches multiple cells (e.g. New Unit X also has the em-dash which is `—`, not `60`, so this should be fine). If clicking the cell doesn't fire `onCellClick`, the DataGrid's internal click handler may be on a wrapper — click the Typography directly. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/UnitTable.jsx react-app/src/test/UnitTable.test.jsx && git commit -m "feat(components): extract UnitTable from App" +``` + +--- + +## Task 14: Extend `test/setup.js` with `renderWithTheme` and add a shared helper + +**Files:** +- Modify: `react-app/src/test/setup.js` + +**Why:** Multiple tests need to render inside `ThemeProvider` (App test, possibly component tests). Centralize it. + +- [ ] **Step 1: Update `test/setup.js`** + +Replace the current contents of `react-app/src/test/setup.js`: +```js +import '@testing-library/jest-dom/vitest' +``` + +With: +```js +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' + +const theme = createTheme({ + palette: { + mode: 'dark', + background: { default: '#0a0e14', paper: '#11161e' }, + primary: { main: '#3b82f6' }, + secondary: { main: '#60a5fa' }, + success: { main: '#3fb950' }, + error: { main: '#f85149' }, + text: { primary: '#e6edf3', secondary: '#7d8590' }, + divider: '#232b38', + }, + typography: { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', fontSize: 14 }, + shape: { borderRadius: 8 }, +}) + +export function renderWithTheme(ui, options = {}) { + return render( + + + {ui} + , + options, + ) +} +``` + +- [ ] **Step 2: Run all existing tests to make sure they still pass** + +Run: +```bash +cd react-app && npx vitest run +``` + +Expected: all 56+ tests from Tasks 3–13 pass. The `renderWithTheme` helper is now available but not yet required — individual component tests can use it in the App test. + +- [ ] **Step 3: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/test/setup.js && git commit -m "test: add renderWithTheme helper to test setup" +``` + +--- + +## Task 15: Add `data-testid` to the App's wrapping Box (for the App test) + +**Files:** +- Modify: `react-app/src/App.jsx` (the only change to App in this task) + +**Why:** The end-to-end App test needs a stable handle for "the root Box" to scope `getByText` queries so the movers card and the table don't conflict. + +- [ ] **Step 1: Read the current `App.jsx` and find the root Box** + +The current root Box is at line 370: +```jsx + +``` + +- [ ] **Step 2: Add a `data-testid` to it** + +In `react-app/src/App.jsx`, change: +```jsx + +``` + +To: +```jsx + +``` + +- [ ] **Step 3: Commit (this is the smallest possible first edit to App.jsx; the full rewrite happens in Task 16)** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/App.jsx && git commit -m "refactor(app): add data-testid to root Box for tests" +``` + +--- + +## Task 16: Rewrite `App.jsx` as the orchestrator + +**Files:** +- Modify: `react-app/src/App.jsx` (full rewrite, ~90 lines) + +**Why last:** `App` imports from everything; doing it last means the new tree is stable first. + +- [ ] **Step 1: Write the failing App test (placeholder for now)** + +Create `react-app/src/test/App.test.jsx` with the full end-to-end suite. This test will FAIL until Task 16's `App.jsx` rewrite is in place. **Add it now so the test scaffolding is ready.** + +```jsx +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { renderWithTheme } from './setup.js' +import App from '../App.jsx' + +function mockFetch(data) { + globalThis.fetch = vi.fn(() => + Promise.resolve({ json: () => Promise.resolve(data) }), + ) +} + +describe('App (end-to-end UI)', () => { + beforeEach(() => { + // Reset URL to bare pathname so each test starts fresh + window.history.replaceState(null, '', '/') + mockFetch(fixture) + }) + + it('shows a loading state then renders rows', async () => { + renderWithTheme() + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument() + }) + expect(screen.getByText('Palatine')).toBeInTheDocument() + }) + + it('filters rows by search query (case-insensitive)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.type(screen.getByLabelText('Search'), 'pala') + await waitFor(() => { + // Palatine is still visible + expect(screen.getByText('Palatine')).toBeInTheDocument() + // Other units are filtered out + expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument() + }) + }) + + it('filters rows by faction and hides the Faction column', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + expect(screen.getByText('Intercessor Squad')).toBeInTheDocument() + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // The "Faction" column header should NOT be visible when filtered to one faction + expect(screen.queryByText('Faction')).not.toBeInTheDocument() + }) + }) + + it('filters rows by change direction (down = cheaper only)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + await waitFor(() => { + expect(screen.getByText('Palatine')).toBeInTheDocument() + // Redemptor Dreadnought is costlier, should be filtered out + expect(screen.queryByText('Redemptor Dreadnought')).not.toBeInTheDocument() + }) + }) + + it('writes filters to the URL and rehydrates from them on load', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + expect(window.location.search).toContain('faction=space-marines') + }) + }) + + it('rehydrates filters from URL params on load', async () => { + window.history.replaceState(null, '', '/?faction=necrons') + renderWithTheme() + await waitFor(() => { + // Only Necrons units visible + expect(screen.getByText('New Unit X')).toBeInTheDocument() + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + }) + }) + + it('updates the active size of a multi-size unit when the # cell is changed', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Arco-flagellants')) + // Arco-flagellants has 3 models (default) and 10 models + // Click the size dropdown + const sizeSelect = screen.getByRole('combobox') + await user.click(sizeSelect) + // Choose "10" (the larger size) + await user.click(screen.getByRole('option', { name: '10' })) + // The row's "New" value should update from 50 to 140 + await waitFor(() => { + // The "10" option click should have changed the displayed value + // The Old and New for the 10-models size are both 140 + const cells = screen.getAllByText('140') + expect(cells.length).toBeGreaterThan(0) + }) + }) + + it('opens the GraphModal when a non-size cell is clicked', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + // Click the Old cell for Palatine (50) + await user.click(screen.getByText('50')) + // Modal should now be visible with Palatine's name + await waitFor(() => { + // Both the grid cell and the modal header show "Palatine" — getAllByText + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + // The "No historical data" message should NOT be present (Palatine has history) + expect(screen.queryByText(/no historical data/i)).not.toBeInTheDocument() + }) + + it('shows "No historical data" for units with empty history', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('New Unit X')) + // Click on the "New Unit X" row (no history) + await user.click(screen.getByText('New Unit X')) + await waitFor(() => { + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + }) + + it('movers cards reflect the filtered set and clicking a row opens the modal', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('↓ Cheaper')) + // The cheapest unit is Palatine at -20% + // Click on the mover row containing Palatine + const cheaperPanel = screen.getByText('↓ Cheaper').parentElement + const palatineInPanel = within(cheaperPanel).getByText('Palatine') + await user.click(palatineInPanel) + // The modal opens (Palatine's name appears more than once) + await waitFor(() => { + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails (App.jsx still has the old shape)** + +Run: +```bash +cd react-app && npx vitest run src/test/App.test.jsx +``` + +Expected: FAIL — most tests will fail because the new components don't exist yet, OR (if the components do exist) because the old App.jsx still contains inline GraphModal/SizeCell/FilterBar/etc. Either way, the failure proves the test is exercising the right things. + +- [ ] **Step 3: Rewrite `App.jsx`** + +Replace the entire contents of `react-app/src/App.jsx` with the orchestrator. This is the final form per the spec. + +```jsx +import React, { useState, useMemo, useCallback, useEffect } from 'react' +import { Box, useMediaQuery, useTheme } from '@mui/material' +import { FilterBar } from './components/FilterBar.jsx' +import { MoversSection } from './components/MoversSection.jsx' +import { UnitTable } from './components/UnitTable.jsx' +import { GraphModal } from './components/GraphModal.jsx' +import { computeMovers } from './utils/movers.js' +import { buildColumns } from './utils/columns.jsx' +import { readFiltersFromUrl, writeFiltersToUrl } from './utils/url.js' + +export default function App() { + const [data, setData] = useState(null) + const initial = useMemo(() => readFiltersFromUrl(), []) + const [query, setQuery] = useState(initial.q) + const [faction, setFaction] = useState(initial.faction) + const [dir, setDir] = useState(initial.dir) + const [sizeChoice, setSizeChoice] = useState({}) + const [modalRow, setModalRow] = useState(null) + + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + useEffect(() => { + fetch('./data.json').then(r => r.json()).then(setData).catch(console.error) + }, []) + + useEffect(() => { + writeFiltersToUrl({ q: query, faction, dir }) + }, [query, faction, dir]) + + const selectSize = useCallback((row, sizeLabel) => { + const key = `${row.faction}|${row.name}` + setSizeChoice(prev => ({ ...prev, [key]: sizeLabel })) + }, []) + + const augmented = useMemo(() => { + if (!data) return [] + return data.units.map(u => { + const key = `${u.faction}|${u.name}` + const chosen = sizeChoice[key] + const active = (chosen && u.sizes.find(s => s.size === chosen)) || u.sizes.find(s => s.size === u.default_size) || u.sizes[0] + return { + ...u, + size: active.size, + original: active.original, + new: active.new, + change_pct: active.change_pct, + change_pts: active.change_pts, + tier: active.tier, + } + }) + }, [data, sizeChoice]) + + const filtered = useMemo(() => { + if (!augmented.length) return [] + let view = augmented + const q = query.trim().toLowerCase() + if (q) { + view = view.filter(u => + u.name.toLowerCase().includes(q) || + u.faction_name.toLowerCase().includes(q) || + u.size.toLowerCase().includes(q) + ) + } + if (faction) view = view.filter(u => u.faction === faction) + if (dir === 'up') view = view.filter(u => u.change_pct !== null && u.change_pct > 0) + else if (dir === 'down') view = view.filter(u => u.change_pct !== null && u.change_pct < 0) + else if (dir === 'no-change') view = view.filter(u => u.change_pct === 0) + else if (dir === 'new-only') view = view.filter(u => u.original === null && u.new !== null) + else if (dir === 'old-only') view = view.filter(u => u.original !== null && u.new === null) + return view + }, [augmented, query, faction, dir]) + + const movers = useMemo(() => computeMovers(filtered), [filtered]) + const showFactionCol = !faction + const showFactionInMovers = !faction + const columns = useMemo( + () => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }), + [isMobile, showFactionCol, selectSize], + ) + + if (!data) return Loading… + + return ( + + + + + setModalRow(null)} /> + + ) +} +``` + +- [ ] **Step 4: Run the App test** + +Run: +```bash +cd react-app && npx vitest run src/test/App.test.jsx +``` + +Expected: all 11 tests pass. If `getByLabelText('Search')` fails, ensure the FilterBar renders the TextField with `label="Search"` (it does). If `getByText('Loading…')` doesn't appear (because the fetch resolves synchronously in tests), the `waitFor` for the loaded state should still succeed — the loading state may flash and be gone before `getByText` can see it. If that test fails, change the assertion to: `await waitFor(() => screen.getByText('Palatine'))` and remove the loading-state assertion. + +- [ ] **Step 5: Run the full test suite** + +Run: +```bash +cd react-app && npx vitest run +``` + +Expected: all tests pass (utils + SizeCell + GraphModal + MoversSection + FilterBar + UnitTable + App). + +- [ ] **Step 6: Run the build** + +Run: +```bash +cd react-app && npm run build +``` + +Expected: build succeeds with no errors. The `dist/` directory is created. + +- [ ] **Step 7: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/App.jsx react-app/src/test/App.test.jsx && git commit -m "refactor: rewrite App.jsx as orchestrator over new component tree" +``` + +--- + +## Task 17: Final verification + +**Files:** none — verification only. + +- [ ] **Step 1: Full test run** + +```bash +cd react-app && npm test -- --run +``` + +Expected: all tests pass. + +- [ ] **Step 2: Full build** + +```bash +cd react-app && npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Manual smoke (matches the spec's verification step 3)** + +```bash +cd react-app && npm run dev +``` + +Then in the browser at the dev URL: +- [ ] Page loads, "Loading…" disappears, rows appear. +- [ ] Search filters by name/faction_name/size. +- [ ] Faction select filters; Faction column disappears. +- [ ] Change select filters by direction. +- [ ] URL query string updates; reload restores state. +- [ ] Multi-size `#` cell click changes the row's Old/New/Δ values. +- [ ] Click any other cell → modal opens with that unit's history. +- [ ] Modal size dropdown switches the chart's history for multi-size units. +- [ ] Empty-history units show "No historical data". +- [ ] Movers cards reflect the filtered set; clicking a row opens the same modal. + +- [ ] **Step 4: Final diff check** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git diff --stat master +``` + +Expected: changes are confined to `react-app/src/` (plus `react-app/package.json` and `react-app/vite.config.js`). No changes to `main.jsx`, `index.html`, Dockerfile, or Python scripts. + +- [ ] **Step 5: Tag the refactor (optional)** + +If everything is green: +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git tag refactor/component-tree +``` + +--- + +## Self-Review Notes (for the plan author) + +- **Spec coverage:** every bullet in the spec's "Verification" step 3 maps to a test in Task 15. Every component and util from the spec is created. The test strategy section is mirrored by Tasks 1 (install), 2 (fixture), 14 (theme helper). +- **Placeholder scan:** no TBDs. Every code block is a complete file or a complete edit. +- **Type/signature consistency:** the same function names and parameter shapes appear in both the producing task and every consuming task (e.g. `computeMovers(filtered) -> { drops, rises }` is consistent across Tasks 5, 10, 16; `buildColumns({ isMobile, showFactionCol, onSelectSize })` is consistent across Tasks 12, 13, 16; `readFiltersFromUrl() -> { q, faction, dir }` matches the destructure in Task 16's `App`). +- **Order matters:** Task 7 (`SizeCell`) is required by Task 12 (`buildColumns`). Task 12 is required by Task 13 (`UnitTable`). Task 16 (`App`) requires everything. The task numbers reflect the dependency order. +- **One non-obvious decision worth flagging:** in Task 9, the `IconButton` for the modal close gets an explicit `aria-label="✕"` so the test can find it by accessible name. This is a tiny production change (the visual `✕` still renders) and is the only accessibility attribute added during the refactor. +- **One risk worth flagging:** the DataGrid's click semantics in `UnitTable` are exercised in Task 13's test by clicking on a cell's inner ``. If MUI's DataGrid wraps clicks in a way that the Typography click doesn't bubble, the test will fail — in that case, click the parent cell by adding a `data-testid` to the Typography or use `fireEvent.click` on the cell row. Try the natural approach first. diff --git a/docs/superpowers/specs/2026-06-23-react-component-tree-refactor-design.md b/docs/superpowers/specs/2026-06-23-react-component-tree-refactor-design.md new file mode 100644 index 0000000..98f39e0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-react-component-tree-refactor-design.md @@ -0,0 +1,384 @@ +# React Component Tree Refactor — WH40K Points Comparator + +**Date:** 2026-06-23 +**Scope:** Restructure `react-app/src/App.jsx` from a single ~510-line file into a +presentational component tree with a `utils/` library. Zero behavior change. + +## Goals + +- Make each file small enough to hold in context at once (target: ≤120 lines). +- Mirror the visible UI regions in the file tree so navigation is obvious. +- Pull the densest code paths (DataGrid column defs, chart scale math) into + pure-function modules that are unit-testable without rendering. +- Preserve all current behavior exactly: same theme, same URL state, same + responsive breakpoints, same DataGrid click semantics, same modal. + +## Non-Goals + +- No new features, no new filters, no new columns. +- No state-management library (Redux/Zustand) — `useState`/`useMemo` in `App`. +- No E2E browser automation (Playwright/Cypress) — component-level UI tests are + the right granularity for a single-page read-only app with no backend. +- No changes to `main.jsx`, `index.html`, `vite.config.js` (beyond adding Vitest + config), Dockerfile, `docker-compose.yml`, or any of the data-build Python + scripts. + +## Tech Stack (tests) + +- **Test framework:** Vitest + `@testing-library/react` + `@testing-library/jest-dom` + + `jsdom` + `@testing-library/user-event`. Vite-native, configured via a + `test` block in `vite.config.js` (no separate config file). All + devDependencies — ship no code to production. +- **Coverage target:** not a number — every behavior bullet under `Verification` + step 3 has a corresponding test. The component tests catch single-component + regressions; the App test catches wiring regressions (URL state, derived + data, modal open/close). + +## Target File Tree + +``` +react-app/src/ + main.jsx ← unchanged + App.jsx ← orchestration only (~90 lines) + components/ + FilterBar.jsx ← sticky header + search/faction/change controls + MoversSection.jsx ← layout wrapper for the two movers cards + MoversPanel.jsx ← one movers card (drops OR rises) + UnitTable.jsx ← DataGrid + count header + SizeCell.jsx ← grid cell for the # column + GraphModal.jsx ← modal shell (header, size dropdown, footer) + PointsHistoryChart.jsx ← pure SVG renderer for the history line chart + utils/ + format.js ← sizeShort, pctLabel, ptsLabel, changeColor + url.js ← readFiltersFromUrl, writeFiltersToUrl + movers.js ← computeMovers(filtered) -> { drops, rises } + columns.jsx ← buildColumns({ isMobile, showFactionCol, onSelectSize }) + history.js ← buildChartGeometry(history, dims) -> geometry + test/ + setup.js ← vitest setup (imports jest-dom matchers) + fixtures/ + data.json ← minimal mock dataset for tests + App.test.jsx ← end-to-end UI tests of the full App + FilterBar.test.jsx ← search + faction + change select behavior + UnitTable.test.jsx ← DataGrid renders rows; size cell click + MoversSection.test.jsx ← drops + rises render; click opens modal + GraphModal.test.jsx ← modal opens with history; size dropdown + SizeCell.test.jsx ← single vs multi-size rendering + click + utils.test.js ← smoke tests for format/url/movers/history +``` + +## Module Specs + +### `utils/format.js` — formatting helpers + +Pure functions, no React, no module state. + +| Function | Signature | Purpose | +|---|---|---| +| `sizeShort` | `(size: string) => string` | Strips trailing " model" / " models" from a unit-size label. | +| `pctLabel` | `(pct: number \| null) => string` | Formats a percent change with explicit `+` sign and one decimal. Returns `—` for null/undefined. | +| `ptsLabel` | `(pts: number \| null) => string` | Formats a points-delta with `+` prefix for positive values and bare `-` for negative (ASCII hyphen, no unicode minus). Returns `—` for null/undefined. | +| `changeColor` | `(val: number \| null) => string` | Returns `'#f85149'` (costlier/red) for `>0`, `'#3fb950'` (cheaper/green) for `<0`, `'text.secondary'` for null/zero. | + +### `utils/url.js` — URL ⇄ filter state + +| Function | Signature | Purpose | +|---|---|---| +| `readFiltersFromUrl` | `() => { q: string, faction: string, dir: string }` | Reads `?q=`, `?faction=`, `?dir=` from `window.location.search`. Missing keys default to `''`. | +| `writeFiltersToUrl` | `({ q, faction, dir }: { q: string, faction: string, dir: string }) => void` | Sets the three query params on the current URL via `history.replaceState`. Empties the query string when all three are empty (preserving `pathname`). | + +Both functions encapsulate the `window` dependency. The component effect becomes +a one-liner: `useEffect(() => writeFiltersToUrl({ q, faction, dir }), [q, faction, dir])`. + +### `utils/movers.js` — top-N by direction + +| Function | Signature | Purpose | +|---|---|---| +| `computeMovers` | `(filtered: Unit[]) => { drops: Unit[], rises: Unit[] }` | Returns up to 5 cheapest (largest negative `change_pct`) and 5 costliest (largest positive `change_pct`) units from the input. Empty input → `{ drops: [], rises: [] }`. Units with `change_pct === null` are excluded. | + +### `utils/columns.jsx` — DataGrid column definitions + +| Function | Signature | Purpose | +|---|---|---| +| `buildColumns` | `({ isMobile, showFactionCol, onSelectSize }: { isMobile: boolean, showFactionCol: boolean, onSelectSize: (row, sizeLabel) => void }) => GridColDef[]` | Returns the full column array in the same order as today: optional Faction (unshifted when `showFactionCol`), Unit, `#`, Old, New, `Δ pts` (desktop only, inserted at index 4), `Δ %`. All column metadata (flex/minWidth/align/renderCell) preserved exactly. | + +File extension is `.jsx` because `renderCell` returns JSX. Imports `SizeCell` from +`../components/SizeCell.jsx` and `pctLabel`/`ptsLabel`/`changeColor` from `./format.js`. + +### `utils/history.js` — chart scale math + +| Function | Signature | Purpose | +|---|---|---| +| `buildChartGeometry` | `(history: HistoryPoint[], dims: { W, H, padL, padR, padT, padB }) => { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH }` | Computes the SVG geometry: scale functions, tick positions, the `M…L…` line path, the filled area path, and the inner chart dimensions. Pure; no React. | + +Empty/single-point history yields empty `linePath` and `areaPath === ''` (current +behavior). The component passes these strings directly into ``. + +## Component Specs + +### `components/SizeCell.jsx` + +Same behavior as the current inline `SizeCell` in `App.jsx` (lines 36–74). Props: +`{ row, onSelect }`. Returns the `#`-column cell. No change in rendering. + +### `components/FilterBar.jsx` + +The sticky header band (title, subtitle, search field, faction select, change +select). Props: +``` +{ + query: string, setQuery: (s: string) => void, + faction: string, setFaction: (s: string) => void, + dir: string, setDir: (s: string) => void, + factions: string[], + factionNames: Record, + isMobile: boolean, + totalRows: number, +} +``` + +Renders the same Container with the same MUI components in the same order. +Subscript text "Codex vs. MFM v4.3 · …" takes `totalRows` from props instead of +`data.stats.total_rows`. + +### `components/MoversSection.jsx` + +Wraps the two movers cards in their side-by-side Container. Props: +``` +{ + movers: { drops: Unit[], rises: Unit[] }, + onSelectUnit: (row: Unit) => void, + showFaction: boolean, +} +``` + +Renders two `` instances — left = drops (success/green), right = +rises (error/red). No data shaping here. + +### `components/MoversPanel.jsx` + +A single movers card. Props: +``` +{ + title: string, // "↓ Cheaper" | "↑ Costlier" + units: Unit[], + accent: 'success' | 'error', // controls the border + title color + onSelectUnit: (row: Unit) => void, + showFaction: boolean, +} +``` + +Renders the Paper with the accent border-left, the title in the accent color, +and a Stack of clickable rows. Row layout matches the current implementation +exactly (name, optional faction_name, mono `original→new (pct)` label). + +### `components/UnitTable.jsx` + +The DataGrid + count header card. Props: +``` +{ + rows: Unit[], + columns: GridColDef[], + filteredCount: number, + onCellClick: (row: Unit) => void, +} +``` + +Renders the Paper with the count header (`{filteredCount} units` and the +"Click a unit for points history →" hint) and the DataGrid. All `sx` styles +preserved exactly. The size-column click exception (`if (p.field === 'size') return`) +stays here. + +### `components/GraphModal.jsx` + +The modal shell. Props: +``` +{ row: Unit | null, open: boolean, onClose: () => void } +``` + +Owns the `graphSize` local state and the `useEffect` that syncs it to +`row.size` when `row` changes. Renders the Modal + Paper with header (name + +faction_name + close button), the size dropdown (only when `sizes.length > 1`), +embeds ``, and the footer summary +(first/last version) when there is more than one history point. Renders the +"No historical data" message when `history.length === 0`. + +### `components/PointsHistoryChart.jsx` + +Pure SVG renderer. Props: +``` +{ history: HistoryPoint[], W?: number = 640, H?: number = 360 } +``` + +Calls `buildChartGeometry` from `utils/history.js` to compute scales, ticks, +and paths, then renders the SVG (grid lines, area fill, line path, circles + +value labels, date labels along the x-axis). The `fmtDate` helper moves into +this file as a local const since only the chart uses it. Defaults W/H match +the current `GraphModal` constants (640 × 360). + +## Resulting `App.jsx` + +The orchestrator. Holds: +- State: `data`, `query`, `faction`, `dir`, `sizeChoice`, `modalRow`. +- Effects: data fetch (`./data.json`); URL sync via `writeFiltersToUrl`. +- Derived: `augmented`, `filtered`, `movers = useMemo(() => computeMovers(filtered), [filtered])`, + `columns = useMemo(() => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }), [...deps])`. +- JSX: `FilterBar`, `MoversSection`, `UnitTable`, `GraphModal` — composed in the + same Box → Container order as today. The `Loading…` guard is preserved. + +Target: ~90 lines. + +## Data Shape (unchanged) + +`./data.json` continues to be fetched on mount. Shape: +```ts +{ + units: Array<{ + name: string, + faction: string, + faction_name: string, + sizes: Array<{ + size: string, original: number | null, new: number | null, + change_pct: number | null, change_pts: number | null, + tier: string, history: Array<{ date: string, pts: number, version: string }> + }>, + default_size: string, + }>, + factions: string[], + faction_names: Record, + stats: { total_rows: number, ... }, +} +``` + +The `augmented` step in `App` flattens each unit to its active-size record — +unchanged. + +## Verification + +1. `cd react-app && npm test` — full Vitest suite must pass. This is the + primary verification for the refactor. Tests cover every UI behavior + listed in the manual smoke checklist below. +2. `cd react-app && npm run build` — must succeed. Catches circular imports, + missing exports, JSX syntax errors, broken path aliases. +3. `cd react-app && npm run dev` and manually verify, in order (these mirror + the test cases; the tests are the source of truth, this is a final + eyeball check): + a. Page loads, data populates, "Loading…" disappears. + b. Search field filters by name/faction_name/size (case-insensitive). + c. Faction select filters to one faction (Faction column disappears). + d. Change select filters by direction (up/down/no-change/new-only/old-only). + e. URL query string updates as any filter changes; reload restores state. + f. Click a `#` cell with multiple sizes → row's Old/New/Δ% updates. + g. Click any other cell → modal opens with the correct unit's history. + h. Modal size dropdown switches the chart's history when applicable. + i. Empty-history units render the "No historical data" message. + j. Movers cards reflect the currently filtered set; clicking a mover row + opens the same modal. +4. `git diff --stat` — should show the new files plus a much smaller + `App.jsx`. No file outside `react-app/src/` (plus the `package.json` + devDeps and the `vitest` config block in `vite.config.js`) should change. + +## Test Strategy + +**Framework:** Vitest + `@testing-library/react` + `@testing-library/jest-dom` ++ `jsdom` + `@testing-library/user-event`. Configured in `vite.config.js` via +a `test:` block (no separate config file). All test code lives under +`react-app/src/test/`. + +**Test categories:** + +1. **Pure-function smoke tests** (`test/utils.test.js`) — one or two assertions + per helper in `utils/format.js`, `utils/url.js`, `utils/movers.js`, + `utils/history.js`. No DOM, no React. Fast, deterministic, document the + contracts the components rely on. + +2. **Component tests** (`test/FilterBar.test.jsx`, `UnitTable.test.jsx`, + `MoversSection.test.jsx`, `GraphModal.test.jsx`, `SizeCell.test.jsx`) — + render the component in isolation with realistic props (drawn from the + `test/fixtures/data.json` fixture), drive user behavior with + `userEvent`, assert on rendered text / class / role. The handler props + (`onSelect`, `onSelectUnit`, `onCellClick`) are jest `vi.fn()`s and + assertions check they were called with the right args. + +3. **End-to-end App test** (`test/App.test.jsx`) — render `` inside + the real `ThemeProvider`, mock `fetch` to return the fixture, then + exercise the full user journey from the README's feature list: + - Page loads → "Loading…" disappears → table rows appear. + - Type into search → rows narrow; clear → rows restore. + - Select a faction → rows filter to that faction; the Faction column disappears. + - Select "↓ Cheaper" → only negative `change_pct` rows remain. + - URL query string reflects the filters (read `window.location.search`). + - Reload page with `?faction=...&dir=...&q=...` → filters rehydrate from URL. + - Click a multi-size `#` cell, choose a different size → row's Old/New/Δ + values update. + - Click any other cell → modal opens with the unit's name and history. + - Click an empty-history unit → "No historical data" appears. + - Click a row in the movers card → same modal opens. + + `data-testid` attributes will be added sparingly to `App.jsx` and a couple + of components only where the test needs a stable handle that the + accessible role/text does not provide (e.g. the DataGrid's internal cells, + which don't expose stable roles). + +**Mocking strategy:** `globalThis.fetch` is stubbed in each test that needs +data (the App test; component tests use fixture data directly via props). +No other module is mocked — the tests run against the real implementations +of the components and utils. The `ThemeProvider` wrapper is a small helper +in `test/setup.js` to avoid repeating it in every test file. + +**Coverage target:** not a number — every behavior bullet under +`Verification` step 3 has a test. The component tests give us confidence +that a one-off regression in a single component is caught; the App test +gives us confidence that the orchestrator (URL state, augmented/filtered +derivation, modal open/close) is wired correctly. + +## Migration Strategy + +Single-commit refactor. Steps: +1. Create `utils/format.js`, `utils/url.js`, `utils/movers.js` first (leaf + modules with no internal dependencies). +2. Create `components/SizeCell.jsx` (leaf, no internal imports beyond MUI). +3. Create `utils/history.js` (depends only on data shape). +4. Create `components/PointsHistoryChart.jsx` (depends on `utils/history.js`). +5. Create `components/GraphModal.jsx` (depends on `PointsHistoryChart` + `format`). +6. Create `components/MoversPanel.jsx` (depends on `format`). +7. Create `components/MoversSection.jsx` (depends on `MoversPanel`). +8. Create `components/FilterBar.jsx` (depends on `format`). +9. Create `utils/columns.jsx` (depends on `SizeCell` + `format`). +10. Create `components/UnitTable.jsx` (no internal `utils/` deps — receives + `rows`/`columns`/`filteredCount`/`onCellClick` from `App`). +11. Rewrite `App.jsx` last, importing from the new tree. + +After each step, `npm run build` confirms nothing is broken mid-refactor. + +## Risks + +- **Circular imports:** `utils/columns.jsx` imports `SizeCell`; `UnitTable` + receives the columns from `App` as a prop, so it does not need to import + them. No cycle expected. +- **JSX-in-`utils/`.** Only `columns.jsx` contains JSX. Renamed `.jsx` from + `.js` to make the JSX explicit to readers and to the Vite/Babel toolchain. + Vite handles `.jsx` and `.js` identically; no config change needed. +- **Stale closures in handlers.** The `selectSize` callback is wrapped in + `useCallback` today. The split preserves this — `App` still owns it and + passes it to `buildColumns` → `SizeCell` via prop drilling (one level). +- **URL effect timing.** The two URL effects in `App` (read on mount, write + on change) move to `App` unchanged. The two helpers in `utils/url.js` only + encapsulate the `window` calls; they don't change the lifecycle. +- **Test data fidelity.** The fixture must cover the variety of cases the + real `data.json` has: at least one unit with multiple sizes, one with no + history, one with `change_pct === 0`, one with `original === null` (new + unit), one with `new === null` (removed unit). Each App test case + references a specific unit from the fixture by name so the test is + self-documenting. +- **DataGrid in jsdom.** `@mui/x-data-grid` works under jsdom but the + internal virtual scroller can be slow. We test by querying by role + (`cell`, `row`) and by visible text. We do not test internal column + resize / sort behavior (out of scope — the app uses none of it). +- **Test runtime.** Vitest in jsdom is fast enough that the full suite + should run in <10s. If a particular test is slow, the fix is to render + smaller fixtures, not to mock React. + +## Open Questions + +None. diff --git a/react-app/package-lock.json b/react-app/package-lock.json index e119ae1..b5e358a 100644 --- a/react-app/package-lock.json +++ b/react-app/package-lock.json @@ -17,6 +17,34 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "vite": "^8.0.16" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^25.0.1", + "vitest": "^2.1.8" + } + }, + "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": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@babel/code-frame": { @@ -158,6 +186,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -335,6 +478,448 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1034,6 +1619,446 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1044,6 +2069,21 @@ "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/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -1100,6 +2140,154 @@ } } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1115,6 +2303,30 @@ "npm": ">=6" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1124,6 +2336,33 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1133,6 +2372,19 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -1164,12 +2416,54 @@ "node": ">= 6" } }, + "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/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1187,6 +2481,43 @@ } } }, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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", @@ -1196,6 +2527,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1206,6 +2545,34 @@ "csstype": "^3.0.2" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1215,6 +2582,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1224,6 +2601,85 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1236,6 +2692,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1259,6 +2735,23 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1282,6 +2775,87 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1309,6 +2883,60 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1325,6 +2953,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -1346,12 +2984,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1637,6 +3323,84 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1661,6 +3425,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1700,6 +3471,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1715,6 +3499,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1761,6 +3562,30 @@ "node": "^10 || ^12 || >=14" } }, + "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/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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1778,6 +3603,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1821,6 +3656,20 @@ "react-dom": ">=16.6.0" } }, + "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/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -1890,12 +3739,91 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "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", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "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": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -1914,6 +3842,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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "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/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -1932,6 +3887,27 @@ "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/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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1948,6 +3924,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2041,6 +4093,1219 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/react-app/package.json b/react-app/package.json index 78a4ecb..f7a01e8 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -2,11 +2,13 @@ "name": "react-app", "version": "1.0.0", "description": "", + "type": "module", "main": "index.js", "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest" }, "keywords": [], "author": "", @@ -20,5 +22,12 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "vite": "^8.0.16" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^25.0.1", + "vitest": "^2.1.8" } } diff --git a/react-app/src/App.jsx b/react-app/src/App.jsx index f91d9b0..24bc2a6 100644 --- a/react-app/src/App.jsx +++ b/react-app/src/App.jsx @@ -1,234 +1,31 @@ -import React, { useState, useMemo, useCallback } from 'react' -import { - Box, Container, Typography, TextField, Select, MenuItem, InputLabel, - FormControl, Stack, IconButton, Modal, Paper, - useMediaQuery, useTheme, -} from '@mui/material' -import { DataGrid } from '@mui/x-data-grid' - -// ── helpers ── - -function sizeShort(size) { - return size.replace(/\s*models?$/, '') -} - -function pctLabel(pct) { - if (pct === null || pct === undefined) return '—' - const sign = pct > 0 ? '+' : '' - return `${sign}${pct.toFixed(1)}%` -} - -function ptsLabel(pts) { - if (pts === null || pts === undefined) return '—' - return pts > 0 ? `+${pts}` : `${pts}` -} - -// Color: red = more expensive (bad for player), green = cheaper (good for player) -function changeColor(val) { - if (val === null || val === undefined) return 'text.secondary' - if (val > 0) return '#f85149' // costlier = red - if (val < 0) return '#3fb950' // cheaper = green - return 'text.secondary' -} - -// ── Size dropdown cell ── - -function SizeCell({ row, onSelect }) { - const sizes = row.sizes || [] - if (sizes.length <= 1) { - return ( - - - {sizeShort(row.size)} - - - ) - } - return ( - - - - ) -} - -// ── Line graph modal ── - -function GraphModal({ row, open, onClose }) { - const [graphSize, setGraphSize] = useState(null) - - React.useEffect(() => { - if (row) setGraphSize(row.size) - }, [row]) - - if (!row) return null - - const sizes = row.sizes || [] - const activeSize = graphSize || row.size - const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0] - const history = activeSizeData?.history || [] - - const W = 640, H = 360, padL = 70, padR = 24, padT = 24, padB = 48 - const chartW = W - padL - padR - const chartH = H - padT - padB - - const pts = history.map(h => h.pts) - const minPts = Math.min(...pts, 0) - const maxPts = Math.max(...pts, 1) - const ptsRange = maxPts - minPts || 1 - const padY = ptsRange * 0.15 - const yMin = Math.max(0, minPts - padY) - const yMax = maxPts + padY - const yRange = yMax - yMin || 1 - - const n = history.length - const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW - const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH - - const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ') - const areaPath = history.length > 1 - ? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z` - : '' - - const yTicks = [] - const tickCount = Math.min(4, Math.ceil(yRange / 10)) - for (let i = 0; i <= tickCount; i++) { - const v = yMin + (yRange * i / tickCount) - yTicks.push({ v: Math.round(v), y: yFor(v) }) - } - - const fmtDate = (d) => { - const dt = new Date(d) - return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) - } - - return ( - - - - - {row.name} - {row.faction_name} - - - - - {sizes.length > 1 && ( - - Model count - - - )} - - {history.length > 0 ? ( - - - - {yTicks.map((t, i) => ( - - - {t.v} - - ))} - {areaPath && } - - {history.map((h, i) => ( - - - {h.pts} - {fmtDate(h.date)} - - ))} - - - - ) : ( - - No historical data for this unit. - - )} - - {history.length > 1 && ( - - - {history[0].version}: {history[0].pts}pts - - - {history[history.length - 1].version}: {history[history.length - 1].pts}pts - - - )} - - - ) -} - -// ── Main App ── +import React, { useState, useMemo, useCallback, useEffect } from 'react' +import { Box, useMediaQuery, useTheme } from '@mui/material' +import { FilterBar } from './components/FilterBar.jsx' +import { MoversSection } from './components/MoversSection.jsx' +import { UnitTable } from './components/UnitTable.jsx' +import { GraphModal } from './components/GraphModal.jsx' +import { computeMovers } from './utils/movers.js' +import { buildColumns } from './utils/columns.jsx' +import { readFiltersFromUrl, writeFiltersToUrl } from './utils/url.js' export default function App() { const [data, setData] = useState(null) - // Read initial state from URL params - const params = new URLSearchParams(window.location.search) - const [query, setQuery] = useState(params.get('q') || '') - const [faction, setFaction] = useState(params.get('faction') || 'adepta-sororitas') - const [dir, setDir] = useState(params.get('dir') || '') + const initial = useMemo(() => readFiltersFromUrl(), []) + const [query, setQuery] = useState(initial.q) + const [faction, setFaction] = useState(initial.faction || 'adepta-sororitas') + const [dir, setDir] = useState(initial.dir) const [sizeChoice, setSizeChoice] = useState({}) const [modalRow, setModalRow] = useState(null) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - React.useEffect(() => { + useEffect(() => { fetch('./data.json').then(r => r.json()).then(setData).catch(console.error) }, []) - // Sync filter state to URL whenever it changes - React.useEffect(() => { - const p = new URLSearchParams() - if (query) p.set('q', query) - if (faction) p.set('faction', faction) - if (dir) p.set('dir', dir) - const qs = p.toString() - const newUrl = qs ? `?${qs}` : window.location.pathname - window.history.replaceState(null, '', newUrl) + useEffect(() => { + writeFiltersToUrl({ q: query, faction, dir }) }, [query, faction, dir]) const selectSize = useCallback((row, sizeLabel) => { @@ -274,234 +71,38 @@ export default function App() { return view }, [augmented, query, faction, dir]) - // Movers — based on the currently filtered view - const movers = useMemo(() => { - if (!filtered.length) return { drops: [], rises: [] } - const drops = filtered.filter(u => u.change_pct !== null && u.change_pct < 0) - .sort((a, b) => a.change_pct - b.change_pct).slice(0, 5) - const rises = filtered.filter(u => u.change_pct !== null && u.change_pct > 0) - .sort((a, b) => b.change_pct - a.change_pct).slice(0, 5) - return { drops, rises } - }, [filtered]) - + const movers = useMemo(() => computeMovers(filtered), [filtered]) const showFactionCol = !faction - const showFactionInMovers = !faction // hide faction name in movers when filtered to a faction - - // Columns: all flex-based - const columns = useMemo(() => { - const cols = [ - { - field: 'name', headerName: 'Unit', flex: 3, minWidth: 80, - renderCell: (p) => ( - - - {p.row.name} - - - ), - }, - { - field: 'size', headerName: '#', flex: 0.6, minWidth: 36, - renderCell: (p) => , - }, - { - field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', - renderCell: (p) => ( - - {p.row.original ?? '—'} - - ), - }, - { - field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', - renderCell: (p) => ( - - {p.row.new ?? '—'} - - ), - }, - { - field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right', - renderCell: (p) => ( - - {pctLabel(p.row.change_pct)} - - ), - }, - ] - - // Δ pts column — desktop only - if (!isMobile) { - cols.splice(4, 0, { - field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right', - renderCell: (p) => ( - - {ptsLabel(p.row.change_pts)} - - ), - }) - } - - if (showFactionCol) { - cols.unshift({ - field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80, - renderCell: (p) => ( - - {p.row.faction_name} - - ), - }) - } - return cols - }, [selectSize, showFactionCol, isMobile]) + const showFactionInMovers = !faction + const columns = useMemo( + () => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }), + [isMobile, showFactionCol, selectSize], + ) if (!data) return Loading… return ( - {/* Sticky filter bar */} - - - - Points Comparator - - - Codex vs. MFM v4.3 · {data.stats.total_rows.toLocaleString()} units · green = cheaper · red = costlier - - - setQuery(e.target.value)} - placeholder="Unit name…" - sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }} - /> - - - Faction - - - - {isMobile ? 'Δ' : 'Change'} - - - - - - - - {/* Movers — based on current filtered view, click opens modal */} - - - {/* Drops = cheaper = green (good for player) */} - - ↓ Cheaper - - {movers.drops.map((u, i) => ( - setModalRow(u)}> - - {u.name} - {showFactionInMovers && ( - {u.faction_name} - )} - - - {u.original}→{u.new} ({pctLabel(u.change_pct)}) - - - ))} - - - {/* Rises = costlier = red (bad for player) */} - - ↑ Costlier - - {movers.rises.map((u, i) => ( - setModalRow(u)}> - - {u.name} - {showFactionInMovers && ( - {u.faction_name} - )} - - - {u.original}→{u.new} ({pctLabel(u.change_pct)}) - - - ))} - - - - - - {/* Data Grid */} - - - {/* Count label — inside the table card, anchored */} - - - {filtered.length.toLocaleString()} units - - - Click a unit for points history → - - - `${row.faction}|${row.name}`} - density="compact" - autoHeight - hideFooter - disableColumnMenu - onCellClick={(p) => { - if (p.field === 'size') return - setModalRow(p.row) - }} - sx={{ - border: 'none', - width: '100%', - '& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }, - '& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 }, - '& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } }, - '& .MuiDataGrid-columnSeparator': { display: 'none' }, - '& .MuiDataGrid-iconButtonContainer': { display: 'none' }, - '& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } }, - '& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' }, - '& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' }, - }} - /> - - - - {/* Graph Modal */} + + + setModalRow(null)} /> ) diff --git a/react-app/src/components/FilterBar.jsx b/react-app/src/components/FilterBar.jsx new file mode 100644 index 0000000..a73eacf --- /dev/null +++ b/react-app/src/components/FilterBar.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import { + Box, Container, Typography, TextField, Select, MenuItem, InputLabel, + FormControl, Stack, +} from '@mui/material' + +export function FilterBar({ + query, setQuery, + faction, setFaction, + dir, setDir, + factions, factionNames, + isMobile, totalRows, +}) { + return ( + + + + Points Comparator + + + Codex vs. MFM v4.3 · {totalRows.toLocaleString()} units · green = cheaper · red = costlier + + + setQuery(e.target.value)} + placeholder="Unit name…" + sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }} + /> + + + Faction + + + + {isMobile ? 'Δ' : 'Change'} + + + + + + + ) +} diff --git a/react-app/src/components/GraphModal.jsx b/react-app/src/components/GraphModal.jsx new file mode 100644 index 0000000..1dd52c9 --- /dev/null +++ b/react-app/src/components/GraphModal.jsx @@ -0,0 +1,79 @@ +import React, { useState, useEffect } from 'react' +import { + Box, Typography, IconButton, Modal, Paper, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material' +import { PointsHistoryChart } from './PointsHistoryChart.jsx' +import { sizeShort } from '../utils/format.js' + +export function GraphModal({ row, open, onClose }) { + const [graphSize, setGraphSize] = useState(null) + + useEffect(() => { + if (row) setGraphSize(row.size) + }, [row]) + + if (!row) return null + + const sizes = row.sizes || [] + const activeSize = graphSize || row.size + const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0] + const history = activeSizeData?.history || [] + + return ( + + + + + {row.name} + {row.faction_name} + + + + + {sizes.length > 1 && ( + + Model count + + + )} + + {history.length > 0 ? ( + + ) : ( + + No historical data for this unit. + + )} + + {history.length > 1 && ( + + + {history[0].version}: {history[0].pts}pts + + + {history[history.length - 1].version}: {history[history.length - 1].pts}pts + + + )} + + + ) +} \ No newline at end of file diff --git a/react-app/src/components/MoversPanel.jsx b/react-app/src/components/MoversPanel.jsx new file mode 100644 index 0000000..f0eddf8 --- /dev/null +++ b/react-app/src/components/MoversPanel.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Box, Paper, Typography, Stack } from '@mui/material' +import { pctLabel } from '../utils/format.js' + +export function MoversPanel({ title, units, accent, onSelectUnit, showFaction }) { + return ( + + {title} + + {units.map((u, i) => ( + onSelectUnit(u)} + > + + {u.name} + {showFaction && ( + {u.faction_name} + )} + + + {u.original}→{u.new} ({pctLabel(u.change_pct)}) + + + ))} + + + ) +} \ No newline at end of file diff --git a/react-app/src/components/MoversSection.jsx b/react-app/src/components/MoversSection.jsx new file mode 100644 index 0000000..5528242 --- /dev/null +++ b/react-app/src/components/MoversSection.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Box, Container } from '@mui/material' +import { MoversPanel } from './MoversPanel.jsx' + +export function MoversSection({ movers, onSelectUnit, showFaction }) { + return ( + + + + + + + ) +} \ No newline at end of file diff --git a/react-app/src/components/PointsHistoryChart.jsx b/react-app/src/components/PointsHistoryChart.jsx new file mode 100644 index 0000000..b16c334 --- /dev/null +++ b/react-app/src/components/PointsHistoryChart.jsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Box } from '@mui/material' +import { buildChartGeometry } from '../utils/history.js' + +const PADL = 70, PADR = 24, PADT = 24, PADB = 48 + +const fmtDate = (d) => { + const dt = new Date(d) + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) +} + +export function PointsHistoryChart({ history, W = 640, H = 360 }) { + const { xFor, yFor, yTicks, linePath, areaPath } = buildChartGeometry( + history, + { W, H, padL: PADL, padR: PADR, padT: PADT, padB: PADB }, + ) + + return ( + + + + {yTicks.map((t, i) => ( + + + {t.v} + + ))} + {areaPath && } + + {history.map((h, i) => ( + + + {h.pts} + {fmtDate(h.date)} + + ))} + + + + ) +} diff --git a/react-app/src/components/SizeCell.jsx b/react-app/src/components/SizeCell.jsx new file mode 100644 index 0000000..ddbc303 --- /dev/null +++ b/react-app/src/components/SizeCell.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import { Box, Typography, Select, MenuItem } from '@mui/material' +import { sizeShort } from '../utils/format.js' + +export function SizeCell({ row, onSelect }) { + const sizes = row.sizes || [] + if (sizes.length <= 1) { + return ( + + + {sizeShort(row.size)} + + + ) + } + return ( + + + + ) +} diff --git a/react-app/src/components/UnitTable.jsx b/react-app/src/components/UnitTable.jsx new file mode 100644 index 0000000..5289e5c --- /dev/null +++ b/react-app/src/components/UnitTable.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Box, Container, Paper, Typography } from '@mui/material' +import { DataGrid } from '@mui/x-data-grid' + +export function UnitTable({ rows, columns, filteredCount, onCellClick }) { + return ( + + + + + {filteredCount.toLocaleString()} units + + + Click a unit for points history → + + + `${row.faction}|${row.name}`} + density="compact" + autoHeight + hideFooter + disableColumnMenu + onCellClick={(p) => { + if (p.field === 'size') return + onCellClick(p.row) + }} + sx={{ + border: 'none', + width: '100%', + '& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }, + '& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 }, + '& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } }, + '& .MuiDataGrid-columnSeparator': { display: 'none' }, + '& .MuiDataGrid-iconButtonContainer': { display: 'none' }, + '& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } }, + '& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' }, + '& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' }, + }} + /> + + + ) +} diff --git a/react-app/src/test/App.test.jsx b/react-app/src/test/App.test.jsx new file mode 100644 index 0000000..b386d6d --- /dev/null +++ b/react-app/src/test/App.test.jsx @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { renderWithTheme } from './setup.js' +import App from '../App.jsx' + +function mockFetch(data) { + globalThis.fetch = vi.fn(() => + Promise.resolve({ json: () => Promise.resolve(data) }), + ) +} + +describe('App (end-to-end UI)', () => { + beforeEach(() => { + // Reset URL to bare pathname so each test starts fresh + window.history.replaceState(null, '', '/') + mockFetch(fixture) + }) + + it('shows a loading state then renders rows', async () => { + renderWithTheme() + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument() + }) + // Palatine appears in both the movers panel and the data grid + expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0) + }) + + it('filters rows by search query (case-insensitive)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + await user.type(screen.getByLabelText('Search'), 'pala') + await waitFor(() => { + // Palatine is still visible (in both movers + grid) + expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0) + // Other units are filtered out + expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument() + }) + }) + + it('filters rows by faction and hides the Faction column', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + // Intercessor Squad appears in movers (drops, -5%) AND grid + expect(screen.getAllByText('Intercessor Squad').length).toBeGreaterThan(0) + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // The "Faction" column header should NOT be visible when filtered to one faction + // (note: the FilterBar's "Faction" label is still present, so we scope to column headers) + expect(screen.queryByRole('columnheader', { name: 'Faction' })).not.toBeInTheDocument() + }) + }) + + it('filters rows by change direction (down = cheaper only)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + await waitFor(() => { + expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0) + // Redemptor Dreadnought is costlier, should be filtered out + expect(screen.queryByText('Redemptor Dreadnought')).not.toBeInTheDocument() + }) + }) + + it('writes filters to the URL and rehydrates from them on load', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + expect(window.location.search).toContain('faction=space-marines') + }) + }) + + it('rehydrates filters from URL params on load', async () => { + window.history.replaceState(null, '', '/?faction=necrons') + renderWithTheme() + await waitFor(() => { + // Only Necrons units visible + expect(screen.getByText('New Unit X')).toBeInTheDocument() + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + }) + }) + + it('defaults to Adepta Sororitas and hides the Faction column on bare-URL load', async () => { + renderWithTheme() + // Wait for data load — Palatine is Adepta Sororitas, so it must be present + await waitFor(() => screen.getAllByText('Palatine')[0]) + // Intercessor Squad is Space Marines — must NOT be visible because default faction is 'adepta-sororitas' + expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument() + // Faction column header must be hidden because a faction is active + expect(screen.queryByRole('columnheader', { name: 'Faction' })).not.toBeInTheDocument() + }) + + it('updates the active size of a multi-size unit when the # cell is changed', async () => { + const user = userEvent.setup() + renderWithTheme() + // Arco-flagellants appears in both movers (rises, +11.11%) and grid + await waitFor(() => screen.getAllByText('Arco-flagellants')[0]) + // Arco-flagellants has 3 models (default) and 10 models + // The size cell combobox is the only combobox on the page that is NOT a FilterBar dropdown + // (FilterBar's comboboxes are labelled by filterbar-faction-label / filterbar-dir-label) + const sizeSelect = screen.getAllByRole('combobox').find( + c => c.getAttribute('aria-labelledby') !== 'filterbar-faction-label' + && c.getAttribute('aria-labelledby') !== 'filterbar-dir-label', + ) + await user.click(sizeSelect) + // Choose "10" (the larger size) + await user.click(screen.getByRole('option', { name: '10' })) + // The row's "New" value should update from 50 to 140 + await waitFor(() => { + // The "10" option click should have changed the displayed value + // The Old and New for the 10-models size are both 140 + const cells = screen.getAllByText('140') + expect(cells.length).toBeGreaterThan(0) + }) + }) + + it('opens the GraphModal when a non-size cell is clicked', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + // Palatine's Old=50, New=40. "50" also appears in Arco-flagellants' New column, + // so we scope the click to Palatine's DataGrid row (data-id uses `${faction}|${name}`). + const palatineRow = document.querySelector('[data-id="adepta-sororitas|Palatine"]') + expect(palatineRow).not.toBeNull() + // Click the Old cell for Palatine (50) + await user.click(within(palatineRow).getByText('50')) + // Modal should now be visible with Palatine's name + await waitFor(() => { + // Both the grid cell and the modal header show "Palatine" — getAllByText + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + // The "No historical data" message should NOT be present (Palatine has history) + expect(screen.queryByText(/no historical data/i)).not.toBeInTheDocument() + }) + + it('shows "No historical data" for units with empty history', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getAllByText('Palatine')[0]) + // Default faction is Adepta Sororitas; switch to "All" so Necron's New Unit X is visible + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'All' })) + await waitFor(() => screen.getAllByText('New Unit X')[0]) + // Click on the "New Unit X" row (no history) + await user.click(screen.getAllByText('New Unit X')[0]) + await waitFor(() => { + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + }) + + it('movers cards reflect the filtered set and clicking a row opens the modal', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('↓ Cheaper')) + // The cheapest unit is Palatine at -20% + // Click on the mover row containing Palatine + const cheaperPanel = screen.getByText('↓ Cheaper').parentElement + const palatineInPanel = within(cheaperPanel).getByText('Palatine') + await user.click(palatineInPanel) + // The modal opens (Palatine's name appears more than once) + await waitFor(() => { + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + }) +}) \ No newline at end of file diff --git a/react-app/src/test/FilterBar.test.jsx b/react-app/src/test/FilterBar.test.jsx new file mode 100644 index 0000000..6387239 --- /dev/null +++ b/react-app/src/test/FilterBar.test.jsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FilterBar } from '../components/FilterBar.jsx' + +const defaultProps = { + query: '', + setQuery: vi.fn(), + faction: '', + setFaction: vi.fn(), + dir: '', + setDir: vi.fn(), + factions: ['adepta-sororitas', 'space-marines'], + factionNames: { 'adepta-sororitas': 'Adepta Sororitas', 'space-marines': 'Space Marines' }, + isMobile: false, + totalRows: 7, +} + +describe('FilterBar', () => { + it('renders title, subtitle with row count, and all three controls', () => { + render() + expect(screen.getByText('Points Comparator')).toBeInTheDocument() + expect(screen.getByText(/7 units/)).toBeInTheDocument() + expect(screen.getByLabelText('Search')).toBeInTheDocument() + expect(screen.getByLabelText('Faction')).toBeInTheDocument() + expect(screen.getByLabelText('Change')).toBeInTheDocument() + }) + + it('calls setQuery when the user types in the search field', async () => { + const user = userEvent.setup() + const setQuery = vi.fn() + render() + const search = screen.getByLabelText('Search') + await user.type(search, 'palatine') + // Each character fires setQuery once + expect(setQuery).toHaveBeenCalled() + expect(setQuery).toHaveBeenLastCalledWith('e') // last char typed + }) + + it('calls setFaction when the user picks a faction', async () => { + const user = userEvent.setup() + const setFaction = vi.fn() + render() + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + expect(setFaction).toHaveBeenCalledWith('space-marines') + }) + + it('calls setDir when the user picks a change direction', async () => { + const user = userEvent.setup() + const setDir = vi.fn() + render() + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + expect(setDir).toHaveBeenCalledWith('down') + }) + + it('uses "Δ" as the change-select label on mobile', () => { + render() + // On mobile, the label is the symbol + expect(screen.getByLabelText('Δ')).toBeInTheDocument() + // The longer "Change" label should not be present + expect(screen.queryByLabelText('Change')).not.toBeInTheDocument() + }) + + it('includes an "All" option in both selects', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Faction')) + expect(screen.getByRole('option', { name: 'All' })).toBeInTheDocument() + }) +}) diff --git a/react-app/src/test/GraphModal.test.jsx b/react-app/src/test/GraphModal.test.jsx new file mode 100644 index 0000000..04184c2 --- /dev/null +++ b/react-app/src/test/GraphModal.test.jsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { GraphModal } from '../components/GraphModal.jsx' + +const arco = fixture.units.find(u => u.name === 'Arco-flagellants') +const palatine = fixture.units.find(u => u.name === 'Palatine') +const newUnit = fixture.units.find(u => u.name === 'New Unit X') + +function renderModal(row, onClose = vi.fn()) { + return render() +} + +describe('GraphModal', () => { + it('renders the unit name and faction in the header', () => { + renderModal(palatine) + // Modal renders; the name appears in the dialog + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Adepta Sororitas')).toBeInTheDocument() + }) + + it('shows the size dropdown for multi-size units and updates the chart', async () => { + const user = userEvent.setup() + renderModal(arco) + // The size dropdown should be present (MUI Select) + const selects = screen.getAllByRole('combobox') + expect(selects.length).toBeGreaterThan(0) + // The chart should render at least one circle per data point on the small size (3 points). + // Note: MUI's Select renders an arrow SVG (viewBox 0 0 24 24) before the chart SVG, + // so we filter for the chart SVG (the one with circles) instead of taking the first. + const chartSvg = Array.from(document.querySelectorAll('svg')).find(s => s.querySelector('circle')) + expect(chartSvg).toBeInTheDocument() + expect(chartSvg.querySelectorAll('circle').length).toBe(3) + }) + + it('shows "No historical data" when the unit has empty history', () => { + renderModal(newUnit) + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderModal(palatine, onClose) + // The close button is a small IconButton with ✕ text + await user.click(screen.getByRole('button', { name: '✕' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders nothing when row is null', () => { + const { container } = render( {}} />) + // No header text should be present + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // container should be effectively empty (no dialog rendered with meaningful content) + expect(container.textContent).toBe('') + }) + + it('shows first/last version footer for multi-point history', () => { + renderModal(palatine) // 2 history points + // Footer should show "MFM 1.14: 50pts" and "MFM (current): 40pts" + expect(screen.getByText(/MFM 1\.14: 50pts/)).toBeInTheDocument() + expect(screen.getByText(/MFM \(current\): 40pts/)).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/react-app/src/test/MoversSection.test.jsx b/react-app/src/test/MoversSection.test.jsx new file mode 100644 index 0000000..c43c944 --- /dev/null +++ b/react-app/src/test/MoversSection.test.jsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { MoversSection } from '../components/MoversSection.jsx' + +const palatine = fixture.units.find(u => u.name === 'Palatine') +const intercessor = fixture.units.find(u => u.name === 'Intercessor Squad') +const dread = fixture.units.find(u => u.name === 'Redemptor Dreadnought') + +describe('MoversSection', () => { + it('renders both panel titles', () => { + render( + {}} + showFaction={true} + />, + ) + expect(screen.getByText('↓ Cheaper')).toBeInTheDocument() + expect(screen.getByText('↑ Costlier')).toBeInTheDocument() + }) + + it('lists units in the drops panel under "Cheaper"', () => { + render( + {}} + showFaction={true} + />, + ) + // Both drops should be present + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Intercessor Squad')).toBeInTheDocument() + // The rises unit should also be in the document + expect(screen.getByText('Redemptor Dreadnought')).toBeInTheDocument() + }) + + it('hides faction name in movers when showFaction is false', () => { + render( + {}} + showFaction={false} + />, + ) + // The unit name still shows + expect(screen.getByText('Palatine')).toBeInTheDocument() + // The faction name should NOT show in the mover row + // Note: it might still appear elsewhere on the page in a real App, but in this + // isolated render of MoversSection, the only place it would appear is inside + // the panel rows. So we expect it not to be present. + const rows = screen.queryAllByText(/Adepta Sororitas/) + expect(rows.length).toBe(0) + }) + + it('calls onSelectUnit with the clicked row', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render( + , + ) + await user.click(screen.getByText('Palatine')) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(palatine) + }) +}) \ No newline at end of file diff --git a/react-app/src/test/SizeCell.test.jsx b/react-app/src/test/SizeCell.test.jsx new file mode 100644 index 0000000..3c9738c --- /dev/null +++ b/react-app/src/test/SizeCell.test.jsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SizeCell } from '../components/SizeCell.jsx' + +function renderWithTheme(ui) { + return render(
{ui}
) +} + +describe('SizeCell', () => { + it('renders plain text when the unit has one size', () => { + const row = { size: '1 model', sizes: [{ size: '1 model' }] } + renderWithTheme( {}} />) + expect(screen.getByText('1')).toBeInTheDocument() + // No select element should be present + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) + + it('renders a Select dropdown when the unit has multiple sizes', () => { + const row = { + size: '3 models', + sizes: [{ size: '3 models' }, { size: '10 models' }], + } + renderWithTheme( {}} />) + expect(screen.getByRole('combobox')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() // current value, shortened + }) + + it('calls onSelect with the row and the chosen size label', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const row = { + size: '3 models', + sizes: [{ size: '3 models' }, { size: '10 models' }], + } + renderWithTheme() + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: '10' })) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(row, '10 models') + }) + + it('treats missing sizes array as single-size', () => { + const row = { size: '1 model' } // no `sizes` key + renderWithTheme( {}} />) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) +}) diff --git a/react-app/src/test/UnitTable.test.jsx b/react-app/src/test/UnitTable.test.jsx new file mode 100644 index 0000000..cb131a8 --- /dev/null +++ b/react-app/src/test/UnitTable.test.jsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { UnitTable } from '../components/UnitTable.jsx' +import { buildColumns } from '../utils/columns.jsx' + +const columns = buildColumns({ isMobile: false, showFactionCol: true, onSelectSize: () => {} }) + +describe('UnitTable', () => { + it('renders the count header with the filtered count', () => { + render( {}} />) + // "X units" is rendered (X being the filteredCount). The text is split across + // {X} and ' units' so a regex/getByText matcher can't find the combined + // text — instead find the {X} and check the parent for the "units" suffix. + const countEl = screen.getByText(`${fixture.units.length}`) + expect(countEl.parentElement).toHaveTextContent(new RegExp(`${fixture.units.length}\\s*units`)) + }) + + it('renders all unit names in the grid', () => { + render( {}} />) + for (const u of fixture.units) { + expect(screen.getByText(u.name)).toBeInTheDocument() + } + }) + + it('calls onCellClick with the clicked row when a non-size cell is clicked', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const palatine = fixture.units.find(u => u.name === 'Palatine') + render() + // Click the Old cell for Palatine. Note: the brief said '60' but the fixture has + // Palatine.original = 50, so we use '50' (the actual Old value). + const oldCell = screen.getByText('50') + await user.click(oldCell) + expect(onCellClick).toHaveBeenCalledTimes(1) + expect(onCellClick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Palatine' })) + }) + + it('does NOT call onCellClick when the size cell is clicked (size cell handles its own clicks)', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const arco = fixture.units.find(u => u.name === 'Arco-flagellants') + render() + // The size column shows a Select for multi-size units + const combobox = screen.getByRole('combobox') + await user.click(combobox) + // No onCellClick yet + expect(onCellClick).not.toHaveBeenCalled() + }) + + it('renders em-dash for new units (null original)', () => { + const newUnit = fixture.units.find(u => u.name === 'New Unit X') + render( {}} />) + // At least one em-dash should be in the document + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThan(0) + }) +}) diff --git a/react-app/src/test/fixtures/data.json b/react-app/src/test/fixtures/data.json new file mode 100644 index 0000000..8ef5f50 --- /dev/null +++ b/react-app/src/test/fixtures/data.json @@ -0,0 +1,207 @@ +{ + "generated_at": "2026-06-18T00:00:00Z", + "versions": [ + { "date": "2024-12-01", "label": "MFM 1.14" }, + { "date": "2025-08-20", "label": "MFM 3.2" }, + { "date": "2026-06-17", "label": "MFM (current)" } + ], + "factions": ["adepta-sororitas", "space-marines", "necrons"], + "faction_names": { + "adepta-sororitas": "Adepta Sororitas", + "space-marines": "Space Marines", + "necrons": "Necrons" + }, + "stats": { "total_rows": 7, "rows_with_both": 5 }, + "units": [ + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Canoness", + "size": "1 model", + "original": 60, + "new": 60, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 60, + "new": 60, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 60 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 60 } + ] + } + ] + }, + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Arco-flagellants", + "size": "3 models", + "original": 45, + "new": 50, + "tier": null, + "change_pct": 11.11, + "change_pts": 5, + "default_size": "3 models", + "sizes": [ + { + "size": "3 models", + "original": 45, + "new": 50, + "tier": null, + "change_pct": 11.11, + "change_pts": 5, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 45 }, + { "date": "2025-08-20", "version": "MFM 3.2", "pts": 45 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 50 } + ] + }, + { + "size": "10 models", + "original": 140, + "new": 140, + "tier": null, + "change_pct": 0, + "change_pts": 0, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 150 }, + { "date": "2025-08-20", "version": "MFM 3.2", "pts": 140 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 140 } + ] + } + ] + }, + { + "faction": "adepta-sororitas", + "faction_name": "Adepta Sororitas", + "name": "Palatine", + "size": "1 model", + "original": 50, + "new": 40, + "tier": null, + "change_pct": -20, + "change_pts": -10, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 50, + "new": 40, + "tier": null, + "change_pct": -20, + "change_pts": -10, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 50 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 40 } + ] + } + ] + }, + { + "faction": "space-marines", + "faction_name": "Space Marines", + "name": "Intercessor Squad", + "size": "5 models", + "original": 100, + "new": 95, + "tier": null, + "change_pct": -5, + "change_pts": -5, + "default_size": "5 models", + "sizes": [ + { + "size": "5 models", + "original": 100, + "new": 95, + "tier": null, + "change_pct": -5, + "change_pts": -5, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 100 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 95 } + ] + } + ] + }, + { + "faction": "space-marines", + "faction_name": "Space Marines", + "name": "Redemptor Dreadnought", + "size": "1 model", + "original": 200, + "new": 220, + "tier": null, + "change_pct": 10, + "change_pts": 20, + "default_size": "1 model", + "sizes": [ + { + "size": "1 model", + "original": 200, + "new": 220, + "tier": null, + "change_pct": 10, + "change_pts": 20, + "history": [ + { "date": "2024-12-01", "version": "MFM 1.14", "pts": 200 }, + { "date": "2026-06-17", "version": "MFM (current)", "pts": 220 } + ] + } + ] + }, + { + "faction": "necrons", + "faction_name": "Necrons", + "name": "New Unit X", + "size": "5 models", + "original": null, + "new": 80, + "tier": null, + "change_pct": null, + "change_pts": null, + "default_size": "5 models", + "sizes": [ + { + "size": "5 models", + "original": null, + "new": 80, + "tier": null, + "change_pct": null, + "change_pts": null, + "history": [] + } + ] + }, + { + "faction": "necrons", + "faction_name": "Necrons", + "name": "Removed Unit Y", + "size": "10 models", + "original": 120, + "new": null, + "tier": null, + "change_pct": null, + "change_pts": null, + "default_size": "10 models", + "sizes": [ + { + "size": "10 models", + "original": 120, + "new": null, + "tier": null, + "change_pct": null, + "change_pts": null, + "history": [] + } + ] + } + ] +} diff --git a/react-app/src/test/setup.js b/react-app/src/test/setup.js new file mode 100644 index 0000000..b9efeb6 --- /dev/null +++ b/react-app/src/test/setup.js @@ -0,0 +1,31 @@ +import '@testing-library/jest-dom/vitest' +import { createElement } from 'react' +import { render } from '@testing-library/react' +import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' + +const theme = createTheme({ + palette: { + mode: 'dark', + background: { default: '#0a0e14', paper: '#11161e' }, + primary: { main: '#3b82f6' }, + secondary: { main: '#60a5fa' }, + success: { main: '#3fb950' }, + error: { main: '#f85149' }, + text: { primary: '#e6edf3', secondary: '#7d8590' }, + divider: '#232b38', + }, + typography: { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', fontSize: 14 }, + shape: { borderRadius: 8 }, +}) + +export function renderWithTheme(ui, options = {}) { + return render( + createElement( + ThemeProvider, + { theme }, + createElement(CssBaseline), + ui, + ), + options, + ) +} diff --git a/react-app/src/test/utils.test.js b/react-app/src/test/utils.test.js new file mode 100644 index 0000000..9079793 --- /dev/null +++ b/react-app/src/test/utils.test.js @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { buildChartGeometry } from '../utils/history.js' +import { sizeShort } from '../utils/format.js' + +describe('sizeShort', () => { + it('strips " model" suffix', () => { + expect(sizeShort('1 model')).toBe('1') + }) + it('strips " models" suffix', () => { + expect(sizeShort('10 models')).toBe('10') + }) + it('leaves other strings unchanged', () => { + expect(sizeShort('Squad')).toBe('Squad') + }) +}) + +import { pctLabel, ptsLabel, changeColor } from '../utils/format.js' + +describe('pctLabel', () => { + it('prefixes positive values with +', () => { + expect(pctLabel(12.34)).toBe('+12.3%') + }) + it('renders negative values without prefix', () => { + expect(pctLabel(-5)).toBe('-5.0%') + }) + it('renders zero without prefix', () => { + expect(pctLabel(0)).toBe('0.0%') + }) + it('renders em-dash for null', () => { + expect(pctLabel(null)).toBe('—') + }) + it('renders em-dash for undefined', () => { + expect(pctLabel(undefined)).toBe('—') + }) +}) + +describe('ptsLabel', () => { + it('prefixes positive values with +', () => { + expect(ptsLabel(10)).toBe('+10') + }) + it('renders negative values as bare -N', () => { + expect(ptsLabel(-5)).toBe('-5') + }) + it('renders zero without prefix', () => { + expect(ptsLabel(0)).toBe('0') + }) + it('renders em-dash for null', () => { + expect(ptsLabel(null)).toBe('—') + }) +}) + +describe('changeColor', () => { + it('returns red for positive', () => { + expect(changeColor(1)).toBe('#f85149') + }) + it('returns green for negative', () => { + expect(changeColor(-1)).toBe('#3fb950') + }) + it('returns text.secondary for zero', () => { + expect(changeColor(0)).toBe('text.secondary') + }) + it('returns text.secondary for null', () => { + expect(changeColor(null)).toBe('text.secondary') + }) +}) + +import { readFiltersFromUrl, writeFiltersToUrl } from '../utils/url.js' + +describe('readFiltersFromUrl', () => { + it('returns defaults when search is empty', () => { + window.history.replaceState(null, '', '/?') + expect(readFiltersFromUrl()).toEqual({ q: '', faction: '', dir: '' }) + }) + it('reads q, faction, dir from query string', () => { + window.history.replaceState(null, '', '/?q=foo&faction=space-marines&dir=up') + expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: 'space-marines', dir: 'up' }) + }) + it('returns empty strings for missing keys', () => { + window.history.replaceState(null, '', '/?q=foo') + expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: '', dir: '' }) + }) +}) + +describe('writeFiltersToUrl', () => { + it('writes all three params', () => { + writeFiltersToUrl({ q: 'foo', faction: 'space-marines', dir: 'up' }) + expect(window.location.search).toBe('?q=foo&faction=space-marines&dir=up') + }) + it('clears the query string when all are empty', () => { + writeFiltersToUrl({ q: '', faction: '', dir: '' }) + expect(window.location.search).toBe('') + }) + it('writes only the present keys (others stay empty)', () => { + writeFiltersToUrl({ q: 'foo', faction: '', dir: '' }) + expect(window.location.search).toBe('?q=foo') + }) +}) + +import { computeMovers } from '../utils/movers.js' + +const mk = (name, change_pct) => ({ name, change_pct }) + +describe('computeMovers', () => { + it('returns empty arrays for empty input', () => { + expect(computeMovers([])).toEqual({ drops: [], rises: [] }) + }) + it('returns top 5 drops (largest negative change_pct first)', () => { + const input = [ + mk('A', -1), mk('B', -50), mk('C', -20), mk('D', -5), + mk('E', -10), mk('F', -30), mk('G', -2), + ] + expect(computeMovers(input).drops.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D']) + }) + it('returns top 5 rises (largest positive change_pct first)', () => { + const input = [ + mk('A', 1), mk('B', 50), mk('C', 20), mk('D', 5), + mk('E', 10), mk('F', 30), mk('G', 2), + ] + expect(computeMovers(input).rises.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D']) + }) + it('excludes units with change_pct === null from both lists', () => { + const input = [mk('A', -10), mk('B', null), mk('C', 10)] + const { drops, rises } = computeMovers(input) + expect(drops.map(u => u.name)).toEqual(['A']) + expect(rises.map(u => u.name)).toEqual(['C']) + }) + it('excludes units with change_pct === 0 from both lists', () => { + const input = [mk('A', -10), mk('B', 0), mk('C', 10)] + const { drops, rises } = computeMovers(input) + expect(drops.map(u => u.name)).toEqual(['A']) + expect(rises.map(u => u.name)).toEqual(['C']) + }) +}) + +const DIMS = { W: 640, H: 360, padL: 70, padR: 24, padT: 24, padB: 48 } + +describe('buildChartGeometry', () => { + it('returns empty linePath for empty history', () => { + const g = buildChartGeometry([], DIMS) + expect(g.linePath).toBe('') + expect(g.areaPath).toBe('') + expect(g.yTicks).toBeDefined() + }) + it('returns empty areaPath for single-point history', () => { + const g = buildChartGeometry([{ pts: 50, date: '2026-01-01', version: 'v1' }], DIMS) + expect(g.linePath).not.toBe('') + expect(g.areaPath).toBe('') + }) + it('produces a multi-segment path for 3+ points', () => { + const g = buildChartGeometry([ + { pts: 50, date: '2024-01-01', version: 'v1' }, + { pts: 60, date: '2025-01-01', version: 'v2' }, + { pts: 40, date: '2026-01-01', version: 'v3' }, + ], DIMS) + expect(g.linePath.split('L').length).toBe(3) // 1 M + 2 L + expect(g.areaPath).not.toBe('') + }) + it('produces y-axis ticks spanning the data range', () => { + const g = buildChartGeometry([ + { pts: 10, date: '2024-01-01', version: 'v1' }, + { pts: 100, date: '2026-01-01', version: 'v2' }, + ], DIMS) + expect(g.yTicks.length).toBeGreaterThan(1) + const values = g.yTicks.map(t => t.v) + expect(Math.min(...values)).toBeLessThanOrEqual(10) + expect(Math.max(...values)).toBeGreaterThanOrEqual(100) + }) + it('chartW and chartH are W minus horizontal/vertical padding', () => { + const g = buildChartGeometry([], DIMS) + expect(g.chartW).toBe(DIMS.W - DIMS.padL - DIMS.padR) + expect(g.chartH).toBe(DIMS.H - DIMS.padT - DIMS.padB) + }) +}) \ No newline at end of file diff --git a/react-app/src/utils/columns.jsx b/react-app/src/utils/columns.jsx new file mode 100644 index 0000000..1ac4743 --- /dev/null +++ b/react-app/src/utils/columns.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { Box, Typography } from '@mui/material' +import { SizeCell } from '../components/SizeCell.jsx' +import { pctLabel, ptsLabel, changeColor } from './format.js' + +export function buildColumns({ isMobile, showFactionCol, onSelectSize }) { + const cols = [ + { + field: 'name', headerName: 'Unit', flex: 3, minWidth: 80, + renderCell: (p) => ( + + + {p.row.name} + + + ), + }, + { + field: 'size', headerName: '#', flex: 0.6, minWidth: 36, + renderCell: (p) => , + }, + { + field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.original ?? '—'} + + ), + }, + { + field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.new ?? '—'} + + ), + }, + { + field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {pctLabel(p.row.change_pct)} + + ), + }, + ] + + // Δ pts column — desktop only + if (!isMobile) { + cols.splice(4, 0, { + field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {ptsLabel(p.row.change_pts)} + + ), + }) + } + + if (showFactionCol) { + cols.unshift({ + field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80, + renderCell: (p) => ( + + {p.row.faction_name} + + ), + }) + } + return cols +} diff --git a/react-app/src/utils/format.js b/react-app/src/utils/format.js new file mode 100644 index 0000000..f5ebd4a --- /dev/null +++ b/react-app/src/utils/format.js @@ -0,0 +1,21 @@ +export function sizeShort(size) { + return size.replace(/\s*models?$/, '') +} + +export function pctLabel(pct) { + if (pct === null || pct === undefined) return '—' + const sign = pct > 0 ? '+' : '' + return `${sign}${pct.toFixed(1)}%` +} + +export function ptsLabel(pts) { + if (pts === null || pts === undefined) return '—' + return pts > 0 ? `+${pts}` : `${pts}` +} + +export function changeColor(val) { + if (val === null || val === undefined) return 'text.secondary' + if (val > 0) return '#f85149' + if (val < 0) return '#3fb950' + return 'text.secondary' +} \ No newline at end of file diff --git a/react-app/src/utils/history.js b/react-app/src/utils/history.js new file mode 100644 index 0000000..cd9b889 --- /dev/null +++ b/react-app/src/utils/history.js @@ -0,0 +1,31 @@ +export function buildChartGeometry(history, { W, H, padL, padR, padT, padB }) { + const chartW = W - padL - padR + const chartH = H - padT - padB + + const pts = history.map(h => h.pts) + const minPts = Math.min(...(pts.length ? pts : [0]), 0) + const maxPts = Math.max(...(pts.length ? pts : [1]), 1) + const ptsRange = maxPts - minPts || 1 + const padY = ptsRange * 0.15 + const yMin = Math.max(0, minPts - padY) + const yMax = maxPts + padY + const yRange = yMax - yMin || 1 + + const n = history.length + const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW + const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH + + const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ') + const areaPath = history.length > 1 + ? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z` + : '' + + const yTicks = [] + const tickCount = Math.min(4, Math.ceil(yRange / 10)) + for (let i = 0; i <= tickCount; i++) { + const v = yMin + (yRange * i / tickCount) + yTicks.push({ v: Math.round(v), y: yFor(v) }) + } + + return { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH } +} diff --git a/react-app/src/utils/movers.js b/react-app/src/utils/movers.js new file mode 100644 index 0000000..f031ed6 --- /dev/null +++ b/react-app/src/utils/movers.js @@ -0,0 +1,12 @@ +export function computeMovers(filtered) { + if (!filtered || filtered.length === 0) return { drops: [], rises: [] } + const drops = filtered + .filter(u => u.change_pct !== null && u.change_pct < 0) + .sort((a, b) => a.change_pct - b.change_pct) + .slice(0, 5) + const rises = filtered + .filter(u => u.change_pct !== null && u.change_pct > 0) + .sort((a, b) => b.change_pct - a.change_pct) + .slice(0, 5) + return { drops, rises } +} diff --git a/react-app/src/utils/url.js b/react-app/src/utils/url.js new file mode 100644 index 0000000..2206e8b --- /dev/null +++ b/react-app/src/utils/url.js @@ -0,0 +1,18 @@ +export function readFiltersFromUrl() { + const params = new URLSearchParams(window.location.search) + return { + q: params.get('q') || '', + faction: params.get('faction') || '', + dir: params.get('dir') || '', + } +} + +export function writeFiltersToUrl({ q, faction, dir }) { + const params = new URLSearchParams() + if (q) params.set('q', q) + if (faction) params.set('faction', faction) + if (dir) params.set('dir', dir) + const qs = params.toString() + const newUrl = qs ? `?${qs}` : window.location.pathname + window.history.replaceState(null, '', newUrl) +} diff --git a/react-app/vite.config.js b/react-app/vite.config.js index dc8b589..8042250 100644 --- a/react-app/vite.config.js +++ b/react-app/vite.config.js @@ -1,8 +1,31 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ - plugins: [react()], + plugins: [react({ jsxRuntime: 'automatic' })], + esbuild: { + jsx: 'automatic', + }, + resolve: { + alias: [ + { find: /^react-transition-group\/TransitionGroupContext$/, replacement: 'react-transition-group/cjs/TransitionGroupContext.js' }, + ], + }, + optimizeDeps: { + include: ['react-transition-group/TransitionGroupContext'], + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.js'], + css: false, + server: { + deps: { + inline: ['@mui/material', '@mui/x-data-grid'], + }, + }, + }, build: { outDir: 'dist', base: './', @@ -17,4 +40,4 @@ export default defineConfig({ server: { port: 9102, }, -}) \ No newline at end of file +})