From 699f0fa637a514db5cd1f7da72c4da25f8a51833 Mon Sep 17 00:00:00 2001 From: kris7011 Date: Fri, 16 Oct 2026 13:40:51 +0200 Subject: [PATCH 1/2] Centralize frontend API base URL and fix coverage rate translation - Replace nine hard-coded API_BASE_URL constants with api/config.ts, configurable through VITE_API_BASE_URL (default unchanged) - translateRiskMessage removed the first '.' in 'Schedule coverage is 78.57%.', showing '7857%' whenever the API runs with an English or invariant culture (e.g. in the Docker image). Only strip the sentence-ending period. Co-Authored-By: Claude Opus 5.5 --- README.md | 24 +++++++++++++++++++ frontend/.env.example | 2 ++ frontend/src/App.tsx | 6 +++-- frontend/src/api/config.ts | 3 +++ frontend/src/api/demoDataApi.ts | 4 ++-- frontend/src/api/employeeLoadDetailsApi.ts | 4 ++-- frontend/src/api/employeeLoadOverviewApi.ts | 4 ++-- .../src/api/employeePreferenceProfilesApi.ts | 3 +-- frontend/src/api/scheduleDataApi.ts | 4 ++-- frontend/src/api/scheduleGenerationApi.ts | 4 ++-- frontend/src/api/scheduleOverviewApi.ts | 4 ++-- frontend/src/api/scheduleSimulationApi.ts | 4 ++-- .../src/api/shiftAssignmentAnalysisApi.ts | 4 ++-- 13 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/api/config.ts diff --git a/README.md b/README.md index dee7337..b55043a 100644 --- a/README.md +++ b/README.md @@ -1191,6 +1191,16 @@ The frontend is available at: http://localhost:5173 ``` +The API base URL defaults to `http://localhost:5026`. To point the frontend at another API, copy `frontend/.env.example` to `frontend/.env.local` and set `VITE_API_BASE_URL`. + +Run the frontend checks: + +```bash +npm run lint +npm test +npm run build +``` + --- # Seed Demo Data @@ -1268,6 +1278,7 @@ Frontend job: * installs dependencies with `npm ci` * runs ESLint (`npm run lint`) +* runs the Vitest + React Testing Library tests (`npm test`) * builds the production bundle (`npm run build`, including TypeScript type checking) --- @@ -1305,6 +1316,19 @@ The solution currently includes 59 unit tests covering: * Simulation candidate scoring logic * Demo data seeding and reset support +## Frontend tests + +The React dashboard has component tests with Vitest, React Testing Library and jsdom. `fetch` is stubbed with fixtures captured from the real API after `POST /api/demo/reset`, so no backend is needed. + +They cover: + +* Schedule overview rendering and Danish translation of risk indicators (including culture-independent coverage rates) +* Error page and retry when the API is unavailable +* Keeping the dashboard visible while refreshing after schedule generation +* Reloading employee preference profiles after a demo reset +* Weekly schedule placement per weekday, assigned/unassigned counts and filters +* Keeping the weekly schedule visible during refresh, plus error and retry + --- # Example Frontend Demo Flow diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..033e0c5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Base URL of the ShiftPlanner API (defaults to http://localhost:5026) +VITE_API_BASE_URL=http://localhost:5026 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9aba4f2..8dac81b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,10 +59,12 @@ function translateRiskType(type: string) { function translateRiskMessage(message: string) { if (message.startsWith("Schedule coverage is")) { + // The API formats the rate with the server culture ("78,57%" or "78.57%"), + // so only the sentence-ending period may be removed. const percentage = message .replace("Schedule coverage is", "") - .replace(".", "") - .trim(); + .trim() + .replace(/\.$/, ""); return `Vagtplanens dækningsgrad er ${percentage}.`; } diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..d92abd4 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,3 @@ +// Base URL of the ShiftPlanner API. Override with VITE_API_BASE_URL (see .env.example). +export const API_BASE_URL: string = + import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5026"; diff --git a/frontend/src/api/demoDataApi.ts b/frontend/src/api/demoDataApi.ts index bf3df4a..2dfded6 100644 --- a/frontend/src/api/demoDataApi.ts +++ b/frontend/src/api/demoDataApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type DemoSeedResult = { wasSeeded: boolean; message: string; @@ -5,8 +7,6 @@ export type DemoSeedResult = { shiftCount: number; }; -const API_BASE_URL = "http://localhost:5026"; - export async function resetDemoData(): Promise { const response = await fetch(`${API_BASE_URL}/api/demo/reset`, { method: "POST", diff --git a/frontend/src/api/employeeLoadDetailsApi.ts b/frontend/src/api/employeeLoadDetailsApi.ts index 21d7a0e..372f219 100644 --- a/frontend/src/api/employeeLoadDetailsApi.ts +++ b/frontend/src/api/employeeLoadDetailsApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type EmployeeAssignedShiftLoadItem = { shiftId: string; date: string; @@ -16,8 +18,6 @@ export type EmployeeLoadDetailsResponse = { assignedShifts: EmployeeAssignedShiftLoadItem[]; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getEmployeeLoadDetails( employeeId: string ): Promise { diff --git a/frontend/src/api/employeeLoadOverviewApi.ts b/frontend/src/api/employeeLoadOverviewApi.ts index 2a5b4f7..1bf9d19 100644 --- a/frontend/src/api/employeeLoadOverviewApi.ts +++ b/frontend/src/api/employeeLoadOverviewApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type EmployeeLoadOverviewItem = { employeeId: string; employeeName: string; @@ -7,8 +9,6 @@ export type EmployeeLoadOverviewItem = { isHighRisk: boolean; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getEmployeeLoadOverview(): Promise { const response = await fetch(`${API_BASE_URL}/api/employees/load-overview`); diff --git a/frontend/src/api/employeePreferenceProfilesApi.ts b/frontend/src/api/employeePreferenceProfilesApi.ts index a371d65..29cab05 100644 --- a/frontend/src/api/employeePreferenceProfilesApi.ts +++ b/frontend/src/api/employeePreferenceProfilesApi.ts @@ -1,4 +1,5 @@ import type { ShiftType } from "./scheduleSimulationApi"; +import { API_BASE_URL } from "./config"; export type EmployeePreferenceProfileOverviewItem = { employeeId: string; @@ -11,8 +12,6 @@ export type EmployeePreferenceProfileOverviewItem = { avoidsWeekends: boolean; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getEmployeePreferenceProfiles(): Promise< EmployeePreferenceProfileOverviewItem[] > { diff --git a/frontend/src/api/scheduleDataApi.ts b/frontend/src/api/scheduleDataApi.ts index b477728..84099d9 100644 --- a/frontend/src/api/scheduleDataApi.ts +++ b/frontend/src/api/scheduleDataApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type ShiftType = "Day" | "Evening" | "Night" | "OnCall"; export type Employee = { @@ -15,8 +17,6 @@ export type Shift = { requiredStaff: number; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getEmployees(): Promise { const response = await fetch(`${API_BASE_URL}/api/employees`); diff --git a/frontend/src/api/scheduleGenerationApi.ts b/frontend/src/api/scheduleGenerationApi.ts index ae40e07..37647c8 100644 --- a/frontend/src/api/scheduleGenerationApi.ts +++ b/frontend/src/api/scheduleGenerationApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type ScheduleAssignmentResult = { shiftId: string; employeeId: string | null; @@ -18,8 +20,6 @@ export type GenerateScheduleResponse = { assignments: ScheduleAssignmentResult[]; }; -const API_BASE_URL = "http://localhost:5026"; - export async function generateSchedule( request: GenerateScheduleRequest, ): Promise { diff --git a/frontend/src/api/scheduleOverviewApi.ts b/frontend/src/api/scheduleOverviewApi.ts index 9ee0c5a..30ef1df 100644 --- a/frontend/src/api/scheduleOverviewApi.ts +++ b/frontend/src/api/scheduleOverviewApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type ScheduleRiskLevel = "Low" | "Medium" | "High"; export type ShiftType = "Day" | "Evening" | "Night" | "OnCall"; @@ -47,8 +49,6 @@ export type ScheduleOverviewResponse = { unassignedShiftDetails: UnassignedShiftDetail[]; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getScheduleOverview(): Promise { const response = await fetch(`${API_BASE_URL}/api/schedule/overview`); diff --git a/frontend/src/api/scheduleSimulationApi.ts b/frontend/src/api/scheduleSimulationApi.ts index 4d5d314..bd2c788 100644 --- a/frontend/src/api/scheduleSimulationApi.ts +++ b/frontend/src/api/scheduleSimulationApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type ScheduleRiskLevel = "Low" | "Medium" | "High"; export type ShiftType = "Day" | "Evening" | "Night" | "OnCall"; @@ -36,8 +38,6 @@ export type SimulateScheduleResponse = { candidateResults: SimulationCandidateResult[]; }; -const API_BASE_URL = "http://localhost:5026"; - export async function simulateSchedule( request: SimulateScheduleRequest, ): Promise { diff --git a/frontend/src/api/shiftAssignmentAnalysisApi.ts b/frontend/src/api/shiftAssignmentAnalysisApi.ts index 09038e4..dac70a0 100644 --- a/frontend/src/api/shiftAssignmentAnalysisApi.ts +++ b/frontend/src/api/shiftAssignmentAnalysisApi.ts @@ -1,3 +1,5 @@ +import { API_BASE_URL } from "./config"; + export type ShiftAssignmentCandidateResult = { employeeId: string; employeeName: string; @@ -16,8 +18,6 @@ export type ShiftAssignmentAnalysisResponse = { candidateResults: ShiftAssignmentCandidateResult[]; }; -const API_BASE_URL = "http://localhost:5026"; - export async function getShiftAssignmentAnalysis( shiftId: string, maxAssignmentsPerEmployee: number From 3e447847db9e30954ffa720eadd24086b4077ffc Mon Sep 17 00:00:00 2001 From: kris7011 Date: Fri, 16 Oct 2026 13:40:51 +0200 Subject: [PATCH 2/2] Add Vitest and React Testing Library tests for the dashboard Tests use fixtures captured from the real API after a demo reset and cover the overview, error/retry, refresh behaviour after generation, preference reload after reset and the weekly schedule table. CI now runs npm test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dotnet.yml | 5 +- frontend/package-lock.json | 1067 ++++++++++++++++- frontend/package.json | 10 +- frontend/src/App.test.tsx | 126 ++ .../components/WeeklyScheduleTable.test.tsx | 105 ++ frontend/src/test/fakeApi.ts | 74 ++ frontend/src/test/fixtures/employees.json | 79 ++ frontend/src/test/fixtures/load-overview.json | 109 ++ .../test/fixtures/preference-profiles.json | 106 ++ .../src/test/fixtures/schedule-overview.json | 156 +++ frontend/src/test/fixtures/shifts.json | 114 ++ frontend/src/test/setup.ts | 9 + frontend/tsconfig.app.json | 1 + frontend/vite.config.ts | 5 + 14 files changed, 1948 insertions(+), 18 deletions(-) create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/components/WeeklyScheduleTable.test.tsx create mode 100644 frontend/src/test/fakeApi.ts create mode 100644 frontend/src/test/fixtures/employees.json create mode 100644 frontend/src/test/fixtures/load-overview.json create mode 100644 frontend/src/test/fixtures/preference-profiles.json create mode 100644 frontend/src/test/fixtures/schedule-overview.json create mode 100644 frontend/src/test/fixtures/shifts.json create mode 100644 frontend/src/test/setup.ts diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c2f60a5..c7dbed4 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -38,7 +38,7 @@ jobs: run: dotnet test --no-build --verbosity normal frontend: - name: Frontend (React lint and build) + name: Frontend (React lint, test and build) runs-on: ubuntu-latest defaults: @@ -62,5 +62,8 @@ jobs: - name: Lint run: npm run lint + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c936420..606304f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -21,9 +24,71 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^30.1.3", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^5.0.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.3.tgz", + "integrity": "sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@babel/code-frame": { @@ -218,6 +283,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -266,6 +341,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -428,6 +656,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -527,9 +773,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -855,6 +1101,105 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -866,6 +1211,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1186,6 +1557,44 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1226,6 +1635,51 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1249,6 +1703,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -1317,6 +1781,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1339,6 +1813,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1346,6 +1841,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1364,6 +1873,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1371,6 +1887,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1381,12 +1907,40 @@ "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.360", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", - "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.360", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", + "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, - "license": "ISC" + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", @@ -1583,6 +2137,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1593,6 +2157,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1751,6 +2325,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1771,6 +2358,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1794,6 +2391,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1808,6 +2412,56 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2178,6 +2832,44 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2234,6 +2926,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2284,6 +2990,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2312,9 +3031,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2363,6 +3082,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2394,6 +3129,38 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2428,6 +3195,19 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, + "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", @@ -2477,10 +3257,50 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2494,6 +3314,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2566,6 +3432,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -2692,6 +3568,137 @@ } } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2708,6 +3715,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2718,6 +3738,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 70b0913..5270b31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^19.2.6", @@ -15,6 +16,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -23,8 +27,10 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^30.1.3", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^5.0.5" } } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..d86752f --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import App from "./App"; +import { deferred, fixtures, jsonResponse, stubApi } from "./test/fakeApi"; + +function preferencesSection() { + return screen + .getByRole("heading", { name: "Medarbejderpræferencer" }) + .closest("section") as HTMLElement; +} + +describe("App", () => { + it("renders the schedule overview with translated risk indicators", async () => { + stubApi(); + + render(); + + expect(screen.getByText("Henter data...")).toBeInTheDocument(); + expect(await screen.findByText("Kræver opmærksomhed")).toBeInTheDocument(); + // The API formats the coverage rate with the server culture; a decimal point must survive translation. + expect(screen.getByText("Vagtplanens dækningsgrad er 78.57%.")).toBeInTheDocument(); + expect(screen.getByText("3 vagt(er) er aktuelt ubesatte.")).toBeInTheDocument(); + expect( + screen.getByText("UL kræves af 1 ubesat vagt, men ingen medarbejdere har denne kompetence."), + ).toBeInTheDocument(); + }); + + it("shows an error page and recovers when retrying", async () => { + let failing = true; + stubApi([ + (_method, path) => { + if (failing && path === "/api/schedule/overview") { + return new Response("Service unavailable", { status: 503 }); + } + }, + ]); + const user = userEvent.setup(); + + render(); + + expect( + await screen.findByText("Kunne ikke hente vagtplanens overblik fra API'et."), + ).toBeInTheDocument(); + + failing = false; + await user.click(screen.getByRole("button", { name: "Prøv igen" })); + + expect(await screen.findByText("Kræver opmærksomhed")).toBeInTheDocument(); + }); + + it("keeps the dashboard visible while refreshing after generating a schedule", async () => { + let generated = false; + const refreshedOverview = deferred(); + const fetchMock = stubApi([ + (method, path, body) => { + if (method === "POST" && path === "/api/schedule/generate") { + expect(body).toEqual({ maxAssignmentsPerEmployee: 5 }); + generated = true; + return jsonResponse({ + message: "Schedule generation completed.", + employeeCount: 10, + shiftCount: 14, + assignments: [ + { shiftId: "1", employeeId: "e1", employeeName: "Jonas", requiredSkill: "CT", wasAssigned: true, failureReasons: [] }, + { shiftId: "2", employeeId: null, employeeName: null, requiredSkill: "UL", wasAssigned: false, failureReasons: [] }, + ], + }); + } + if (generated && method === "GET" && path === "/api/schedule/overview") { + return refreshedOverview.promise; + } + }, + ]); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: "Generér vagtplan" })); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/schedule/generate"), + expect.objectContaining({ method: "POST" }), + ), + ); + expect(screen.queryByText("Henter data...")).not.toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Dashboard navigation" })).toBeInTheDocument(); + + refreshedOverview.resolve(jsonResponse(fixtures.scheduleOverview)); + + expect( + await screen.findByText( + "Vagtplanen blev genereret. 1 vagt(er) blev tildelt, og 1 vagt(er) kunne ikke tildeles.", + ), + ).toBeInTheDocument(); + }); + + it("reloads employee preference profiles after resetting demo data", async () => { + let reset = false; + stubApi([ + (method, path) => { + if (method === "POST" && path === "/api/demo/reset") { + reset = true; + return jsonResponse({ wasSeeded: true, message: "Demo data was reset and seeded.", employeeCount: 10, shiftCount: 14 }); + } + if (method === "GET" && path === "/api/employees/preference-profiles") { + // An empty database has no employees and therefore no profiles until the reset. + return jsonResponse(reset ? fixtures.preferenceProfiles : []); + } + }, + ]); + const user = userEvent.setup(); + + render(); + await user.click(await screen.findByRole("button", { name: /Medarbejdere/ })); + expect(within(preferencesSection()).queryByText("Henrik")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Nulstil demo-data" })); + + expect( + await screen.findByText(/Demo-data blev nulstillet\. 10 medarbejdere og 14 vagter blev oprettet\./), + ).toBeInTheDocument(); + expect(await within(preferencesSection()).findByText("Henrik")).toBeInTheDocument(); + expect(within(preferencesSection()).getByText("Undgår helst weekend")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WeeklyScheduleTable.test.tsx b/frontend/src/components/WeeklyScheduleTable.test.tsx new file mode 100644 index 0000000..aa3b08b --- /dev/null +++ b/frontend/src/components/WeeklyScheduleTable.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { deferred, fixtures, jsonResponse, stubApi } from "../test/fakeApi"; +import { WeeklyScheduleTable } from "./WeeklyScheduleTable"; + +function summaryValue(label: string) { + return screen.getByText(label).nextElementSibling; +} + +function dayCard(dayLabel: string) { + return screen.getByText(dayLabel).closest(".schedule-day-card") as HTMLElement; +} + +describe("WeeklyScheduleTable", () => { + it("places shifts on the right weekday and shows who is assigned", async () => { + stubApi(); + + render(); + + expect(screen.getByText("Henter ugeplan...")).toBeInTheDocument(); + expect(await screen.findByText("Vagter i ugeplanen")).toBeInTheDocument(); + expect(summaryValue("Vagter i ugeplanen")).toHaveTextContent("14"); + expect(summaryValue("Tildelte vagter")).toHaveTextContent("4"); + expect(summaryValue("Ubesatte vagter")).toHaveTextContent("10"); + + const monday = dayCard("Mandag"); + expect(within(monday).getByText("Tildelt: Kris")).toBeInTheDocument(); + expect(within(monday).getByText("Tildelt: Mette")).toBeInTheDocument(); + + const friday = dayCard("Fredag"); + expect(within(friday).getByText("Kræver UL")).toBeInTheDocument(); + expect(within(friday).getAllByText("Ikke tildelt")).toHaveLength(2); + + expect(within(dayCard("Lørdag")).getAllByText("Weekend")).toHaveLength(1); + }); + + it("filters the week to only unassigned or only assigned shifts", async () => { + stubApi(); + const user = userEvent.setup(); + + render(); + await screen.findByText("Vagter i ugeplanen"); + + await user.click(screen.getByRole("button", { name: "Kun ubesatte" })); + expect(screen.queryByText(/^Tildelt: /)).not.toBeInTheDocument(); + expect(screen.getAllByText("Ikke tildelt")).toHaveLength(10); + expect(within(dayCard("Mandag")).getByText("Ingen vagter")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Kun tildelte" })); + expect(screen.queryByText("Ikke tildelt")).not.toBeInTheDocument(); + expect(screen.getAllByText(/^Tildelt: /)).toHaveLength(4); + }); + + it("keeps the current week visible while refreshing after refreshKey changes", async () => { + let refreshed = false; + const refreshedShifts = deferred(); + stubApi([ + (method, path) => { + if (refreshed && method === "GET" && path === "/api/shifts") { + return refreshedShifts.promise; + } + }, + ]); + + const { rerender } = render(); + await screen.findByText("Tildelt: Kris"); + + refreshed = true; + rerender(); + + expect(screen.queryByText("Henter ugeplan...")).not.toBeInTheDocument(); + expect(screen.getByText("Tildelt: Kris")).toBeInTheDocument(); + + const allAssignedToKris = fixtures.shifts.map((shift) => ({ + ...shift, + employeeId: fixtures.employees[0].id, + })); + refreshedShifts.resolve(jsonResponse(allAssignedToKris)); + + await waitFor(() => expect(summaryValue("Tildelte vagter")).toHaveTextContent("14")); + }); + + it("shows an error and can retry when the API is unavailable", async () => { + let failing = true; + stubApi([ + (_method, path) => { + if (failing && path === "/api/shifts") { + return new Response("Service unavailable", { status: 503 }); + } + }, + ]); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByText("Kunne ikke hente vagtplanens skemadata.")).toBeInTheDocument(); + + failing = false; + await user.click(screen.getByRole("button", { name: "Prøv igen" })); + + expect(await screen.findByText("Tildelt: Kris")).toBeInTheDocument(); + expect(screen.queryByText("Kunne ikke hente vagtplanens skemadata.")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/fakeApi.ts b/frontend/src/test/fakeApi.ts new file mode 100644 index 0000000..6abe3fb --- /dev/null +++ b/frontend/src/test/fakeApi.ts @@ -0,0 +1,74 @@ +import { vi } from "vitest"; +import employees from "./fixtures/employees.json"; +import loadOverview from "./fixtures/load-overview.json"; +import preferenceProfiles from "./fixtures/preference-profiles.json"; +import scheduleOverview from "./fixtures/schedule-overview.json"; +import shifts from "./fixtures/shifts.json"; + +// Fixtures are real responses captured from the API after POST /api/demo/reset. +export const fixtures = { + employees, + shifts, + scheduleOverview, + loadOverview, + preferenceProfiles, +}; + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +type Route = ( + method: string, + path: string, + body: unknown, +) => Response | Promise | undefined; + +const defaultGetResponses: Record = { + "/api/employees": employees, + "/api/shifts": shifts, + "/api/schedule/overview": scheduleOverview, + "/api/employees/load-overview": loadOverview, + "/api/employees/preference-profiles": preferenceProfiles, +}; + +/** + * Replaces global fetch with an in-memory version of the API. + * Custom routes are tried first; unmatched GET requests fall back to the fixtures. + */ +export function stubApi(routes: Route[] = []) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body.toString()) : undefined; + + for (const route of routes) { + const response = route(method, url.pathname, body); + if (response) { + return response; + } + } + + if (method === "GET" && url.pathname in defaultGetResponses) { + return jsonResponse(defaultGetResponses[url.pathname]); + } + + return jsonResponse({ error: `No fake route for ${method} ${url.pathname}` }, 404); + }); + + vi.stubGlobal("fetch", fetchMock); + + return fetchMock; +} + +export function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + + return { promise, resolve }; +} diff --git a/frontend/src/test/fixtures/employees.json b/frontend/src/test/fixtures/employees.json new file mode 100644 index 0000000..693bace --- /dev/null +++ b/frontend/src/test/fixtures/employees.json @@ -0,0 +1,79 @@ +[ + { + "id": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3", + "name": "Kris", + "skills": [ + "CT", + "XR" + ] + }, + { + "id": "312f340c-8955-45c5-bb58-a81ea4c16409", + "name": "Mette", + "skills": [ + "MRI", + "XR" + ] + }, + { + "id": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f", + "name": "Henrik", + "skills": [ + "CT", + "Night" + ] + }, + { + "id": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c", + "name": "Sofie", + "skills": [ + "XR", + "Night" + ] + }, + { + "id": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b", + "name": "Lars", + "skills": [ + "MRI", + "CT" + ] + }, + { + "id": "a0518392-3c90-49ad-894b-366e92d95b0e", + "name": "Anna", + "skills": [ + "XR" + ] + }, + { + "id": "add63646-029b-4e20-8b54-56fcf901eb65", + "name": "Jonas", + "skills": [ + "CT" + ] + }, + { + "id": "6a224f4d-aa65-460f-b921-d2ce3ac57e22", + "name": "Maria", + "skills": [ + "MRI", + "Night" + ] + }, + { + "id": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761", + "name": "Peter", + "skills": [ + "XR", + "CT" + ] + }, + { + "id": "89613300-1b09-4a82-a38a-3099125ef018", + "name": "Line", + "skills": [ + "MRI" + ] + } +] diff --git a/frontend/src/test/fixtures/load-overview.json b/frontend/src/test/fixtures/load-overview.json new file mode 100644 index 0000000..14d5690 --- /dev/null +++ b/frontend/src/test/fixtures/load-overview.json @@ -0,0 +1,109 @@ +[ + { + "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f", + "employeeName": "Henrik", + "skills": [ + "CT", + "Night" + ], + "totalLoad": 4, + "loadStatus": "Medium", + "isHighRisk": false + }, + { + "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409", + "employeeName": "Mette", + "skills": [ + "MRI", + "XR" + ], + "totalLoad": 2, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3", + "employeeName": "Kris", + "skills": [ + "CT", + "XR" + ], + "totalLoad": 1, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c", + "employeeName": "Sofie", + "skills": [ + "XR", + "Night" + ], + "totalLoad": 1, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "a0518392-3c90-49ad-894b-366e92d95b0e", + "employeeName": "Anna", + "skills": [ + "XR" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "add63646-029b-4e20-8b54-56fcf901eb65", + "employeeName": "Jonas", + "skills": [ + "CT" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b", + "employeeName": "Lars", + "skills": [ + "MRI", + "CT" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "89613300-1b09-4a82-a38a-3099125ef018", + "employeeName": "Line", + "skills": [ + "MRI" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "6a224f4d-aa65-460f-b921-d2ce3ac57e22", + "employeeName": "Maria", + "skills": [ + "MRI", + "Night" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + }, + { + "employeeId": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761", + "employeeName": "Peter", + "skills": [ + "XR", + "CT" + ], + "totalLoad": 0, + "loadStatus": "Low", + "isHighRisk": false + } +] diff --git a/frontend/src/test/fixtures/preference-profiles.json b/frontend/src/test/fixtures/preference-profiles.json new file mode 100644 index 0000000..e4dd46e --- /dev/null +++ b/frontend/src/test/fixtures/preference-profiles.json @@ -0,0 +1,106 @@ +[ + { + "employeeId": "a0518392-3c90-49ad-894b-366e92d95b0e", + "employeeName": "Anna", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f", + "employeeName": "Henrik", + "preferredShiftTypes": [ + "Night" + ], + "dislikedShiftTypes": [], + "maxNightShifts": 3, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "add63646-029b-4e20-8b54-56fcf901eb65", + "employeeName": "Jonas", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3", + "employeeName": "Kris", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b", + "employeeName": "Lars", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "89613300-1b09-4a82-a38a-3099125ef018", + "employeeName": "Line", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "6a224f4d-aa65-460f-b921-d2ce3ac57e22", + "employeeName": "Maria", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409", + "employeeName": "Mette", + "preferredShiftTypes": [], + "dislikedShiftTypes": [ + "Night" + ], + "maxNightShifts": 1, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + }, + { + "employeeId": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761", + "employeeName": "Peter", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": true + }, + { + "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c", + "employeeName": "Sofie", + "preferredShiftTypes": [], + "dislikedShiftTypes": [], + "maxNightShifts": null, + "maxEveningShifts": null, + "prefersWeekends": false, + "avoidsWeekends": false + } +] diff --git a/frontend/src/test/fixtures/schedule-overview.json b/frontend/src/test/fixtures/schedule-overview.json new file mode 100644 index 0000000..cd72c5b --- /dev/null +++ b/frontend/src/test/fixtures/schedule-overview.json @@ -0,0 +1,156 @@ +{ + "totalShifts": 14, + "assignedShifts": 11, + "unassignedShifts": 3, + "coverageRate": 78.57, + "employeeCount": 10, + "highRiskEmployeeCount": 0, + "unassignedShiftDetails": [ + { + "shiftId": "ce5649f6-da84-4588-a033-e7123cbd1153", + "date": "2026-05-15", + "shiftType": "Day", + "requiredSkill": "UL", + "failureReasons": [ + "Kris: Missing required skill 'UL'.", + "Mette: Missing required skill 'UL'.", + "Henrik: Missing required skill 'UL'.", + "Sofie: Missing required skill 'UL'.", + "Lars: Missing required skill 'UL'.", + "Anna: Missing required skill 'UL'.", + "Jonas: Missing required skill 'UL'.", + "Maria: Missing required skill 'UL'.", + "Peter: Missing required skill 'UL'.", + "Line: Missing required skill 'UL'." + ] + }, + { + "shiftId": "57a0f822-1470-46c0-9725-e9c426ca2457", + "date": "2026-05-15", + "shiftType": "Evening", + "requiredSkill": "Intervention", + "failureReasons": [ + "Kris: Missing required skill 'Intervention'.", + "Mette: Missing required skill 'Intervention'.", + "Henrik: Missing required skill 'Intervention'.", + "Sofie: Missing required skill 'Intervention'.", + "Lars: Missing required skill 'Intervention'.", + "Anna: Missing required skill 'Intervention'.", + "Jonas: Missing required skill 'Intervention'.", + "Maria: Missing required skill 'Intervention'.", + "Peter: Missing required skill 'Intervention'.", + "Line: Missing required skill 'Intervention'." + ] + }, + { + "shiftId": "48a89f5e-3dd5-4968-b8ef-ad3b1f7ab191", + "date": "2026-05-17", + "shiftType": "Night", + "requiredSkill": "Night", + "failureReasons": [ + "Kris: Missing required skill 'Night'.", + "Mette: Missing required skill 'Night'.", + "Henrik: Employee projected workload would be too high.", + "Sofie: Employee projected workload would be too high.", + "Lars: Missing required skill 'Night'.", + "Anna: Missing required skill 'Night'.", + "Jonas: Missing required skill 'Night'.", + "Maria: Employee projected workload would be too high.", + "Peter: Missing required skill 'Night'.", + "Line: Missing required skill 'Night'." + ] + } + ], + "skillGaps": [ + { + "requiredSkill": "Intervention", + "unassignedShiftCount": 1 + }, + { + "requiredSkill": "Night", + "unassignedShiftCount": 1 + }, + { + "requiredSkill": "UL", + "unassignedShiftCount": 1 + } + ], + "riskSummary": { + "coverageRisk": "Medium", + "unassignedShiftCount": 3, + "skillGapCount": 3, + "highRiskEmployeeCount": 0 + }, + "riskIndicators": [ + { + "type": "Coverage", + "severity": "Medium", + "message": "Schedule coverage is 78.57%." + }, + { + "type": "UnassignedShifts", + "severity": "Medium", + "message": "3 shift(s) are currently unassigned." + }, + { + "type": "SkillGap", + "severity": "Medium", + "message": "1 unassigned shift(s) require Intervention." + }, + { + "type": "SkillGap", + "severity": "Medium", + "message": "1 unassigned shift(s) require Night." + }, + { + "type": "SkillGap", + "severity": "Medium", + "message": "1 unassigned shift(s) require UL." + }, + { + "type": "Capacity", + "severity": "High", + "message": "Intervention is required by 1 unassigned shift(s), but no employees have this skill." + }, + { + "type": "Capacity", + "severity": "High", + "message": "UL is required by 1 unassigned shift(s), but no employees have this skill." + } + ], + "skillCapacity": [ + { + "skill": "CT", + "employeeCount": 5 + }, + { + "skill": "MRI", + "employeeCount": 4 + }, + { + "skill": "Night", + "employeeCount": 3 + }, + { + "skill": "XR", + "employeeCount": 5 + } + ], + "uncoveredRequiredSkills": [ + { + "skill": "Intervention", + "requiredByUnassignedShifts": 1, + "availableEmployees": 0 + }, + { + "skill": "UL", + "requiredByUnassignedShifts": 1, + "availableEmployees": 0 + } + ], + "capacitySummary": { + "totalSkills": 4, + "missingRequiredSkills": 2, + "criticalSkillGaps": 2 + } +} diff --git a/frontend/src/test/fixtures/shifts.json b/frontend/src/test/fixtures/shifts.json new file mode 100644 index 0000000..9dff1ee --- /dev/null +++ b/frontend/src/test/fixtures/shifts.json @@ -0,0 +1,114 @@ +[ + { + "id": "3105c80e-4f29-4b1b-b472-a51569292643", + "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3", + "date": "2026-05-11", + "shiftType": "Day", + "requiredSkill": "CT", + "requiredStaff": 1 + }, + { + "id": "0a5664ed-d58f-4a51-baf8-a506f9c0e490", + "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409", + "date": "2026-05-11", + "shiftType": "Evening", + "requiredSkill": "MRI", + "requiredStaff": 1 + }, + { + "id": "9e1a4f4a-0204-471f-9e4b-2c1aaeea7e27", + "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f", + "date": "2026-05-12", + "shiftType": "Night", + "requiredSkill": "Night", + "requiredStaff": 1 + }, + { + "id": "f02474ea-9dba-40f0-af72-fc8dbaa12c0b", + "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c", + "date": "2026-05-12", + "shiftType": "Day", + "requiredSkill": "XR", + "requiredStaff": 1 + }, + { + "id": "7b7cfa03-deee-4c7a-acd6-716ba059dc7f", + "employeeId": null, + "date": "2026-05-13", + "shiftType": "Day", + "requiredSkill": "CT", + "requiredStaff": 1 + }, + { + "id": "733c4a0a-db62-4960-b66a-f238c1266575", + "employeeId": null, + "date": "2026-05-13", + "shiftType": "Evening", + "requiredSkill": "MRI", + "requiredStaff": 1 + }, + { + "id": "8cecf40b-cbc8-48dc-9448-281f85a4a39b", + "employeeId": null, + "date": "2026-05-14", + "shiftType": "Night", + "requiredSkill": "Night", + "requiredStaff": 1 + }, + { + "id": "2efad4f3-f629-4c96-a4a9-77a946979c6b", + "employeeId": null, + "date": "2026-05-14", + "shiftType": "Day", + "requiredSkill": "XR", + "requiredStaff": 1 + }, + { + "id": "ce5649f6-da84-4588-a033-e7123cbd1153", + "employeeId": null, + "date": "2026-05-15", + "shiftType": "Day", + "requiredSkill": "UL", + "requiredStaff": 1 + }, + { + "id": "57a0f822-1470-46c0-9725-e9c426ca2457", + "employeeId": null, + "date": "2026-05-15", + "shiftType": "Evening", + "requiredSkill": "Intervention", + "requiredStaff": 1 + }, + { + "id": "d2714adf-3fcd-4fc5-b861-a067ada96281", + "employeeId": null, + "date": "2026-05-16", + "shiftType": "Day", + "requiredSkill": "CT", + "requiredStaff": 1 + }, + { + "id": "7a88dafa-8b10-4a02-96cb-53c5aef2a755", + "employeeId": null, + "date": "2026-05-16", + "shiftType": "Night", + "requiredSkill": "Night", + "requiredStaff": 1 + }, + { + "id": "8995a793-ad88-4396-8414-ae233f32334a", + "employeeId": null, + "date": "2026-05-17", + "shiftType": "Day", + "requiredSkill": "XR", + "requiredStaff": 1 + }, + { + "id": "48a89f5e-3dd5-4968-b8ef-ad3b1f7ab191", + "employeeId": null, + "date": "2026-05-17", + "shiftType": "Night", + "requiredSkill": "Night", + "requiredStaff": 1 + } +] diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..b2225d7 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,9 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 7f42e5f..0d272e5 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -9,6 +9,7 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..6e6a47f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,12 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, })