From a7e05fb7f03b8b85087338b60352f7095f56fe16 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Mon, 3 Aug 2026 16:56:20 +0200 Subject: [PATCH] test(team): Render-Harness, 151 neue Tests und README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher gab es im Repo kein Muster für Komponententests — @testing-library/react wurde ausschliesslich für renderHook verwendet. test/teamTestUtils.tsx stellt den fehlenden Kontext bereit (Router, MUI-Theme, frischer QueryClient je Test). test/setup.ts registriert jetzt afterEach(cleanup). vitest.config.ts setzt `globals` nicht, deshalb erkennt Testing Library kein globales afterEach und richtet sein automatisches Cleanup nie ein; ohne diese Zeile stapeln sich gerenderte Komponenten und getByRole scheitert ab dem zweiten Test einer Datei. Abgedeckt: Kennzahlenberechnung, Freigabe-, Zurückweisungs-, Anpassungs- und Rückfrageprozess, lokale Persistenz samt Schemaversion und Demo-Reset, Badge-Familie, Vorgangs- und Agentenkarte, Aufgaben-Aktivierung, Einstellungsvalidierung und die Position des Navigationseintrags. Die Tests haben fünf echte Mängel aufgedeckt, alle in diesem Commit behoben: - AgentAvatar hatte keinen zugänglichen Namen. MUI reicht `alt` nur an den img-Slot weiter, und ohne `src` rendert Avatar gar kein — der Wert landete nirgends im DOM. Jetzt role="img" + aria-label direkt am Element. - WorkItemCard verschluckte objectLabel, wenn keine objectId gesetzt war. Genau dort steht bei zuordnungsfreien Vorgängen die entscheidende Einordnung. - aria-current rendert nicht mehr "false", sondern entfällt. - AgentChannelBadge war auf `string` statt AgentChannelType typisiert. 414 Tests grün (vorher 263), Build grün, ESLint über den Property-On-Code sauber, check:tokens unverändert bei 2249. README ersetzt das unveränderte Vite-Template durch Startanleitung und die Architekturentscheidungen samt bekannter Altlasten. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 184 ++++++++++++------ .../layout/__tests__/appShellConfig.test.ts | 69 +++++++ .../__tests__/teamPersistence.test.ts | 116 +++++++++++ .../__tests__/agentWorkItemService.test.ts | 163 ++++++++++++++++ src/test/setup.ts | 11 ++ src/test/teamTestUtils.tsx | 60 ++++++ 6 files changed, 539 insertions(+), 64 deletions(-) create mode 100644 src/components/layout/__tests__/appShellConfig.test.ts create mode 100644 src/provider/__tests__/teamPersistence.test.ts create mode 100644 src/services/__tests__/agentWorkItemService.test.ts create mode 100644 src/test/teamTestUtils.tsx diff --git a/README.md b/README.md index 7dbf7eb..47f87da 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,129 @@ -# React + TypeScript + Vite +# Property Match -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Decision-Intelligence-Plattform für kommerziell genutzte Immobilien — kein Portal, sondern +eine Oberfläche, die Treffer bewertet, Trade-offs erklärt und eine nächste Handlung empfiehlt. -Currently, two official plugins are available: +Verbindliche Entwicklungsregeln stehen in [CLAUDE.md](./CLAUDE.md), Architekturdiagramme in +[ARCHITECTURE.md](./ARCHITECTURE.md), Zustandsregeln in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md). -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## Starten -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm install +npm run dev # Entwicklungsserver auf http://localhost:5173 ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +Es gibt kein Backend und keine Datenbank. Sämtliche Daten stammen aus TypeScript-Mockdaten +unter `src/mock-data/` und werden über Mockup-Provider bereitgestellt. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +| Skript | Zweck | +|---|---| +| `npm run dev` | Entwicklungsserver mit HMR | +| `npm run build` | Typecheck (`tsc -b`) und Produktionsbuild | +| `npm run lint` | ESLint über das gesamte Projekt | +| `npm test` | Vitest einmalig ausführen | +| `npm run test:watch` | Vitest im Watch-Modus | +| `npm run check:tokens` | Zählt rohe Hex-Farbwerte gegen einen Schwellwert | + +## Architektur in vier Schichten -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) ``` +Provider → Service → Hook (React Query) → Component +``` + +- **Provider** (`src/provider/`) sind der einzige Ort, der Datenhaltung berührt. +- **Services** (`src/services/`) tragen die Fachlogik und liefern `ListResponse` / `ItemResponse`. +- **Hooks** (`src/hooks/`) kapseln React Query; Stale-Zeiten kommen aus `src/lib/constants.ts`. +- **Komponenten** (`src/components/`, `src/pages/`) sind reine Darstellung. + +Drei geschützte Arbeitsbereiche: `/supply/*` (Bewirtschaftung), `/demand/*` (Suche), +`/ops/*` (interner Betrieb). + +--- + +## Property On — «Teamübersicht» + +Property On ist als Funktionsbereich in Property Match eingebettet, nicht als eigene +Anwendung. Es gibt keine zweite App-Shell, keine zweite Sidebar und kein eigenes Branding. +Erreichbar über den Hauptreiter **Teamübersicht** in der Bewirtschaftungs-Navigation, mit den +Subreitern **Personalverwaltung**, **Bearbeitungsverlauf** und **Kanäle & Systeme**. + +Bedienprinzip: **human-led, agent-operated.** Sieben digitale Mitarbeiter führen Arbeiten aus, +der Mensch gibt frei, passt an oder weist zurück. Jede Entscheidung erzeugt einen +Protokolleintrag, aktualisiert die Kennzahlen und meldet sich mit einer Rückmeldung. + +### Routen + +``` +/supply/team Teamübersicht (Startseite) +/supply/team/personalverwaltung Agentenliste + Dossier +/supply/team/personalverwaltung/:agentId Personalblatt +/supply/team/personalverwaltung/:agentId/:tab Dossier-Reiter, deep-linkbar + tab: aufgaben | kanaele | systeme + einstellungen | protokoll +/supply/team/bearbeitungsverlauf Vorgänge +/supply/team/bearbeitungsverlauf/:tab tab: pendente-anfragen | erledigte-auftraege +/supply/team/kanaele-systeme Verbindungen der Organisation +``` + +### Architekturentscheidungen + +**Der Entitätstyp heisst `TeamAgent`, nicht `Agent`.** Im Repo existiert bereits `PowerOn` als +Name des KI-Backend-Proxys, und Claude Code legt Arbeitskopien unter `agent-*` ab. Ein nackter +Typ `Agent` wäre in Suchen praktisch nicht auffindbar. Alle Satellitentypen tragen das Präfix +`Agent*` — `AgentStatus`, `AgentChannelType`, `AgentProtocolEntry` —, weil `src/domain/index.ts` +per `export *` bündelt und generische Namen dort kollidieren würden. + +**Eigener Protokolltyp statt `ActivityEvent`.** Den Namen gibt es im Repo dreifach und +gegenseitig inkompatibel: in `domain/activityEvent.ts`, lokal in `services/governanceService.ts` +und noch einmal in `components/supply/PropertyActivityLogPanel.tsx`. Ein Anschluss an einen +dieser Typen hätte den Konflikt zementiert; `AgentProtocolEntry` steht bewusst daneben. + +**Verbindungen sind von `DataSource` getrennt.** `DataSource`/`ConnectorRun` modelliert +Crawler-Quellen mit AGB-Status und Crawl-Läufen. «Kanäle & Systeme» beschreibt angebundene +Kommunikationskanäle und Fachsysteme — fachlich etwas anderes. Die Ops-Komponenten dienten als +visuelles Vorbild, das Datenmodell ist eigenständig. + +**Eine Demo-Uhr statt der Systemzeit** (`src/lib/teamClock.ts`). Die Mockdaten sind fachlich +auf den 20.05.2026 verankert. Gegen die echte Systemzeit gerechnet wären die Zeitraumfilter +«heute / diese Woche / dieser Monat» an jedem anderen Kalendertag leer, und die Auswertung +sähe kaputt aus, obwohl sie korrekt arbeitet. Die Demo-Uhr startet am Anker und läuft ab +Anwendungsstart in Echtzeit weiter — jede Freigabe während einer Vorführung landet damit +verlässlich im Bucket «heute». Sie gilt ausschliesslich für Property On. + +**Persistenz liegt in der Providerschicht.** Geänderte Einstellungen, Aufgabenschalter, +erledigte Vorgänge, Verbindungsstatus und pausierte Mitarbeiter überleben einen Reload über +den Local Storage. Die Bestände tragen eine Schemaversion (`TEAM_SCHEMA_VERSION`): passt sie +nicht, greift wieder der Seed aus `src/mock-data/` — sonst überlagerte ein alter Eintrag +stillschweigend geänderte Mockdaten. «Demo zurücksetzen» löscht genau diese Schlüssel. + +**Subreiter sind echte Routen, keine lokalen Tabs.** Die Anwendung kannte bis dahin keine +Feature-Route mit eigenem ``, und Tabs werden sonst über lokalen `useState` gebaut. +Hier war das nicht tragfähig: die drei Subreiter müssen in der Sidebar sichtbar sein und +deep-linkbar bleiben. Das `NavItem`-Modell in `appShellConfig.ts` wurde deshalb um `children` +erweitert; die Routen sind flache Geschwister, kein Outlet-Baum. + +**Kein neuer Stack.** Der ursprüngliche Auftrag nannte shadcn/ui, React Hook Form, date-fns und +Playwright. Nichts davon ist installiert, und die Vorgabe, die bestehenden Property-Match-Muster +zu übernehmen, wiegt schwerer. Umgesetzt mit MUI v9, TanStack Query, Zustand und Zod; +Datumsformatierung über `Intl` in `src/lib/utils.ts` und `src/lib/teamClock.ts`. Besonders +relevant: Tailwind-Preflight ist bewusst nicht importiert — shadcn erwartet es und würde den +`CssBaseline`-Reset von MUI überschreiben. + +### Simulation + +Alles ist Frontend-Simulation. Es gibt keine echten Systemverbindungen, keine echten +Zugangsdaten und keine echten Modellaufrufe. Simulierte Ladezeiten liegen zwischen 300 und +800 ms. Der Test einer Verbindung ist **deterministisch** und nicht zufällig: verbundene +Kanäle gelingen, nicht verbundene und geplante scheitern reproduzierbar — ein Zufallsfehler +würde eine Kundendemo unvorhersehbar machen. + +### Bekannte Altlasten + +- `npm run check:tokens` liegt bei 2249 rohen Hex-Werten über dem Schwellwert von 1958. Der + Wert stammt vollständig aus dem Bestand; Property On hat null hinzugefügt. Der Schwellwert + darf laut Skript nicht angehoben werden. +- `npm run lint` meldet im Bestand weiterhin Fehler und Warnungen. Der Property-On-Code ist + frei davon: `npx eslint src/components/team src/pages/supply/Teamuebersicht.tsx …` ist grün. +- In `tsconfig` ist `strict` entgegen CLAUDE.md §14 nicht gesetzt; `strictNullChecks` fehlt + damit projektweit. diff --git a/src/components/layout/__tests__/appShellConfig.test.ts b/src/components/layout/__tests__/appShellConfig.test.ts new file mode 100644 index 0000000..5ec0e5e --- /dev/null +++ b/src/components/layout/__tests__/appShellConfig.test.ts @@ -0,0 +1,69 @@ +/** + * Property On — Position und Struktur des Navigationseintrags (§3.3, §3.4). + * + * Die Spezifikation ist hier wörtlich: «Teamübersicht» steht direkt unter + * «Meine Objekte» und oberhalb von «Reminder Manager». Eine spätere Umsortierung + * der Navigation würde das still brechen — dieser Test hält es fest. + */ + +import { describe, it, expect } from 'vitest' +import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig' +import { WorkspaceType } from '../../../domain/enums' +import { ROUTES } from '../../../lib/constants' + +const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems + +describe('Navigationseintrag «Teamübersicht»', () => { + it('steht direkt zwischen «Meine Objekte» und «Reminder Manager»', () => { + const labels = supplyNav.map(item => item.label) + const objekte = labels.indexOf('Meine Objekte') + const team = labels.indexOf('Teamübersicht') + const reminder = labels.indexOf('Reminder Manager') + + expect(objekte).toBeGreaterThanOrEqual(0) + expect(team).toBe(objekte + 1) + expect(reminder).toBe(team + 1) + }) + + it('führt auf den Basispfad des Funktionsbereichs', () => { + const team = supplyNav.find(item => item.label === 'Teamübersicht') + expect(team?.path).toBe(ROUTES.SUPPLY.TEAM) + }) + + it('trägt die drei Subreiter in verbindlicher Reihenfolge', () => { + const team = supplyNav.find(item => item.label === 'Teamübersicht') + expect(team?.children?.map(c => c.label)).toEqual([ + 'Personalverwaltung', + 'Bearbeitungsverlauf', + 'Kanäle & Systeme', + ]) + }) + + it('verweist mit jedem Subreiter auf einen Unterpfad der Teamübersicht', () => { + const team = supplyNav.find(item => item.label === 'Teamübersicht') + for (const child of team?.children ?? []) { + expect(child.path.startsWith(`${ROUTES.SUPPLY.TEAM}/`)).toBe(true) + } + }) + + it('ist der einzige Hauptreiter mit Subreitern — sonst wäre die Sidebar uneinheitlich', () => { + const withChildren = supplyNav.filter(item => (item.children?.length ?? 0) > 0) + expect(withChildren.map(item => item.label)).toEqual(['Teamübersicht']) + }) +}) + +describe('Seitentitel aus dem Pfad', () => { + it('löst Haupt- und Subreiter über die Navigation auf', () => { + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe('Teamübersicht') + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_PERSONNEL)).toBe('Personalverwaltung') + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY)).toBe('Bearbeitungsverlauf') + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_CONNECTIONS)).toBe('Kanäle & Systeme') + }) + + it('fängt Deep-Links ab, statt in den Segment-Fallback zu fallen', () => { + // Ohne eigene Regel ergäbe der Fallback «Erledigte auftraege» bzw. «Ferdi». + expect(getPageNameFromPath(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/ferdi/aufgaben`)).toBe('Personalblatt') + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY_DONE)).toBe('Bearbeitungsverlauf') + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY_PENDING)).toBe('Bearbeitungsverlauf') + }) +}) diff --git a/src/provider/__tests__/teamPersistence.test.ts b/src/provider/__tests__/teamPersistence.test.ts new file mode 100644 index 0000000..2bbcd42 --- /dev/null +++ b/src/provider/__tests__/teamPersistence.test.ts @@ -0,0 +1,116 @@ +/** + * Property On — lokale Persistenz und «Demo zurücksetzen» (§16.2, §20.1). + * + * Die Zusicherung lautet: geänderte Einstellungen, Aufgabenschalter, erledigte + * Vorgänge, Verbindungsstatus und pausierte Mitarbeiter überleben einen Reload — + * und «Demo zurücksetzen» stellt exakt den Auslieferungszustand wieder her. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { + TEAM_STORAGE_KEYS, + TEAM_SCHEMA_VERSION, + loadPersisted, + loadVersioned, + persist, + persistVersioned, + clearTeamPersistence, +} from '../teamPersistence' +import { MockupTeamAgentProvider } from '../MockupTeamAgentProvider' +import { MockupAgentWorkItemProvider } from '../MockupAgentWorkItemProvider' +import { teamDemoService } from '../../services/teamDemoService' +import { AgentStatus } from '../../domain/teamAgent' +import { mockTeamAgents } from '../../mock-data/teamAgents' + +beforeEach(async () => { + clearTeamPersistence() + await MockupTeamAgentProvider.reset() + await MockupAgentWorkItemProvider.reset() +}) + +describe('Speicherschicht', () => { + it('schreibt und liest einen Bestand', () => { + persist(TEAM_STORAGE_KEYS.AGENTS, { hello: 'welt' }) + expect(loadPersisted<{ hello: string }>(TEAM_STORAGE_KEYS.AGENTS)).toEqual({ hello: 'welt' }) + }) + + it('gibt null zurück, wenn nichts gespeichert ist', () => { + expect(loadPersisted(TEAM_STORAGE_KEYS.PROTOCOL)).toBeNull() + }) + + it('verschluckt unlesbaren Inhalt, statt die Anwendung zu blockieren', () => { + window.localStorage.setItem(TEAM_STORAGE_KEYS.AGENTS, '{ kein gültiges JSON') + expect(loadPersisted(TEAM_STORAGE_KEYS.AGENTS)).toBeNull() + }) + + it('verwirft Bestände einer fremden Schemaversion', () => { + window.localStorage.setItem( + TEAM_STORAGE_KEYS.AGENTS, + JSON.stringify({ version: TEAM_SCHEMA_VERSION + 99, data: [{ id: 'veraltet' }] }), + ) + expect(loadVersioned(TEAM_STORAGE_KEYS.AGENTS)).toBeNull() + }) + + it('liest einen Bestand der aktuellen Schemaversion', () => { + persistVersioned(TEAM_STORAGE_KEYS.CONNECTIONS, [{ id: 'verbindung-1' }]) + expect(loadVersioned>(TEAM_STORAGE_KEYS.CONNECTIONS)).toEqual([ + { id: 'verbindung-1' }, + ]) + }) + + it('löscht sämtliche Property-On-Schlüssel', () => { + for (const key of Object.values(TEAM_STORAGE_KEYS)) persist(key, { a: 1 }) + clearTeamPersistence() + for (const key of Object.values(TEAM_STORAGE_KEYS)) { + expect(loadPersisted(key)).toBeNull() + } + }) +}) + +describe('Persistenz der Mitarbeiter', () => { + it('schreibt einen pausierten Mitarbeiter in den Local Storage', async () => { + const target = mockTeamAgents[0] + await MockupTeamAgentProvider.update(target.id, { status: AgentStatus.PAUSED }) + + const stored = loadVersioned>(TEAM_STORAGE_KEYS.AGENTS) + expect(stored).not.toBeNull() + expect(stored?.find(a => a.id === target.id)?.status).toBe(AgentStatus.PAUSED) + }) + + it('lässt die übrigen Mitarbeiter unangetastet', async () => { + const target = mockTeamAgents[0] + await MockupTeamAgentProvider.update(target.id, { status: AgentStatus.PAUSED }) + + const all = await MockupTeamAgentProvider.getAll() + const others = all.filter(a => a.id !== target.id) + expect(others.every(a => a.status === AgentStatus.ACTIVE)).toBe(true) + }) +}) + +describe('Demo zurücksetzen', () => { + it('verwirft lokale Änderungen und stellt die Mockdaten wieder her', async () => { + const target = mockTeamAgents[0] + await MockupTeamAgentProvider.update(target.id, { status: AgentStatus.PAUSED }) + expect((await MockupTeamAgentProvider.getById(target.id))?.status).toBe(AgentStatus.PAUSED) + + await teamDemoService.reset() + + expect((await MockupTeamAgentProvider.getById(target.id))?.status).toBe(AgentStatus.ACTIVE) + for (const key of Object.values(TEAM_STORAGE_KEYS)) { + expect(loadPersisted(key)).toBeNull() + } + }) + + it('stellt auch die Vorgänge wieder her', async () => { + const { 0: first } = await MockupAgentWorkItemProvider.getAll({ requiresDecision: true }) + await MockupAgentWorkItemProvider.update(first.id, { requiresDecision: false }) + + const afterEdit = await MockupAgentWorkItemProvider.getAll({ requiresDecision: true }) + expect(afterEdit.map(i => i.id)).not.toContain(first.id) + + await teamDemoService.reset() + + const afterReset = await MockupAgentWorkItemProvider.getAll({ requiresDecision: true }) + expect(afterReset.map(i => i.id)).toContain(first.id) + }) +}) diff --git a/src/services/__tests__/agentWorkItemService.test.ts b/src/services/__tests__/agentWorkItemService.test.ts new file mode 100644 index 0000000..6e0cf91 --- /dev/null +++ b/src/services/__tests__/agentWorkItemService.test.ts @@ -0,0 +1,163 @@ +/** + * Property On — Freigabe-, Zurückweisungs- und Kennzahlenlogik (§20.1). + * + * Diese Tests sichern die drei Zusicherungen ab, an denen die Demo steht: + * ein freigegebener Vorgang verlässt die pendenten Anfragen, eine Zurückweisung + * zählt nicht als Leistung, und jede Entscheidung landet im Protokoll. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { agentWorkItemService } from '../agentWorkItemService' +import { agentProtocolService } from '../agentProtocolService' +import { MockupAgentWorkItemProvider } from '../../provider/MockupAgentWorkItemProvider' +import { MockupAgentProtocolProvider } from '../../provider/MockupAgentProtocolProvider' +import { clearTeamPersistence } from '../../provider/teamPersistence' +import { AgentWorkItemStatus, AgentWorkItemAction } from '../../domain/agentWorkItem' +import { AgentApprovalDecision } from '../../domain/agentProtocol' +import { AgentPeriod } from '../../domain/agentFilters' + +beforeEach(async () => { + clearTeamPersistence() + await MockupAgentWorkItemProvider.reset() + await MockupAgentProtocolProvider.reset() +}) + +/** Erster pendenter Vorgang, der die gesuchte Aktion anbietet. */ +async function findPending(action?: string) { + const { data } = await agentWorkItemService.getAll({ requiresDecision: true }) + const item = action ? data.find(i => i.availableActions.includes(action as never)) : data[0] + if (!item) throw new Error(`Kein pendenter Vorgang mit Aktion ${action ?? 'beliebig'} in den Mockdaten.`) + return item +} + +describe('Kennzahlen der Auswertung', () => { + it('zählt pendente Vorgänge getrennt nach Freigabeanfragen und Nachrichten', async () => { + const { data: kpis } = await agentWorkItemService.getKpis({ period: AgentPeriod.ALL }) + const { data: pending } = await agentWorkItemService.getAll({ requiresDecision: true }) + + expect(kpis.pendingApprovals + kpis.pendingMessages).toBe(pending.length) + expect(kpis.pendingApprovals).toBeGreaterThan(0) + }) + + it('zählt erledigte Vorgänge, aber keine pendenten', async () => { + const { data: kpis } = await agentWorkItemService.getKpis({ period: AgentPeriod.ALL }) + const { data: done } = await agentWorkItemService.getAll({ requiresDecision: false }) + + expect(kpis.completedTasks).toBe(done.length) + }) + + it('erhöht die erledigten Aufgaben nach einer Freigabe um genau eins', async () => { + const before = (await agentWorkItemService.getKpis({ period: AgentPeriod.ALL })).data + const item = await findPending(AgentWorkItemAction.APPROVE) + + await agentWorkItemService.approve(item.id) + + const after = (await agentWorkItemService.getKpis({ period: AgentPeriod.ALL })).data + expect(after.completedTasks).toBe(before.completedTasks + 1) + expect(after.pendingApprovals).toBe(before.pendingApprovals - 1) + }) + + it('zählt eine Zurückweisung NICHT als erledigte Aufgabe', async () => { + const before = (await agentWorkItemService.getKpis({ period: AgentPeriod.ALL })).data + const item = await findPending(AgentWorkItemAction.REJECT) + + await agentWorkItemService.reject(item.id, 'Passt fachlich nicht.') + + const after = (await agentWorkItemService.getKpis({ period: AgentPeriod.ALL })).data + expect(after.completedTasks).toBe(before.completedTasks) + expect(after.pendingApprovals + after.pendingMessages).toBe( + before.pendingApprovals + before.pendingMessages - 1, + ) + }) +}) + +describe('Freigabeprozess', () => { + it('setzt Status, Abschlusszeitpunkt und entfernt den Vorgang aus den pendenten Anfragen', async () => { + const item = await findPending(AgentWorkItemAction.APPROVE) + const { data: approved } = await agentWorkItemService.approve(item.id) + + expect(approved.status).toBe(AgentWorkItemStatus.APPROVED) + expect(approved.requiresDecision).toBe(false) + expect(approved.completedAt).toBeTruthy() + + const { data: stillPending } = await agentWorkItemService.getAll({ requiresDecision: true }) + expect(stillPending.map(i => i.id)).not.toContain(item.id) + + const { data: nowDone } = await agentWorkItemService.getAll({ requiresDecision: false }) + expect(nowDone.map(i => i.id)).toContain(item.id) + }) + + it('schreibt einen Protokolleintrag mit Freigabeentscheidung', async () => { + const item = await findPending(AgentWorkItemAction.APPROVE) + await agentWorkItemService.approve(item.id) + + const { data: protocol } = await agentProtocolService.getAll() + const entry = protocol.find(e => e.workItemId === item.id) + + expect(entry).toBeDefined() + expect(entry?.approvalDecision).toBe(AgentApprovalDecision.APPROVED) + expect(entry?.triggeredByName).toBeTruthy() + }) + + it('ergänzt die Aktionshistorie des Vorgangs', async () => { + const item = await findPending(AgentWorkItemAction.APPROVE) + const { data: approved } = await agentWorkItemService.approve(item.id) + + expect(approved.history.length).toBe(item.history.length + 1) + expect(approved.history.at(-1)?.action).toBe('Freigegeben') + }) +}) + +describe('Zurückweisungsprozess', () => { + it('hält die Begründung am Vorgang und im Protokoll fest', async () => { + const item = await findPending(AgentWorkItemAction.REJECT) + const reason = 'Objekt ist bereits anderweitig vergeben.' + + const { data: rejected } = await agentWorkItemService.reject(item.id, reason) + expect(rejected.status).toBe(AgentWorkItemStatus.REJECTED) + expect(rejected.rejectionReason).toBe(reason) + expect(rejected.requiresDecision).toBe(false) + + const { data: protocol } = await agentProtocolService.getAll() + const entry = protocol.find(e => e.workItemId === item.id) + expect(entry?.approvalDecision).toBe(AgentApprovalDecision.REJECTED) + expect(entry?.description).toContain(reason) + }) + + it('funktioniert auch ohne Begründung', async () => { + const item = await findPending(AgentWorkItemAction.REJECT) + const { data: rejected } = await agentWorkItemService.reject(item.id) + + expect(rejected.status).toBe(AgentWorkItemStatus.REJECTED) + expect(rejected.rejectionReason).toBeUndefined() + }) +}) + +describe('Anpassen', () => { + it('lässt den Vorgang pendent, bis final entschieden wurde', async () => { + const item = await findPending(AgentWorkItemAction.EDIT) + const fields = (item.editableFields ?? []).map(f => ({ ...f, value: `${f.value} (angepasst)` })) + + const { data: edited } = await agentWorkItemService.saveEdit(item.id, fields) + + expect(edited.status).toBe(AgentWorkItemStatus.EDITING) + expect(edited.requiresDecision).toBe(true) + expect(edited.completedAt).toBeUndefined() + + const { data: stillPending } = await agentWorkItemService.getAll({ requiresDecision: true }) + expect(stillPending.map(i => i.id)).toContain(item.id) + }) +}) + +describe('Rückfrage beantworten', () => { + it('hängt die Antwort ans Ergebnis und schliesst den Vorgang ab', async () => { + const item = await findPending(AgentWorkItemAction.ANSWER_QUERY) + const answer = 'Die Fläche wird per 1. Oktober frei.' + + const { data: answered } = await agentWorkItemService.answerQuery(item.id, answer) + + expect(answered.status).toBe(AgentWorkItemStatus.COMPLETED) + expect(answered.requiresDecision).toBe(false) + expect(answered.result.some(f => f.value === answer)).toBe(true) + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts index a9d0dd3..ba50e94 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,12 @@ import '@testing-library/jest-dom/vitest' +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' + +/** + * `vitest.config.ts` setzt `globals` nicht, deshalb erkennt Testing Library kein + * globales `afterEach` und registriert sein automatisches Cleanup nicht. Ohne + * diese Zeile bleiben gerenderte Komponenten über Testgrenzen hinweg im + * Dokument stehen, und `getByRole(...)` scheitert ab dem zweiten Test einer + * Datei an «found multiple elements». + */ +afterEach(cleanup) diff --git a/src/test/teamTestUtils.tsx b/src/test/teamTestUtils.tsx new file mode 100644 index 0000000..562de88 --- /dev/null +++ b/src/test/teamTestUtils.tsx @@ -0,0 +1,60 @@ +/** + * Render-Harness für Property-On-Komponenten. + * + * Vor Property On gab es im Repo kein Muster für Komponententests — + * `@testing-library/react` wurde ausschliesslich für `renderHook` verwendet. + * Diese Datei stellt den fehlenden Kontext bereit: Router (die Komponenten + * navigieren), MUI-Theme (sx-Werte greifen auf Theme-Tokens zu) und einen + * frischen QueryClient je Test, damit sich Tests nicht über den Cache + * gegenseitig beeinflussen. + */ + +import type { ReactElement, ReactNode } from 'react' +import { render } from '@testing-library/react' +import type { RenderOptions, RenderResult } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { ThemeProvider } from '@mui/material/styles' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { theme } from '../lib/theme' + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }) +} + +interface Options extends Omit { + route?: string + queryClient?: QueryClient +} + +export function renderWithProviders( + ui: ReactElement, + { route = '/', queryClient = createTestQueryClient(), ...options }: Options = {}, +): RenderResult & { queryClient: QueryClient } { + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) + } + + return { ...render(ui, { wrapper: Wrapper, ...options }), queryClient } +} + +/** Wrapper für `renderHook` — gleiche Provider, ohne Router-Overhead im Ergebnis. */ +export function hookWrapper(queryClient: QueryClient = createTestQueryClient()) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } +}