import { describe, expect, it } from 'vitest'; import { istZulaessigeKennung, parseProject } from '@/domain/model/schema'; import { CURRENT_SCHEMA_VERSION } from '@/domain/model/project'; /* * Zeichenrepertoire fuer Kennungen (Fassung 5.12.0). * * Das Einlesen prueft an einer Kennung bis dahin nur zweierlei: leer und * doppelt vergeben. Sonst kommt sie mit beliebigem Inhalt aus der Datei. Drei * Befunde sind daraus geworden - 61 und seine Folge (Paarschluessel * aus Signalgruppenkennungen) und 43 (Wegschluessel aus Planlinienkennungen); * behoben war jedes Mal die Schluesselbildung, nie die Kennung. * * WAS DIESE DATEI BEWACHT: dass die Quelle geschlossen ist. Eine Kennung * ausserhalb des Repertoires wird ERSETZT und GEMELDET, und jeder Verweis * darauf zieht mit - Konfliktbeziehungen, Phasen, Verkehrsstaerken, Fahrlinien, * Haltlinien und die Zuordnung zur Zufahrt. Aufgeloest wird nichts: Die Kennung * gehoert genau einem Datensatz, es ist nichts zu raten, und ein Verlust der * Konfliktbeziehungen waere die gefaehrliche Richtung (`intergreenBetween` * liefert fuer eine fehlende Beziehung 0). * * GEGEN DEN ALTSTAND (Fassung 5.11.0) sind 11 der 15 Faelle * rot - gemessen, indem die HEAD-Fassung von schema.ts ueber ein Vite-Plugin * untergeschoben wurde; der Arbeitsbaum blieb unangetastet. Was dabei * herauskommt, ist nicht einerlei: * * - Rot ueber das VERHALTEN sind neun Faelle - jeder, der eine Ersetzung, * eine Meldung oder deren Wortlaut erwartet. Dazu gehoert auch der Fall * zur Doppelvergabe: Im Altstand bleibt die ERSTE, unzulaessige Kennung * stehen. Er prueft das unmittelbar am Trennzeichen und nicht ueber * `istZulaessigeKennung`, damit er dort nicht schon ueber die fehlende * Ausfuhr faellt. * - Rot ueber die fehlende AUSFUHR sind die beiden Faelle, die * `istZulaessigeKennung` selbst festhalten - die Funktion gibt es im * Altstand nicht. Sie bewachen das Repertoire, nicht den Einleseweg. * - GRUEN bleiben dort vier: die Gegenprobe mit gewoehnlichen Kennungen; der * Fall zum Nachziehen der Verweise (ohne Ersetzung faellt die alte Kennung * mit der neuen zusammen, die Erwartung trifft dann von selbst zu); und die * beiden Faelle zum Zusammentreffen von unzulaessig und doppelt, die das * Aufloesen der Verweise und das Fehlen einer beschoenigenden Meldung * festhalten - beides tut der Altstand ebenso, er meldet dort ueberhaupt * nichts. * * GEGEN DEN STAND VOR DEM NACHTRAG (Ersatzmeldung sofort abgesetzt, * `kennungImKlartext` nur ueber \p{Cc}) sind drei der vier nachgereichten * Faelle rot: die beiden zur zurueckgenommenen Umschluesselung und der zu den * unsichtbaren Zeichen. Gruen bleiben dort die Gegenprobe zum gewoehnlichen * Leerzeichen und der Fall, der das Aufloesen der Verweise festhaelt - am * Verhalten hat der Nachtrag nichts geaendert, nur an den Meldungen. */ const DATUM = new Date('2026-01-01T00:00:00Z'); /** Meldungen zu einem Pfad, damit sich die Erwartung ablesen laesst. */ function meldungen( issues: readonly { path: string; message: string }[], path: string, ): readonly string[] { return issues.filter((i) => i.path === path).map((i) => i.message); } describe('E.1 - Zeichenrepertoire der Kennungen', () => { it('nimmt an, was das Programm selbst schreibt', () => { // createId: "--<8 Stellen hexadezimal>". expect(istZulaessigeKennung('sg-1z-4bb787ea')).toBe(true); // tools/vierarmigerKnotenpunkt.ts und die eingefrorene Beispieldatei. expect(istZulaessigeKennung('arm-nord')).toBe(true); expect(istZulaessigeKennung('arm-nord-geradeaus')).toBe(true); expect(istZulaessigeKennung('hl-arm-nord')).toBe(true); expect(istZulaessigeKennung('ph-1')).toBe(true); // Von Hand oder aus einem Fremdwerkzeug: erlaubt, weil sie nichts brechen. expect(istZulaessigeKennung('SG_1')).toBe(true); expect(istZulaessigeKennung('sg.1')).toBe(true); }); it('weist die Zeichen ab, die belegt etwas brechen', () => { // Die Trennzeichen der Paarschluessel (intergreenKey, maskiereKennung). expect(istZulaessigeKennung('a|b')).toBe(false); expect(istZulaessigeKennung('a~b')).toBe(false); // Das Anfuehrungszeichen bricht [data-hervorheben=""]. expect(istZulaessigeKennung('a"b')).toBe(false); // Steuerzeichen und Leerraum zerreissen jede zeilenweise Ausgabe. expect(istZulaessigeKennung('a b')).toBe(false); expect(istZulaessigeKennung('a\nb')).toBe(false); // Der alte Trennstrich aus parseConflicts und wegSchluessel. expect(istZulaessigeKennung('a->b')).toBe(false); // Die Laenge gehoert zum Repertoire: 64 ja, 65 nein. expect(istZulaessigeKennung('a'.repeat(64))).toBe(true); expect(istZulaessigeKennung('a'.repeat(65))).toBe(false); // Die leere Kennung ist nicht Sache des Repertoires, sondern der // Neuvergabe an der Fundstelle - dort ohne Meldung. expect(istZulaessigeKennung('')).toBe(false); }); }); describe('E.1 - Signalgruppenkennung ausserhalb des Repertoires', () => { /** Ein Projekt, in dem alles auf die Signalgruppe "sg|a" verweist. */ const datei = { schemaVersion: CURRENT_SCHEMA_VERSION, intersection: { arms: [{ id: 'arm-nord', name: 'Nord' }] }, signalGroups: [ { id: 'sg|a', name: 'K1', mode: 'kfz', movement: 'geradeaus', armId: 'arm-nord' }, { id: 'sg-b', name: 'F1', mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }, ], conflicts: [ { id: 'cf-1', fromId: 'sg|a', toId: 'sg-b', clearingDistance: 12, enteringDistance: 4 }, ], phases: [{ id: 'ph-1', name: 'Phase 1', signalGroupIds: ['sg|a'] }], demands: [{ signalGroupId: 'sg|a', volume: 600, heavyVehicleShare: 0.05 }], lageplan: { haltlinien: [ { id: 'hl-1', name: 'Zufahrt Nord', art: 'zufahrt', armId: 'arm-nord', von: { x: 0, y: 0 }, bis: { x: 20, y: 0 }, }, ], linien: [ { id: 'pl-1', name: 'L1', mode: 'kfz', movement: 'geradeaus', punkte: [ { x: 10, y: 0 }, { x: 10, y: 40 }, ], haltlinieId: 'hl-1', startT: 0.5, signalGroupId: 'sg|a', }, ], }, program: { phaseOrder: ['ph-1'] }, }; it('ersetzt die Kennung und meldet es', () => { const { project, issues, repaired } = parseProject(datei, DATUM); const ersetzt = project.signalGroups[0]!; expect(ersetzt.name).toBe('K1'); expect(ersetzt.id).not.toBe('sg|a'); expect(istZulaessigeKennung(ersetzt.id)).toBe(true); expect(repaired).toBe(true); expect(meldungen(issues, 'signalGroups[0]')).toHaveLength(1); expect(meldungen(issues, 'signalGroups[0]')[0]).toContain('sg|a'); }); it('zieht alle Verweise auf die neue Kennung nach, statt sie aufzuloesen', () => { const { project, issues } = parseProject(datei, DATUM); const neu = project.signalGroups[0]!.id; // Die feindliche Beziehung bleibt - ihr Verlust waere die gefaehrliche // Richtung: intergreenBetween liefert fuer eine fehlende Beziehung 0. expect(project.conflicts).toHaveLength(1); expect(project.conflicts[0]!.fromId).toBe(neu); expect(project.conflicts[0]!.toId).toBe('sg-b'); expect(project.conflicts[0]!.clearingDistance).toBe(12); expect(project.phases[0]!.signalGroupIds).toEqual([neu]); expect(project.demands).toHaveLength(1); expect(project.demands[0]!.signalGroupId).toBe(neu); expect(project.demands[0]!.volume).toBe(600); expect(project.lageplan.linien[0]!.signalGroupId).toBe(neu); // Und kein Verweis meldet einen Verlust: Gemeldet wird einmal, an der // Kennung selbst. const verluste = issues.filter((i) => /nicht vorhandene|aufgehoben|entfernt/.test(i.message)); expect(verluste).toEqual([]); }); }); describe('E.1 - die uebrigen Kennungen', () => { it('ersetzt eine Zufahrtskennung und zieht Signalgruppe und Haltlinie nach', () => { const { project, issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, intersection: { arms: [{ id: 'arm nord', name: 'Nord' }] }, signalGroups: [{ id: 'sg-1', name: 'K1', armId: 'arm nord' }], lageplan: { haltlinien: [ { id: 'hl-1', name: 'Zufahrt', art: 'zufahrt', armId: 'arm nord', von: { x: 0, y: 0 }, bis: { x: 20, y: 0 }, }, ], linien: [], }, }, DATUM, ); const neu = project.intersection.arms[0]!.id; expect(neu).not.toBe('arm nord'); expect(istZulaessigeKennung(neu)).toBe(true); expect(project.signalGroups[0]!.armId).toBe(neu); expect(project.lageplan.haltlinien[0]!.armId).toBe(neu); expect(meldungen(issues, 'intersection.arms[0]')).toHaveLength(1); }); it('ersetzt eine Phasenkennung und zieht die Phasenfolge nach', () => { const { project, issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, signalGroups: [{ id: 'sg-1', name: 'K1' }], phases: [ // Ein Steuerzeichen in der Kennung - in jeder Ansicht unsichtbar. { id: 'ph\u0001', name: 'Phase 1', signalGroupIds: ['sg-1'] }, { id: 'ph-2', name: 'Phase 2', signalGroupIds: [] }, ], program: { phaseOrder: ['ph-2', 'ph\u0001'] }, }, DATUM, ); const neu = project.phases[0]!.id; expect(neu).not.toBe('ph\u0001'); expect(project.program.phaseOrder).toEqual(['ph-2', neu]); // Das Steuerzeichen steht sichtbar in der Meldung, nicht als Zeilenbruch. expect(meldungen(issues, 'phases[0]')[0]).toContain('\\u0001'); }); it('ersetzt eine Haltlinienkennung und zieht die Fahrlinie nach', () => { const { project, issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, signalGroups: [], lageplan: { haltlinien: [ { id: 'hl->1', name: 'Zufahrt', art: 'zufahrt', von: { x: 0, y: 0 }, bis: { x: 20, y: 0 }, }, ], linien: [ { id: 'pl-1', name: 'L1', mode: 'kfz', movement: 'geradeaus', punkte: [ { x: 10, y: 0 }, { x: 10, y: 40 }, ], haltlinieId: 'hl->1', startT: 0.5, }, ], }, }, DATUM, ); const neu = project.lageplan.haltlinien[0]!.id; expect(neu).not.toBe('hl->1'); expect(project.lageplan.linien[0]!.haltlinieId).toBe(neu); expect(meldungen(issues, 'lageplan.haltlinien[0]')).toHaveLength(1); }); it('ersetzt Konflikt- und Fahrlinienkennung, auf die in der Datei nichts verweist', () => { const { project, issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, signalGroups: [ { id: 'sg-1', name: 'K1' }, { id: 'sg-2', name: 'K2' }, ], conflicts: [{ id: 'cf|1', fromId: 'sg-1', toId: 'sg-2', clearingDistance: 9 }], lageplan: { haltlinien: [], linien: [ { id: 'pl|1', name: 'L1', mode: 'kfz', movement: 'geradeaus', punkte: [ { x: 0, y: 0 }, { x: 0, y: 40 }, ], }, ], }, }, DATUM, ); expect(project.conflicts).toHaveLength(1); expect(project.conflicts[0]!.id).not.toBe('cf|1'); expect(project.conflicts[0]!.clearingDistance).toBe(9); expect(istZulaessigeKennung(project.conflicts[0]!.id)).toBe(true); expect(meldungen(issues, 'conflicts[0]')).toHaveLength(1); expect(project.lageplan.linien[0]!.id).not.toBe('pl|1'); expect(istZulaessigeKennung(project.lageplan.linien[0]!.id)).toBe(true); expect(meldungen(issues, 'lageplan.linien[0]')).toHaveLength(1); }); }); describe('E.1 - unzulaessig UND doppelt vergeben', () => { it('behandelt die zweite Vergabe als mehrdeutig und loest die Verweise auf', () => { // Jetzt ist wieder offen, welche der beiden Gruppen gemeint ist - hier // wird aufgeloest statt umgeschluesselt. const { project, issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, signalGroups: [ { id: 'sg|a', name: 'K1' }, { id: 'sg|a', name: 'K2' }, { id: 'sg-b', name: 'F1', mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }, ], conflicts: [{ id: 'cf-1', fromId: 'sg|a', toId: 'sg-b', clearingDistance: 12 }], demands: [{ signalGroupId: 'sg|a', volume: 600 }], }, DATUM, ); expect(project.signalGroups.map((g) => g.name)).toEqual(['K1', 'K2', 'F1']); expect(new Set(project.signalGroups.map((g) => g.id)).size).toBe(3); // Nicht ueber `istZulaessigeKennung` geprueft, sondern unmittelbar am // Trennzeichen: Im Altstand fehlt die Ausfuhr, und der Fall fiele dort // ueber einen TypeError statt ueber das, was er bewacht - dass auch die // erste, unzulaessige Kennung ersetzt ist und nicht bloss die zweite. for (const gruppe of project.signalGroups) { expect(gruppe.id).not.toContain('|'); } expect(project.conflicts).toEqual([]); expect(project.demands).toEqual([]); expect(issues.some((i) => i.message.includes('kommt mehrfach vor'))).toBe(true); expect( issues.some((i) => i.path === 'demands[0]' && i.message.includes('mehrfach vergebene')), ).toBe(true); }); /** * Alle vier Kennungsarten mit Umschluesselungskarte, jede zweimal mit * derselben unzulaessigen Kennung: Zufahrt, Signalgruppe, Phase, Haltlinie. * Konfliktbeziehung und Fahrlinie fuehren keine Karte - auf ihre Kennungen * verweist in der Datei nichts, dort ist nichts zurueckzunehmen. */ const doppeltUndUnzulaessig = { schemaVersion: CURRENT_SCHEMA_VERSION, intersection: { arms: [ { id: 'arm|n', name: 'Nord' }, { id: 'arm|n', name: 'Nord zwei' }, ], }, signalGroups: [ { id: 'sg|a', name: 'K1', mode: 'kfz', movement: 'geradeaus', armId: 'arm|n' }, { id: 'sg|a', name: 'K2', mode: 'kfz', movement: 'geradeaus' }, { id: 'sg-b', name: 'F1', mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }, ], conflicts: [ { id: 'cf-1', fromId: 'sg|a', toId: 'sg-b', clearingDistance: 12, enteringDistance: 4 }, ], phases: [ { id: 'ph|1', name: 'Phase 1', signalGroupIds: ['sg|a'] }, { id: 'ph|1', name: 'Phase 2', signalGroupIds: [] }, ], demands: [{ signalGroupId: 'sg|a', volume: 600 }], lageplan: { haltlinien: [ { id: 'hl|1', name: 'Zufahrt Nord', art: 'zufahrt', armId: 'arm|n', von: { x: 0, y: 0 }, bis: { x: 20, y: 0 }, }, { id: 'hl|1', name: 'Zufahrt Nord zwei', art: 'zufahrt', von: { x: 0, y: 30 }, bis: { x: 20, y: 30 }, }, ], linien: [ { id: 'pl-1', name: 'L1', mode: 'kfz', movement: 'geradeaus', punkte: [ { x: 10, y: 0 }, { x: 10, y: 40 }, ], haltlinieId: 'hl|1', startT: 0.5, signalGroupId: 'sg|a', }, ], }, program: { phaseOrder: ['ph|1'] }, }; it('behauptet nicht mehr, an den Verweisen aendere sich nichts', () => { // Der Einlesebericht sagte dem Planer an genau der Stelle, an der eine // Konfliktbeziehung verlorengeht, dass nichts verlorengeht: Die // Ersatzmeldung war schon abgesetzt, als die zweite Vergabe die // Umschluesselung wieder zuruecknahm. const { issues } = parseProject(doppeltUndUnzulaessig, DATUM); expect(issues.filter((i) => i.message.includes('ändert sich nichts'))).toEqual([]); }); it('sagt an jeder der vier Kennungen, dass die Umschluesselung zurueckgenommen ist', () => { const { issues } = parseProject(doppeltUndUnzulaessig, DATUM); for (const path of [ 'intersection.arms[0]', 'signalGroups[0]', 'phases[0]', 'lageplan.haltlinien[0]', ]) { // An "phases[0]" steht daneben noch die aufgeloeste Zuordnung zur // mehrfach vergebenen Signalgruppe; gemeint ist hier die Ersatzmeldung. const zurKennung = meldungen(issues, path).filter((m) => m.includes('Sie wurde durch')); expect(zurKennung).toHaveLength(1); expect(zurKennung[0]).toContain('zurückgenommen'); } }); it('loest die Verweise auf alle vier Kennungen tatsaechlich auf', () => { // Die Meldung und das, was geschieht, muessen dasselbe sagen - deshalb // steht hier neben dem Wortlaut auch das Ergebnis. const { project } = parseProject(doppeltUndUnzulaessig, DATUM); expect(project.signalGroups[0]!.armId).toBeNull(); expect(project.lageplan.haltlinien[0]!.armId).toBeNull(); expect(project.conflicts).toEqual([]); expect(project.demands).toEqual([]); expect(project.phases[0]!.signalGroupIds).toEqual([]); expect(project.lageplan.linien[0]!.haltlinieId).toBeNull(); expect(project.lageplan.linien[0]!.signalGroupId).toBeNull(); }); }); describe('E.1 - die Meldung macht unsichtbare Zeichen sichtbar', () => { // Ueber die Zeichennummer gebildet, damit im Quelltext dieser Datei kein // unsichtbares Zeichen steht - man saehe hier so wenig wie in der Meldung. /** U+200B, das Zeichen ohne Breite. */ const OHNE_BREITE = String.fromCharCode(0x200b); /** U+00A0, das geschuetzte Leerzeichen. */ const GESCHUETZT = String.fromCharCode(0x00a0); /** Die Meldung, die das Einlesen zur Signalgruppenkennung `roh` absetzt. */ function meldungZurKennung(roh: string): string { const { issues } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, signalGroups: [{ id: roh, name: 'K1' }] }, DATUM, ); return meldungen(issues, 'signalGroups[0]')[0] ?? ''; } it('schreibt Zeichen aus, die man sonst nicht saehe', () => { // Ohne die Ersetzung stuende auf dem Schirm "sga" - eine Kennung, die // aussieht wie eine zulaessige, und der Anwender staende vor einer // Beanstandung, die er nicht nachvollziehen kann. expect(meldungZurKennung(`sg${OHNE_BREITE}a`)).toContain('sg\\u200ba'); // Das geschuetzte Leerzeichen saehe wie ein gewoehnliches aus. expect(meldungZurKennung(`sg${GESCHUETZT}a`)).toContain('sg\\u00a0a'); // Der Zeilenumbruch zerrisse die Meldung. expect(meldungZurKennung('sg\na')).toContain('sg\\u000aa'); }); it('laesst das gewoehnliche Leerzeichen stehen', () => { // Zwischen den Anfuehrungszeichen ist es zu erkennen; eine Zahlenfolge // waere hier schwerer zu lesen als die Kennung selbst. expect(meldungZurKennung('arm nord')).toContain('"arm nord"'); }); }); describe('E.1 - Gegenprobe', () => { it('laesst eine Datei mit gewoehnlichen Kennungen unveraendert und ohne Meldung', () => { const { project, issues, repaired } = parseProject( { schemaVersion: CURRENT_SCHEMA_VERSION, intersection: { arms: [{ id: 'arm-nord', name: 'Nord' }] }, signalGroups: [ { id: 'sg-1z-4bb787ea', name: 'K1', armId: 'arm-nord' }, { id: 'arm-nord-geradeaus', name: 'K2' }, ], conflicts: [ { id: 'cf-10-fa4aa6f5', fromId: 'sg-1z-4bb787ea', toId: 'arm-nord-geradeaus', clearingDistance: 12, enteringDistance: 4, }, ], phases: [{ id: 'ph-1', name: 'Phase 1', signalGroupIds: ['sg-1z-4bb787ea'] }], program: { phaseOrder: ['ph-1'] }, }, DATUM, ); expect(project.signalGroups.map((g) => g.id)).toEqual(['sg-1z-4bb787ea', 'arm-nord-geradeaus']); expect(project.signalGroups[0]!.armId).toBe('arm-nord'); expect(project.conflicts[0]!.id).toBe('cf-10-fa4aa6f5'); expect(project.phases[0]!.id).toBe('ph-1'); expect(project.program.phaseOrder).toEqual(['ph-1']); expect(issues).toEqual([]); expect(repaired).toBe(false); }); });