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 { mitBewahrtemFokus } from '@/ui/fokus'; import type { ViewContext } from '@/ui/shell'; import { conflictsView } from '@/ui/views/conflictsView'; /** * Befund 35 zur Fassung 5.9.0, die zweite ungeschuetzte Stelle. * * WAS DER ANWENDER SONST ERLEBT * * Er hat eine Konfliktbeziehung vermassen - Raeumweg, Einfahrweg, "enger * Innenradius", eine von Hand abgestimmte Zwischenzeit - und drueckt im * Bearbeitungsfenster auf "Konflikt aufheben". Der Datensatz ist fort, ohne * Rueckfrage und ohne Meldung. Ein zweiter Klick in der Matrix legt eine * frische Beziehung an: Wege 0, alle Ueberschreibungen null, `engerRadius` und * `haltVorKnoten` weggelassen. Die fehlenden Wege meldet der Pruefbericht * spaeter als Fehler; den fehlenden engen Innenradius meldet niemand - der * Abbieger raeumt dann mit 7,0 statt 5,0 m/s, also mit kuerzerer Zwischenzeit, * und ein Merkmal ueber die Oertlichkeit laesst sich aus den Daten nicht * zurueckgewinnen. * * Die Behebung von Befund 35 hat allein die Matrixzelle der Ansicht * "Verträglichkeit" gesichert. Dieselbe Rueckfrage gehoert an diese * Schaltflaeche - mit demselben Massstab: gefragt wird nur, wenn wirklich * etwas daran haengt. * * NACHGEZOGEN (Befund 10 zur Fassung 5.10.0): Die Schaltflaeche * hebt das PAAR auf, nicht die angeklickte Richtung - eine einzelne Richtung * zu entfernen hinterliess ein halbes Paar, das der Pruefbericht als Fehler * meldet und das die Ausgabe sperrt. Dieser Test begruendet allein die * Rueckfrage; die Zahl der entfernten Richtungen war blosses Mitschreiben des * vorgefundenen Verhaltens. Sie steht jetzt auf `vorher - 2`, und der Fall * "nichts erfasst" leert beide Richtungen - sonst traefe seine Voraussetzung * nicht mehr zu, denn gefragt wird ueber beide. */ 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)); } function offeneFenster(): string[] { return [...document.querySelectorAll('.dialog-hintergrund h2')].map( (h) => h.textContent?.trim() ?? '', ); } /** Text der zuletzt geoeffneten Rueckfrage. */ function rueckfrageText(): string { const dialoge = [...document.querySelectorAll('.dialog-hintergrund .dialog-text')]; return dialoge[dialoge.length - 1]?.textContent ?? ''; } /** 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; } /** Oeffnet das Bearbeitungsfenster ueber die Matrixzelle. */ function oeffneFenster(ansicht: Ansicht, von: string, nach: string): void { const zelle = [...ansicht.wurzel.querySelectorAll('button')].find((b) => (b.getAttribute('aria-label') ?? '').startsWith(`${von} räumt, ${nach} fährt ein`), ); if (zelle === undefined) throw new Error(`Keine Matrixzelle ${von} → ${nach}`); zelle.click(); } /** Ein Regelknotenpunkt, dessen erste Beziehung vollstaendig erfasst ist. */ function projektMitAngaben(): { readonly projekt: Project; readonly von: string; readonly nach: string; } { const basis = createStandardIntersectionProject('Vermaßen', FESTES_DATUM); const erste = basis.conflicts[0]; if (erste === undefined) throw new Error('Der Regelknotenpunkt führt keine Konflikte.'); const name = (id: string): string => basis.signalGroups.find((g) => g.id === id)?.name ?? '(ohne)'; const angereichert: Conflict = { ...erste, clearingDistance: 22, enteringDistance: 8, engerRadius: true, manualIntergreen: 7, }; return { projekt: { ...basis, conflicts: basis.conflicts.map((c) => (c.id === erste.id ? angereichert : c)), }, von: name(erste.fromId), nach: name(erste.toId), }; } /** * Ein Regelknotenpunkt, dessen erstes PAAR noch nichts traegt. * * Beide Richtungen, seit die Schaltflaeche beide aufhebt und die Rueckfrage * ueber beide fragt (Befund 10): Mit einer noch vermassten Gegenrichtung waere * dies kein Fall von "nichts erfasst" mehr. */ function projektOhneAngaben(): { readonly projekt: Project; readonly von: string; readonly nach: string; } { const basis = createStandardIntersectionProject('Frisch', FESTES_DATUM); const erste = basis.conflicts[0]; if (erste === undefined) throw new Error('Der Regelknotenpunkt führt keine Konflikte.'); const name = (id: string): string => basis.signalGroups.find((g) => g.id === id)?.name ?? '(ohne)'; const leere = (c: Conflict): Conflict => ({ ...c, clearingDistance: 0, enteringDistance: 0, clearingSpeedOverride: null, enteringSpeedOverride: null, crossingTimeOverride: null, manualIntergreen: null, note: '', }); const gehoertZumPaar = (c: Conflict): boolean => (c.fromId === erste.fromId && c.toId === erste.toId) || (c.fromId === erste.toId && c.toId === erste.fromId); return { projekt: { ...basis, conflicts: basis.conflicts.map((c) => (gehoertZumPaar(c) ? leere(c) : c)), }, von: name(erste.fromId), nach: name(erste.toId), }; } function anzahlKonflikte(ansicht: Ansicht): number { return ansicht.store.getProject().conflicts.length; } describe('Zwischenzeiten: "Konflikt aufheben" fragt vor dem Verwerfen', () => { it('nennt die erfassten Angaben und verwirft erst nach der Bestätigung', async () => { const { projekt, von, nach } = projektMitAngaben(); const ansicht = zeichne(projekt); const vorher = anzahlKonflikte(ansicht); oeffneFenster(ansicht, von, nach); await warte(0); knopf('Konflikt aufheben').click(); await warte(10); expect(offeneFenster(), 'Es steht keine Rückfrage über dem Bearbeitungsfenster').toContain( 'Konflikt aufheben', ); expect(rueckfrageText()).toContain('Räumweg'); expect(rueckfrageText()).toContain('enger Innenradius'); expect(rueckfrageText()).toContain('von Hand abgestimmte Zwischenzeit'); expect(anzahlKonflikte(ansicht), 'Die Beziehung ist schon vor der Antwort fort').toBe(vorher); knopf('Aufheben').click(); await warte(10); // Beide Richtungen: Ein halbes Paar ist ein Fehler mit Exportsperre. expect(anzahlKonflikte(ansicht)).toBe(vorher - 2); expect(offeneFenster(), 'Nach dem Aufheben bleibt kein Fenster stehen').toEqual([]); }); it('behält die Beziehung, wenn die Rückfrage abgebrochen wird', async () => { const { projekt, von, nach } = projektMitAngaben(); const ansicht = zeichne(projekt); const vorher = anzahlKonflikte(ansicht); oeffneFenster(ansicht, von, nach); await warte(0); knopf('Konflikt aufheben').click(); await warte(10); knopf('Abbrechen').click(); await warte(10); expect(anzahlKonflikte(ansicht)).toBe(vorher); }); it('fragt nicht, wenn an der Beziehung nichts erfasst ist', async () => { // Derselbe Massstab wie an der Matrixzelle: Wer an frischen Beziehungen // arbeitet, soll nicht bei jedem Klick aufgehalten werden. const { projekt, von, nach } = projektOhneAngaben(); const ansicht = zeichne(projekt); const vorher = anzahlKonflikte(ansicht); oeffneFenster(ansicht, von, nach); await warte(0); knopf('Konflikt aufheben').click(); await warte(10); expect(anzahlKonflikte(ansicht)).toBe(vorher - 2); expect(offeneFenster()).toEqual([]); }); });