import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { leiteAb, uebernimmSignalgruppen, uebernimmWege, wegSchluessel, } from '@/domain/geometrie/ableitung'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import { createConflict, createEmptyProject, createStandardIntersectionProject, } from '@/domain/model/factory'; import { loadProject } from '@/domain/model/migrate'; import { parseProject } from '@/domain/model/schema'; import { CURRENT_SCHEMA_VERSION, type Conflict, type Project } from '@/domain/model/project'; /** * Herkunftsnachweis der Raeum- und Einfahrwege. * * DER FEHLER, GEGEN DEN DIESE TESTS STEHEN: Zuvor lag der Herkunftsvermerk im * freien Bemerkungsfeld. `uebernimmWege` schrieb dort "Aus dem Lageplan * vermessen", und der Satz blieb wortgleich stehen, wenn der Raeumweg danach * von Hand verlaengert wurde. Der Ausdruck behauptete damit in einer Unterlage * fuer ein verkehrsbehoerdliches Verfahren eine Vermessung, die es fuer diese * Zahl nie gegeben hat. * * Der Nachweis ist nur so viel wert wie seine unguenstigste Stelle: Wird eine * einzige Schreibstelle vergessen, behauptet der Ausdruck wieder etwas * Falsches - und das ist schlimmer als gar kein Nachweis, weil man sich auf ihn * verlaesst. Deshalb prueft der Abschnitt "Zuruecksetzen" jedes Feld einzeln. */ const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); const UEBERNAHME = new Date('2026-08-14T09:30:00Z'); /** Zwei sich kreuzende Fahrlinien; 1 Bildpunkt = 0,5 m. */ function lageplanMitKreuzung(): Lageplan { const linie = ( id: string, name: string, punkte: readonly { x: number; y: number }[], ): Planlinie => ({ id, name, mode: 'kfz', movement: 'geradeaus', breiteMeter: REGELBREITE.kfz, punkte, haltlinieId: null, startT: 0.5, signalGroupId: null, }); return { arbeitsbereiche: [], signalgeber: [], haltlinien: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: 400, hoehe: 400, herkunft: 'Test', geladenAm: FIXED_DATE.toISOString(), }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 50, herkunft: 'gemessen', }, linien: [ linie('l-w', 'K1', [ { x: 0, y: 100 }, { x: 200, y: 100 }, ]), linie('l-s', 'K2', [ { x: 100, y: 0 }, { x: 100, y: 200 }, ]), ], }; } /** * Projekt mit genau einer Beziehung, deren Wege aus dem Lageplan stammen. * Ausgangslage fuer alle Tests des Zuruecksetzens. */ function ausDemLageplan(): { project: Project; conflict: Conflict } { const start = createEmptyProject('Nachweis', FIXED_DATE); const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(start, lageplanMitKreuzung(), [ 'l-w', 'l-s', ]); const ableitung = leiteAb(mitGruppen, lageplan); const einer = ableitung.wege[0]; expect(einer).toBeDefined(); const project = uebernimmWege( mitGruppen, lageplan, ableitung, [wegSchluessel(einer!)], UEBERNAHME, ).project; const conflict = project.conflicts[0]; expect(conflict).toBeDefined(); return { project, conflict: conflict! }; } describe('Modell und Vorlage', () => { it('setzt den Wegnachweis ab Schemaversion 8 voraus', () => { /* * Bewusst "mindestens 8" und nicht "genau 8". Der Wegnachweis ist mit * Schema 8 dazugekommen - das ist die Zusage, die hier festzuhalten ist. * Die feste Zahl fiel dagegen beim naechsten Schemaaufstieg um, obwohl sich * am Wegnachweis nichts geaendert hatte: Ein Test ueber die Herkunft der * Wege soll nicht bei jeder fremden Modellaenderung rot werden. Die * jeweils aktuelle Zahl prueft tests/domain/anlagenartDatei.test.ts an der * Stelle, an die sie gehoert. */ expect(CURRENT_SCHEMA_VERSION).toBeGreaterThanOrEqual(8); }); it('legt eine neue Beziehung ohne Herkunftsangabe an', () => { // Beide Wege stehen auf 0. Diese 0 hat niemand vermessen und niemand // eingetragen - "von Hand" waere hier bereits eine falsche Angabe. const conflict = createConflict('a', 'b'); expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); expect(conflict.wegnachweis.uebernommenAm).toBeNull(); }); it('weist die Masse des Beispielknotenpunkts nicht als eingetragen aus', () => { const project = createStandardIntersectionProject('Vorlage', FIXED_DATE); expect(project.conflicts.length).toBeGreaterThan(0); for (const conflict of project.conflicts) { expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); } }); }); describe('Einlesen', () => { it('liest einen Nachweis aus dem Lageplan samt Zeitpunkt wieder ein', () => { const { project } = ausDemLageplan(); const gelesen = parseProject(project, FIXED_DATE); expect(gelesen.project.conflicts[0]!.wegnachweis).toEqual({ herkunft: 'lageplan', uebernommenAm: UEBERNAHME.toISOString(), }); }); it('gibt einer Datei ohne Angabe "unbestimmt" und nicht "von Hand"', () => { // Eine Datei aus Schema 7: Ueber die Herkunft steht darin nichts. Sie als // Eingabe des Bearbeiters auszuweisen hiesse zu behaupten, jemand habe die // Zahl geprueft. const { project, issues } = parseProject({ schemaVersion: 7, signalGroups: [ { id: 'a', name: 'K1', mode: 'kfz' }, { id: 'b', name: 'K2', mode: 'kfz' }, ], conflicts: [{ fromId: 'a', toId: 'b', clearingDistance: 24, enteringDistance: 9 }], }); expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('unbestimmt'); expect(project.conflicts[0]!.clearingDistance).toBe(24); // Ein fehlendes Feld ist kein Mangel der Datei - es gab es damals nicht. expect(issues.filter((i) => i.path.includes('wegnachweis'))).toHaveLength(0); }); it('verwirft eine unbekannte Herkunftsstufe und meldet sie', () => { const { project, issues } = parseProject({ signalGroups: [ { id: 'a', name: 'K1', mode: 'kfz' }, { id: 'b', name: 'K2', mode: 'kfz' }, ], conflicts: [{ fromId: 'a', toId: 'b', wegnachweis: { herkunft: 'amtlich-vermessen' } }], }); expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('unbestimmt'); expect(issues.some((i) => i.path.includes('wegnachweis'))).toBe(true); }); it('uebernimmt kein erfundenes Vermassungsdatum', () => { const { project, issues } = parseProject({ signalGroups: [ { id: 'a', name: 'K1', mode: 'kfz' }, { id: 'b', name: 'K2', mode: 'kfz' }, ], conflicts: [ { fromId: 'a', toId: 'b', wegnachweis: { herkunft: 'lageplan', uebernommenAm: 'neulich' } }, ], }); expect(project.conflicts[0]!.wegnachweis.herkunft).toBe('lageplan'); expect(project.conflicts[0]!.wegnachweis.uebernommenAm).toBeNull(); expect(issues.some((i) => i.path.includes('uebernommenAm'))).toBe(true); }); it('haengt an "von Hand" keinen Zeitstempel an', () => { const { project } = parseProject({ signalGroups: [ { id: 'a', name: 'K1', mode: 'kfz' }, { id: 'b', name: 'K2', mode: 'kfz' }, ], conflicts: [ { fromId: 'a', toId: 'b', wegnachweis: { herkunft: 'handeingabe', uebernommenAm: '2026-05-05T00:00:00.000Z' }, }, ], }); expect(project.conflicts[0]!.wegnachweis.uebernommenAm).toBeNull(); }); }); describe('Uebernahme von Altprojekten', () => { const legacyProject = { metadata: { version: '4.3.3' }, projektdaten: { projektBezeichnung: 'Alt' }, signalgruppen: [ { id: 'SG-1', nummer: 'K1', typ: 'kfz' }, { id: 'SG-2', nummer: 'F1', typ: 'fussgaenger' }, ], sperrmatrix: { matrix: [ [false, true], [true, false], ], }, zwischenzeiten: { parameter: { raeumweg: 24, einfahrweg: 9 } }, }; it('weist die Wege eines Altprojekts als ungeklaert aus', () => { // Das Altprogramm kannte nur einen global hinterlegten Weg fuer alle // Beziehungen. Woher die Zahl stammt, sagt die Datei nicht. const result = loadProject(legacyProject, FIXED_DATE); expect(result.project.conflicts.length).toBeGreaterThan(0); for (const conflict of result.project.conflicts) { expect(conflict.wegnachweis.herkunft).toBe('unbestimmt'); expect(conflict.clearingDistance).toBe(24); } }); it('sagt beim Oeffnen einer Datei aus Schema 7, dass der Nachweis fehlt', () => { const result = loadProject( { schemaVersion: 7, signalGroups: [ { id: 'a', name: 'K1', mode: 'kfz' }, { id: 'b', name: 'K2', mode: 'kfz' }, ], conflicts: [{ fromId: 'a', toId: 'b', clearingDistance: 24, enteringDistance: 9 }], }, FIXED_DATE, ); const hinweis = result.issues.find((i) => i.path === 'conflicts'); expect(hinweis).toBeDefined(); expect(hinweis!.severity).toBe('hinweis'); expect(hinweis!.message).toContain('Herkunft'); }); it('schweigt bei einer Datei, die den Nachweis bereits fuehrt', () => { const { project } = ausDemLageplan(); const result = loadProject(project, FIXED_DATE); expect(result.issues.some((i) => i.path === 'conflicts')).toBe(false); }); }); describe('Uebernahme aus dem Lageplan', () => { it('weist die uebernommenen Wege mit dem Zeitpunkt der Uebernahme aus', () => { const { conflict } = ausDemLageplan(); expect(conflict.wegnachweis.herkunft).toBe('lageplan'); expect(conflict.wegnachweis.uebernommenAm).toBe(UEBERNAHME.toISOString()); }); it('behauptet keine Vermessung, wo eine Zwischenzeit von Hand vorgegeben ist', () => { // Die Wege stammen dann zwar aus dem Plan, die Zeile im Ausdruck aber // nicht: Die vorgegebene Zwischenzeit ersetzt die Rechnung vollstaendig. const start = createEmptyProject('Nachweis', FIXED_DATE); const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(start, lageplanMitKreuzung(), [ 'l-w', 'l-s', ]); const ableitung = leiteAb(mitGruppen, lageplan); const einer = ableitung.wege[0]!; const von = lageplan.linien.find((l) => l.id === einer.vonLinieId)!.signalGroupId!; const nach = lageplan.linien.find((l) => l.id === einer.nachLinieId)!.signalGroupId!; const mitVorgabe = actions.setConflict(mitGruppen, von, nach, { manualIntergreen: 9 }); const ergebnis = uebernimmWege( mitVorgabe, lageplan, ableitung, [wegSchluessel(einer)], UEBERNAHME, ).project; const conflict = ergebnis.conflicts.find((c) => c.fromId === von && c.toId === nach)!; expect(conflict.clearingDistance).toBeCloseTo(einer.raeumweg, 2); // Die bewusst gesetzte Vorgabe bleibt unangetastet ... expect(conflict.manualIntergreen).toBe(9); // ... der Nachweis wird dafuer nicht vergeben. expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); }); }); describe('Zuruecksetzen bei jeder Aenderung an den Wegen', () => { /** Aendert ein Feld der uebernommenen Beziehung und gibt sie zurueck. */ const nachAenderung = (patch: Parameters[3]): Conflict => { const { project, conflict } = ausDemLageplan(); const geaendert = actions.setConflict(project, conflict.fromId, conflict.toId, patch); const treffer = geaendert.conflicts.find((c) => c.id === conflict.id); expect(treffer).toBeDefined(); return treffer!; }; it('faellt zurueck, wenn der Raeumweg von Hand geaendert wird', () => { // DER FEHLER, UM DEN ES GEHT: uebernehmen, dann den Raeumweg verlaengern. // Zuvor blieb "Aus dem Lageplan vermessen" wortgleich stehen. const conflict = nachAenderung({ clearingDistance: 41 }); expect(conflict.clearingDistance).toBe(41); expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); expect(conflict.wegnachweis.uebernommenAm).toBeNull(); }); it('faellt zurueck, wenn der Einfahrweg von Hand geaendert wird', () => { expect(nachAenderung({ enteringDistance: 3 }).wegnachweis.herkunft).toBe('handeingabe'); }); it('faellt zurueck bei einer abweichenden Raeumgeschwindigkeit', () => { expect(nachAenderung({ clearingSpeedOverride: 8 }).wegnachweis.herkunft).toBe('handeingabe'); }); it('faellt zurueck bei einer abweichenden Einfahrgeschwindigkeit', () => { expect(nachAenderung({ enteringSpeedOverride: 11 }).wegnachweis.herkunft).toBe('handeingabe'); }); it('faellt zurueck bei einer abweichenden Ueberfahrzeit', () => { expect(nachAenderung({ crossingTimeOverride: 2 }).wegnachweis.herkunft).toBe('handeingabe'); }); it('faellt zurueck bei einer von Hand vorgegebenen Zwischenzeit', () => { expect(nachAenderung({ manualIntergreen: 7 }).wegnachweis.herkunft).toBe('handeingabe'); }); it('bleibt bestehen, wenn nur die Bemerkung geaendert wird', () => { // Die Bemerkung geht in keine Rechnung ein; sie entwertet nichts. const conflict = nachAenderung({ note: 'Vor Ort nachgesehen' }); expect(conflict.note).toBe('Vor Ort nachgesehen'); expect(conflict.wegnachweis.herkunft).toBe('lageplan'); }); it('bleibt bestehen, wenn derselbe Wert noch einmal geschrieben wird', () => { // Sonst verfiele der Nachweis beim blossen Durchtabben durch das Fenster, // und nach einer Sitzung stuende ueberall "von Hand". const { project, conflict } = ausDemLageplan(); const gleich = actions.setConflict(project, conflict.fromId, conflict.toId, { clearingDistance: conflict.clearingDistance, enteringDistance: conflict.enteringDistance, }); expect(gleich.conflicts[0]!.wegnachweis).toEqual(conflict.wegnachweis); }); it('faellt beim Sammelbearbeiten aller Beziehungen zurueck', () => { // Die Massenvorgabe war die zweite Stelle, an der der alte Vermerk stehen // blieb, obwohl die Zahl darunter ausgetauscht wurde. const { project } = ausDemLageplan(); const gesetzt = actions.setzeAlleWege(project, 20, 10, false); expect(gesetzt.conflicts[0]!.clearingDistance).toBe(20); expect(gesetzt.conflicts[0]!.wegnachweis.herkunft).toBe('handeingabe'); }); it('laesst beim Sammelbearbeiten die bereits vermassten Beziehungen unberuehrt', () => { const { project, conflict } = ausDemLageplan(); const gesetzt = actions.setzeAlleWege(project, 20, 10, true); expect(gesetzt.conflicts[0]!.clearingDistance).toBe(conflict.clearingDistance); expect(gesetzt.conflicts[0]!.wegnachweis.herkunft).toBe('lageplan'); }); it('macht aus einer ungeklaerten Herkunft eine Eingabe, sobald jemand sie eintraegt', () => { const start = createStandardIntersectionProject('Vorlage', FIXED_DATE); const erster = start.conflicts[0]!; const geaendert = actions.setConflict(start, erster.fromId, erster.toId, { clearingDistance: 31, }); expect(geaendert.conflicts[0]!.wegnachweis.herkunft).toBe('handeingabe'); }); it('legt eine neu erfasste Beziehung mit ihrer Eingabe als Herkunft an', () => { const start = createStandardIntersectionProject('Vorlage', FIXED_DATE); const a = start.signalGroups[0]!; const b = start.signalGroups[1]!; const ohne = actions.removeConflict(actions.removeConflict(start, a.id, b.id), b.id, a.id); const angelegt = actions.setConflict(ohne, a.id, b.id, { clearingDistance: 18 }); const conflict = angelegt.conflicts.find((c) => c.fromId === a.id && c.toId === b.id)!; expect(conflict.wegnachweis.herkunft).toBe('handeingabe'); // Ohne Angabe bleibt sie ungeklaert - eine Beziehung ohne Wege ist keine // Eingabe, sondern eine offene Aufgabe. const leer = actions.setConflict(ohne, b.id, a.id, {}); const leerer = leer.conflicts.find((c) => c.fromId === b.id && c.toId === a.id)!; expect(leerer.wegnachweis.herkunft).toBe('unbestimmt'); }); });