import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Conflict, Project } from '@/domain/model/project'; import { compatibilityView } from '@/ui/views/compatibilityView'; /** * Befund 35 zur Fassung 5.9.0 (Datenverlust). * * WAS DER ANWENDER SONST ERLEBT * * Er hat das Paar K1/K3 vermasst - Raeumweg 25 m - und "enger Innenradius" * angehakt. In der Ansicht "Verträglichkeit" klickt er die Zelle einmal * (Zustand "verträglich") und, weil das ein Versehen war, gleich noch einmal. * `toggleConflictPair` entfernt beim ersten Klick BEIDE gerichteten * Datensaetze, der zweite legt ueber `createConflict` zwei frische an: * Raeumweg 0, Einfahrweg 0, alle Ueberschreibungen null, die von Hand * abgestimmte Zwischenzeit fort, Bemerkung leer, `engerRadius` und * `haltVorKnoten` weggelassen. Nichts hat es angesagt. * * Die Wege faellt der Pruefbericht spaeter auf (`konflikte.raeumweg-fehlt`, * Fehler mit Exportsperre). Den Verlust von `engerRadius` faengt niemand auf, * und er geht in die gefaehrliche Richtung: Ohne das Merkmal raeumt der * Abbieger mit 7,0 statt 5,0 m/s - bei s0 = 25 m rund 1,8 s kuerzere * Zwischenzeit, nachdem der Bearbeiter den angemahnten Raeumweg wieder * eingetragen hat. Ein Merkmal ueber die Oertlichkeit laesst sich aus den * Daten nicht rekonstruieren. * * Die Nachbarschaltflaeche "Alle aufheben" fragt fuer denselben Vorgang ueber * alle Paare und traegt `variant: 'gefahr'`; beim Loeschen einer Signalgruppe * nennt das Programm sogar die Zahl der betroffenen Konfliktbeziehungen. Nur * die Zelle verwarf ohne Rueckfrage. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); afterEach(() => { document.body.replaceChildren(); }); interface Ansicht { readonly wurzel: HTMLElement; readonly store: ProjectStore; readonly aufraeumen: () => void; } function zeichne(project: Project): Ansicht { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const abmelden = compatibilityView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { abmelden(); wurzel.remove(); }, }; } async function mikroschritt(): Promise { await new Promise((fertig) => { queueMicrotask(fertig); }); } /** Der Umschaltknopf der Zelle in Zeile `i`, Spalte `j` der oberen Dreiecksmatrix. */ function zelle(wurzel: HTMLElement, i: number, j: number): HTMLButtonElement { const zeile = wurzel.querySelectorAll('table.matrix tbody tr')[i]; // Erste Zelle der Zeile ist der Zeilenkopf, danach je Signalgruppe eine. const knopf = zeile?.children[1 + j]?.querySelector('button') ?? null; if (knopf === null) throw new Error(`Kein Umschaltknopf in Zeile ${i}, Spalte ${j}`); return knopf; } function rueckfrage(): HTMLElement | null { return document.querySelector('.dialog-hintergrund .dialog'); } function dialogKnopf(beschriftung: string): HTMLButtonElement { const knopf = [ ...document.querySelectorAll('.dialog-hintergrund .dialog-aktionen button'), ].find((b) => (b.textContent ?? '').trim() === beschriftung); if (knopf === undefined) throw new Error(`Keine Schaltflaeche "${beschriftung}"`); return knopf; } /** Beide gerichteten Beziehungen des Paares. */ function beziehungen(project: Project, aId: string, bId: string): Conflict[] { return project.conflicts.filter( (c) => (c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId), ); } /** Regelknotenpunkt, an dessen Paar K1/K3 der enge Innenradius haengt. */ function mitMerkmal(): { projekt: Project; k1: string; k3: string } { const basis = createStandardIntersectionProject('Verträglichkeit', FESTES_DATUM); const k1 = basis.signalGroups.find((g) => g.name === 'K1')!.id; const k3 = basis.signalGroups.find((g) => g.name === 'K3')!.id; return { k1, k3, projekt: { ...basis, conflicts: basis.conflicts.map((c) => c.fromId === k1 && c.toId === k3 ? { ...c, clearingDistance: 25, engerRadius: true, manualIntergreen: 7 } : c, ), }, }; } /** Dieselbe Beziehung ohne jede erfasste Angabe - so, wie `createConflict` sie anlegt. */ function ohneAngaben(): { projekt: Project; k1: string; k3: string } { const { projekt, k1, k3 } = mitMerkmal(); const geleert = (c: Conflict): Conflict => { const { engerRadius: _radius, haltVorKnoten: _halt, ...rest } = c; return { ...rest, clearingDistance: 0, enteringDistance: 0, manualIntergreen: null, note: '' }; }; return { k1, k3, projekt: { ...projekt, conflicts: projekt.conflicts.map((c) => (c.fromId === k1 && c.toId === k3) || (c.fromId === k3 && c.toId === k1) ? geleert(c) : c, ), }, }; } describe('Verträglichkeitsmatrix: das Aufheben eines vermassten Paares fragt zurueck', () => { it('nennt vor dem Verwerfen, was an dem Paar haengt, und laesst es bis dahin stehen', () => { const { projekt, k1, k3 } = mitMerkmal(); const ansicht = zeichne(projekt); try { // Zeile 0 ist K1, Spalte 2 ist K3. zelle(ansicht.wurzel, 0, 2).click(); const dialog = rueckfrage(); expect(dialog, 'Die Zelle verwirft ohne Rueckfrage').not.toBeNull(); const text = dialog?.textContent ?? ''; expect(text, 'die Rueckfrage nennt das Paar nicht').toContain('K1'); expect(text).toContain('K3'); expect(text, 'die Rueckfrage nennt das gefaehrlichste Merkmal nicht').toContain( 'enger Innenradius', ); expect(text).toContain('Räumweg'); expect(text).toContain('Zwischenzeit'); // Solange die Rueckfrage steht, ist nichts verworfen. expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(2); } finally { ansicht.aufraeumen(); } }); it('laesst die Angaben unberuehrt, wenn die Rueckfrage abgebrochen wird', async () => { const { projekt, k1, k3 } = mitMerkmal(); const ansicht = zeichne(projekt); try { zelle(ansicht.wurzel, 0, 2).click(); dialogKnopf('Abbrechen').click(); await mikroschritt(); await mikroschritt(); const paar = beziehungen(ansicht.store.getProject(), k1, k3); expect(paar).toHaveLength(2); expect( paar.some((c) => c.engerRadius === true), 'der enge Innenradius ist fort', ).toBe(true); } finally { ansicht.aufraeumen(); } }); it('hebt das Paar auf, wenn der Bearbeiter zustimmt', async () => { const { projekt, k1, k3 } = mitMerkmal(); const ansicht = zeichne(projekt); try { zelle(ansicht.wurzel, 0, 2).click(); dialogKnopf('Aufheben').click(); await mikroschritt(); await mikroschritt(); expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(0); } finally { ansicht.aufraeumen(); } }); it('fragt nicht, wenn an dem Paar nichts erfasst ist', () => { const { projekt, k1, k3 } = ohneAngaben(); const ansicht = zeichne(projekt); try { zelle(ansicht.wurzel, 0, 2).click(); expect(rueckfrage(), 'eine Rueckfrage ohne Anlass').toBeNull(); expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(0); } finally { ansicht.aufraeumen(); } }); it('fragt nicht beim Erfassen eines Konflikts', () => { const { projekt, k1 } = mitMerkmal(); const k2 = projekt.signalGroups.find((g) => g.name === 'K2')!.id; const ansicht = zeichne(projekt); try { // Zeile 0 ist K1, Spalte 1 ist K2 - dieses Paar ist verträglich. zelle(ansicht.wurzel, 0, 1).click(); expect(rueckfrage()).toBeNull(); expect(beziehungen(ansicht.store.getProject(), k1, k2)).toHaveLength(2); } finally { ansicht.aufraeumen(); } }); });