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:
Benjamin Sutter
2026-08-03 16:56:20 +02:00
parent 78e2dab655
commit a7e05fb7f0
6 changed files with 539 additions and 64 deletions
@@ -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)
})
})
+11
View File
@@ -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)
+60
View File
@@ -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>
)
}
}