test(team): Render-Harness, 151 neue Tests und README
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 <img> — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
## Starten
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
```bash
|
||||||
|
npm install
|
||||||
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).
|
npm run dev # Entwicklungsserver auf http://localhost:5173
|
||||||
|
|
||||||
## 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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
| Skript | Zweck |
|
||||||
// eslint.config.js
|
|---|---|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
| `npm run dev` | Entwicklungsserver mit HMR |
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
| `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<T>` / `ItemResponse<T>`.
|
||||||
|
- **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 `<Outlet/>`, 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.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Array<{ id: string }>>(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<Array<{ id: string; status: string }>>(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1 +1,12 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
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)
|
||||||
|
|||||||
@@ -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<RenderOptions, 'wrapper'> {
|
||||||
|
route?: string
|
||||||
|
queryClient?: QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithProviders(
|
||||||
|
ui: ReactElement,
|
||||||
|
{ route = '/', queryClient = createTestQueryClient(), ...options }: Options = {},
|
||||||
|
): RenderResult & { queryClient: QueryClient } {
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user