import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import * as actions from '@/app/actions'; import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; import type { Conflict, Project } from '@/domain/model/project'; import { validateProject } from '@/domain/validation'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { mitBewahrtemFokus } from '@/ui/fokus'; import type { ViewContext } from '@/ui/shell'; import { conflictsView } from '@/ui/views/conflictsView'; /** * Fassung 5.10.0 - Zwischenzeitenmatrix. * * Drei Befunde an derselben Ansicht: * * - Befund 3 (kritisch): Der Klick auf eine LEERE Zelle rief * `toggleConflictPair` - einen Umschalter ueber das ungerichtete Paar. Stand * das Paar einseitig, verwarf die Zelle die noch bestehende Gegenrichtung * samt Raeumweg, engem Innenradius und Bemerkung, ohne Rueckfrage und unter * der Rueckgaengig-Beschriftung "Konfliktbeziehung angelegt". Danach gilt * das Paar als vollstaendig vertraeglich: `feindlichePartnerInPhase` findet * nichts mehr, beide Signalgruppen duerfen in dieselbe Phase - die * gefaehrliche Richtung. * * - Befund 10 (hoch): "Konflikt aufheben" entfernte ueber `removeConflict` * genau eine Richtung. Zurueck blieb ein halbes Paar: Der Pruefbericht * meldete `konflikte.gegenrichtung-fehlt` als Fehler und sperrte die * Ausgabe, und dieselbe Beziehung hiess in der Zwischenzeitenmatrix * "vertraeglich" und in der Vertraeglichkeitsansicht "feindlich". * * - Befund 55 (gering): Die Abschnittsueberschrift setzte die Zahl unbesehen * vor die Mehrzahl - bei genau einer Beziehung stand dort "1 Beziehungen zu * vermaßen". Die Ueberschrift ist zugleich der Name der Abschnittskarte und * wird so vorgelesen. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); interface Ansicht { readonly wurzel: HTMLElement; readonly store: ProjectStore; } function zeichne(project: Project): Ansicht { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); let abmelden: () => void = () => {}; const context: ViewContext = { store, refresh: () => { mitBewahrtemFokus(wurzel, () => { abmelden(); wurzel.replaceChildren(); abmelden = conflictsView.render(wurzel, context); }); }, navigate: () => {}, target: null, }; abmelden = conflictsView.render(wurzel, context); aufraeumen = () => { abmelden(); wurzel.remove(); }; return { wurzel, store }; } async function warte(ms = 0): Promise { await new Promise((fertig) => setTimeout(fertig, ms)); } /** Zelle der gerichteten Matrix - ueber ihren Namen, nicht ueber die Lage. */ function zelle(ansicht: Ansicht, von: string, nach: string): HTMLButtonElement { const treffer = [...ansicht.wurzel.querySelectorAll('button')].find((b) => { const name = b.getAttribute('aria-label') ?? ''; return ( name.startsWith(`${von} räumt, ${nach} fährt ein`) || name.startsWith(`${von} und ${nach}:`) ); }); if (treffer === undefined) throw new Error(`Keine Matrixzelle ${von} → ${nach}`); return treffer; } /** Schaltflaeche mit dieser Beschriftung - im zuletzt geoeffneten Fenster. */ function knopf(beschriftung: string): HTMLButtonElement { const fenster = [...document.querySelectorAll('.dialog-hintergrund')]; const oben = fenster[fenster.length - 1]; const treffer = [...(oben?.querySelectorAll('button') ?? [])].find( (b) => b.textContent?.trim() === beschriftung, ); if (treffer === undefined) throw new Error(`Keine Schaltfläche "${beschriftung}"`); return treffer; } function rueckfrageText(): string { const dialoge = [...document.querySelectorAll('.dialog-hintergrund .dialog-text')]; return dialoge[dialoge.length - 1]?.textContent ?? ''; } function richtungen(store: ProjectStore, aId: string, bId: string): readonly Conflict[] { return store .getProject() .conflicts.filter( (c) => (c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId), ); } function gruppe(project: Project, name: string): string { const id = project.signalGroups.find((g) => g.name === name)?.id; if (id === undefined) throw new Error(`Keine Signalgruppe "${name}"`); return id; } /** * Regelknotenpunkt, an dem nur noch K3 → K1 steht - der Zustand, den * `engine.ts` als 'konflikte.gegenrichtung-fehlt' meldet und dessen Behebung * der Pruefbericht ausdruecklich verlangt ("Erfassen Sie auch die Beziehung"). * Die verbliebene Richtung traegt Angaben, die aus den Daten nicht * zurueckzugewinnen sind. */ function einseitigesPaar(): { readonly projekt: Project; readonly k1: string; readonly k3: string; } { const basis = createStandardIntersectionProject('Einseitig', FESTES_DATUM); const k1 = gruppe(basis, 'K1'); const k3 = gruppe(basis, 'K3'); const conflicts = basis.conflicts .filter((c) => !(c.fromId === k1 && c.toId === k3)) .map((c) => c.fromId === k3 && c.toId === k1 ? { ...c, clearingDistance: 42, engerRadius: true, note: 'am Lageplan geprüft' } : c, ); return { projekt: { ...basis, conflicts }, k1, k3 }; } describe('Befund 3 - Klick auf eine leere Matrixzelle legt an, statt zu loeschen', () => { it('legt die fehlende Richtung an und laesst die Gegenrichtung unberuehrt', () => { const { projekt, k1, k3 } = einseitigesPaar(); const ansicht = zeichne(projekt); // Die Zelle sagt "Zum Erfassen eines Konflikts anklicken" - genau das muss // sie auch tun. expect(zelle(ansicht, 'K1', 'K3').getAttribute('aria-label')).toContain( 'Zum Erfassen eines Konflikts anklicken', ); zelle(ansicht, 'K1', 'K3').click(); const danach = richtungen(ansicht.store, k1, k3); expect( danach.map((c) => `${c.fromId === k1 ? 'K1' : 'K3'}→${c.toId === k1 ? 'K1' : 'K3'}`).sort(), 'Der Klick hat die Gegenrichtung verworfen, statt die fehlende anzulegen', ).toEqual(['K1→K3', 'K3→K1']); const gegen = danach.find((c) => c.fromId === k3 && c.toId === k1); expect(gegen?.clearingDistance, 'Der Räumweg der Gegenrichtung ist fort').toBe(42); expect(gegen?.engerRadius, 'Der enge Innenradius der Gegenrichtung ist fort').toBe(true); expect(gegen?.note).toBe('am Lageplan geprüft'); }); it('haelt die beiden Signalgruppen danach aus derselben Phase heraus', () => { const { projekt, k3 } = einseitigesPaar(); const ansicht = zeichne(projekt); zelle(ansicht, 'K1', 'K3').click(); const nachher = ansicht.store.getProject(); const phase1 = nachher.phases[0]; expect(phase1).toBeDefined(); expect( actions.feindlichePartnerInPhase(nachher, phase1!.id, k3).map((g) => g.name), 'K1 und K3 gelten nach dem Klick als verträglich', ).toContain('K1'); }); it('legt bei einem noch gar nicht erfassten Paar weiterhin beide Richtungen an', () => { const basis = createStandardIntersectionProject('Frisch', FESTES_DATUM); const k1 = gruppe(basis, 'K1'); const k2 = gruppe(basis, 'K2'); // K1 und K2 sind in der Vorlage vertraeglich - beide Richtungen fehlen. const ansicht = zeichne(basis); expect(richtungen(ansicht.store, k1, k2)).toHaveLength(0); zelle(ansicht, 'K1', 'K2').click(); expect( richtungen(ansicht.store, k1, k2), 'Ein Klick muss das Paar vollständig anlegen, sonst sperrt die fehlende Gegenrichtung die Ausgabe', ).toHaveLength(2); }); }); describe('Befund 10 - "Konflikt aufheben" hebt das Paar auf', () => { it('entfernt beide Richtungen und laesst kein halbes Paar zurueck', async () => { const basis = createStandardIntersectionProject('Aufheben', FESTES_DATUM); const k1 = gruppe(basis, 'K1'); const k3 = gruppe(basis, 'K3'); const ansicht = zeichne(basis); const vorher = ansicht.store.getProject().conflicts.length; zelle(ansicht, 'K1', 'K3').click(); await warte(0); knopf('Konflikt aufheben').click(); await warte(10); knopf('Aufheben').click(); await warte(10); expect(richtungen(ansicht.store, k1, k3), 'Es bleibt ein halbes Paar stehen').toHaveLength(0); expect(ansicht.store.getProject().conflicts).toHaveLength(vorher - 2); const projekt = ansicht.store.getProject(); const bericht = validateProject(projekt, buildSignalPlan(projekt), FESTES_DATUM); expect( bericht.findings.filter((f) => f.rule === 'konflikte.gegenrichtung-fehlt'), 'Das Aufheben erzeugt selbst den Fehler, den der Prüfbericht meldet', ).toHaveLength(0); expect(bericht.exportBlocked, 'Das Aufheben sperrt die Ausgabe').toBe(false); }); it('nennt in der Rueckfrage beide Richtungen und die Angaben der Gegenrichtung', async () => { const basis = createStandardIntersectionProject('Rueckfrage', FESTES_DATUM); const k1 = gruppe(basis, 'K1'); const k3 = gruppe(basis, 'K3'); // Die angeklickte Richtung traegt nichts, die Gegenrichtung alles: Genau // dann verschwiegen Rueckfrage und Beschriftung, was verworfen wird. const conflicts = basis.conflicts.map((c) => { if (c.fromId === k1 && c.toId === k3) { return { ...c, clearingDistance: 0, enteringDistance: 0 }; } if (c.fromId === k3 && c.toId === k1) return { ...c, engerRadius: true }; return c; }); const ansicht = zeichne({ ...basis, conflicts }); zelle(ansicht, 'K1', 'K3').click(); await warte(0); knopf('Konflikt aufheben').click(); await warte(10); const text = rueckfrageText(); expect(text, 'Die Rückfrage bleibt aus, obwohl die Gegenrichtung Angaben trägt').not.toBe(''); expect(text).toContain('beiden Richtungen'); expect(text, 'Der enge Innenradius der Gegenrichtung wird verschwiegen').toContain( 'enger Innenradius', ); knopf('Aufheben').click(); await warte(10); expect(richtungen(ansicht.store, k1, k3)).toHaveLength(0); }); }); describe('Befund 55 - Ueberschrift der Matrix in Ein- und Mehrzahl', () => { function projektMitBeziehungen(anzahl: 1 | 2): Project { let projekt = createEmptyProject('Zählung', FESTES_DATUM); projekt = actions.addSignalGroup(actions.addSignalGroup(projekt, 'kfz'), 'kfz'); const [a, b] = projekt.signalGroups; projekt = actions.toggleConflictPair(projekt, a!.id, b!.id); return anzahl === 2 ? projekt : { ...projekt, conflicts: projekt.conflicts.filter((c) => c.fromId === a!.id) }; } function ueberschrift(ansicht: Ansicht): string { const titel = [...ansicht.wurzel.querySelectorAll('.abschnitt-titel')].map( (t) => t.textContent ?? '', ); const treffer = titel.find((t) => t.includes('vermaßen')); if (treffer === undefined) throw new Error(`Keine Überschrift zur Matrix: ${titel.join(' | ')}`); return treffer; } it('nennt eine einzelne Beziehung in der Einzahl', () => { const ansicht = zeichne(projektMitBeziehungen(1)); expect(ueberschrift(ansicht)).toBe('1 Beziehung zu vermaßen'); }); it('bleibt bei mehreren Beziehungen in der Mehrzahl', () => { const ansicht = zeichne(projektMitBeziehungen(2)); expect(ueberschrift(ansicht)).toBe('2 Beziehungen zu vermaßen'); }); });