import { describe, expect, it } from 'vitest'; import { leiteAb, pruefeAufhebbarkeit, uebernimmWege, wegSchluessel, type Ableitung, type VertraeglichkeitVorschlag, } from '@/domain/geometrie/ableitung'; import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import { createEmptyProject } from '@/domain/model/factory'; import type { Project, SignalGroup } from '@/domain/model/project'; /* * Folge aus Befund 61 (Fassung 5.10.0): zusammengesetzte Schluessel aus * Signalgruppenkennungen in `ableitung.ts`. * * Eine Signalgruppenkennung kam ungeprueft aus der Projektdatei - das Einlesen * beanstandete nur die leere und die doppelt vergebene Kennung, kein * Zeichenrepertoire. Enthaelt eine Kennung ein "|", geht der zusammengeklebte * Schluessel `${fromId}|${toId}` auf zwei Weisen kaputt: * * 1. Er ist MEHRDEUTIG. Die Paare ("a|b", "c") und ("a", "b|c") ergeben * beide "a|b|c"; zwei sachlich verschiedene Beziehungen fallen auf * denselben Eintrag, und eine von beiden verschwindet still. * 2. Er ist NICHT UMKEHRBAR. `paar.split('|')` liefert fuer "sg|a|sg-b" die * drei Stuecke "sg", "a", "sg-b"; uebernommen wurde die Beziehung * "sg" -> "a" - zwei Kennungen, die es im Projekt gar nicht gibt. * * Beides trifft die gefaehrliche Richtung: Die gemeinte Beziehung bleibt ohne * die vermessenen Wege stehen, ihre Zwischenzeit haengt weiter am alten (oder * am Vorgabe-)Raeumweg, und der Anwender sieht im Lageplan eine Uebernahme, * die woanders gelandet ist. * * WELCHE STELLEN ES HEUTE SIND (nachgezaehlt mit der Fassung 5.12.0; zuvor * stand hier "die beiden verbliebenen"). `ableitung.ts` bildet an DREI Stellen * einen Schluessel aus Kennungen: * * 1. die Verdichtung auf Signalgruppenpaare in `uebernimmWege` - seit * Befund 61 verschachtelt gefuehrt, wie `parseConflicts`; * 2. der ungerichtete Schluessel der Gruppenpaare in `pruefeAufhebbarkeit` - * ebenso verschachtelt; * 3. `wegSchluessel`, das Auswahlmerkmal eines Strompaares aus zwei * PLANLINIENKENNUNGEN - seit Fassung 5.11.0 ueber * `intergreenKey` maskiert statt mit "->" zusammengeklebt. * * Die dritte Stelle traegt jeder Fall dieser Datei mit: `wegeAufDieFurt` waehlt * die zu uebernehmenden Wege ueber `wegSchluessel` aus. Eine eigene Wache dazu * steht in `wegeschluessel.test.ts`. * * DIE QUELLE IST SEIT 5.11.0 GESCHLOSSEN: Das Einlesen prueft die Kennungen * jetzt gegen ein Zeichenrepertoire (`ZULAESSIGE_KENNUNG` in * src/domain/model/schema.ts) und ersetzt, was nicht hineinpasst. Eine Kennung * mit "|" kann also nicht mehr aus einer Datei kommen. Die Faelle hier bleiben * trotzdem: Sie bewachen die Schluesselbildung selbst - die zweite Reihe, und * die einzige, die noch traegt, wenn das Repertoire eines Tages weiter gefasst * wird. Sie bauen ihre Projekte deshalb unmittelbar auf, nicht ueber das * Einlesen. * * GEGEN DEN ALTSTAND schlagen die drei Faelle mit "|" in der Kennung fehl; die * beiden Gegenproben mit gewoehnlichen Kennungen bestehen dort ebenso. */ const DATUM = new Date('2026-01-01T00:00:00Z'); /** 1 Bildpunkt = 1 m, damit sich die Masse unmittelbar ablesen lassen. */ const KALIBRIERUNG = { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 100, herkunft: 'gemessen', } as const; /** Zufahrt und Furt eines Kreuzungspaares, um `versatz` Meter nach Osten geschoben. */ function haltlinienPaar(nummer: number, versatz: number): Haltlinie[] { return [ { id: `hl-zufahrt-${nummer}`, name: `Zufahrt ${nummer}`, art: 'zufahrt', armId: `arm-${nummer}`, von: { x: versatz, y: 0 }, bis: { x: versatz + 20, y: 0 }, }, { id: `hl-furt-${nummer}`, name: `Furt ${nummer}`, art: 'querung', armId: `arm-${nummer}`, von: { x: versatz, y: 30 }, bis: { x: versatz + 30, y: 30 }, }, ]; } /** Geradeausstrom und kreuzende Furt; sie schneiden sich bei (versatz+10 | 30). */ function linienPaar( nummer: number, versatz: number, gruppeKfz: string, gruppeFuss: string, ): Planlinie[] { return [ { id: `l-gerade-${nummer}`, name: `L${nummer} geradeaus`, mode: 'kfz', movement: 'geradeaus', breiteMeter: REGELBREITE.kfz, punkte: [ { x: versatz + 10, y: 0 }, { x: versatz + 10, y: 60 }, ], haltlinieId: `hl-zufahrt-${nummer}`, startT: 0.5, signalGroupId: gruppeKfz, }, { id: `l-furt-${nummer}`, name: `L${nummer} Furt`, mode: 'fuss', movement: 'querung', breiteMeter: REGELBREITE.fuss, punkte: [ { x: versatz, y: 30 }, { x: versatz + 30, y: 30 }, ], haltlinieId: `hl-furt-${nummer}`, startT: 0, signalGroupId: gruppeFuss, }, ]; } const gruppe = (id: string, patch: Partial = {}): SignalGroup => ({ id, name: id, mode: 'kfz', movement: 'geradeaus', armId: null, vZul: 50, lanes: 1, vehicleClass: 'pkw', minGreenOverride: null, maxGreenOverride: null, reducedMobility: false, color: '#2f6fd0', ...patch, }); const furtgruppe = (id: string): SignalGroup => gruppe(id, { mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }); /** * Projekt und Lageplan mit `paare` Kreuzungen; je Kreuzung eine Kfz- und eine * Fussgaengergruppe mit den uebergebenen Kennungen. */ function aufbau(paare: readonly (readonly [string, string])[]): { project: Project; lageplan: Lageplan; } { const haltlinien = paare.flatMap((_, i) => haltlinienPaar(i + 1, i * 100)); const linien = paare.flatMap(([kfz, fuss], i) => linienPaar(i + 1, i * 100, kfz, fuss)); const lageplan: Lageplan = { bild: null, kalibrierung: KALIBRIERUNG, haltlinien, linien, arbeitsbereiche: [], signalgeber: [], }; const leer = createEmptyProject('Kennungen mit Strich', DATUM); return { project: { ...leer, signalGroups: paare.flatMap(([kfz, fuss]) => [gruppe(kfz), furtgruppe(fuss)]), lageplan, }, lageplan, }; } /** Die Wege der Geradeausstroeme auf ihre jeweilige Furt - je Kreuzung einer. */ function wegeAufDieFurt(project: Project, lageplan: Lageplan, ableitung: Ableitung): string[] { const gewaehlt = ableitung.wege .filter((w) => w.vonLinieId.startsWith('l-gerade-') && w.nachLinieId.startsWith('l-furt-')) .map((w) => wegSchluessel(w)); // Vorbedingung jedes Falls: Ohne Wege prueft nichts darunter etwas. expect(gewaehlt.length, 'die Ströme kreuzen sich im Plan nicht').toBeGreaterThan(0); expect(project.signalGroups.length).toBeGreaterThan(0); expect(lageplan.linien.length).toBe(project.signalGroups.length); return gewaehlt; } describe('R1.2 - Wegeuebernahme aus dem Lageplan bei "|" in der Signalgruppenkennung', () => { it('schreibt die Beziehung der wirklich gemeinten Signalgruppen', () => { // Eine einzige Kreuzung genuegt: Der Schluessel "sg|a|sg-b" laesst sich // nicht mehr zerlegen, und uebernommen wurde "sg" -> "a". const { project, lageplan } = aufbau([['sg|a', 'sg-b']]); const ableitung = leiteAb(project, lageplan); const ergebnis = uebernimmWege( project, lageplan, ableitung, wegeAufDieFurt(project, lageplan, ableitung), ); expect(ergebnis.uebernommen).toBe(1); expect(ergebnis.project.conflicts).toHaveLength(1); const geschrieben = ergebnis.project.conflicts[0]!; expect(geschrieben.fromId).toBe('sg|a'); expect(geschrieben.toId).toBe('sg-b'); expect(geschrieben.clearingDistance).toBeGreaterThan(0); // Und keine Beziehung zwischen Kennungen, die es im Projekt nicht gibt. const kennungen = new Set(project.signalGroups.map((g) => g.id)); for (const c of ergebnis.project.conflicts) { expect(kennungen.has(c.fromId), `fromId "${c.fromId}"`).toBe(true); expect(kennungen.has(c.toId), `toId "${c.toId}"`).toBe(true); } }); it('haelt zwei Gruppenpaare auseinander, die denselben zusammengeklebten Schluessel ergaeben', () => { // ("a|b", "c") und ("a", "b|c") ergeben beide "a|b|c". const { project, lageplan } = aufbau([ ['a|b', 'c'], ['a', 'b|c'], ]); const ableitung = leiteAb(project, lageplan); const ergebnis = uebernimmWege( project, lageplan, ableitung, wegeAufDieFurt(project, lageplan, ableitung), ); expect(ergebnis.uebernommen).toBe(2); const paare = ergebnis.project.conflicts.map((c) => `${c.fromId} -> ${c.toId}`).sort(); expect(paare).toEqual(['a -> b|c', 'a|b -> c']); }); it('uebernimmt bei gewoehnlichen Kennungen unveraendert', () => { // Gegenprobe: Kennungen aus createId (a-z, 0-9, "-") sind von der // Umstellung nicht betroffen. const { project, lageplan } = aufbau([ ['sg-k1', 'sg-f1'], ['sg-k2', 'sg-f2'], ]); const ableitung = leiteAb(project, lageplan); const ergebnis = uebernimmWege( project, lageplan, ableitung, wegeAufDieFurt(project, lageplan, ableitung), ); expect(ergebnis.uebernommen).toBe(2); const paare = ergebnis.project.conflicts.map((c) => `${c.fromId} -> ${c.toId}`).sort(); expect(paare).toEqual(['sg-k1 -> sg-f1', 'sg-k2 -> sg-f2']); }); }); /** * Ein vertraegliches, im Projekt erfasstes Strompaar - der Fall, in dem * `pruefeAufhebbarkeit` etwas zu melden hat. */ function vertraeglich(aLinieId: string, bLinieId: string): VertraeglichkeitVorschlag { return { aLinieId, bLinieId, aName: aLinieId, bName: bLinieId, feindlich: false, zuPruefen: false, grund: 'kein-schnitt', begruendung: 'Die Fahrlinien schneiden sich nicht.', imProjekt: true, abweichung: true, }; } /** Nur `vertraeglichkeit` wird von pruefeAufhebbarkeit gelesen. */ function nurVertraeglichkeit(paare: readonly VertraeglichkeitVorschlag[]): Ableitung { return { auswertbar: true, signalgruppen: [], vertraeglichkeit: paare, wege: [], unterdrueckt: [], hinweise: [], }; } describe('R1.2 - Aufhebbarkeit bei "|" in der Signalgruppenkennung', () => { it('beurteilt zwei Gruppenpaare mit demselben zusammengeklebten Schluessel getrennt', () => { const { lageplan } = aufbau([ ['a|b', 'c'], ['a', 'b|c'], ]); const ergebnis = pruefeAufhebbarkeit( lageplan, nurVertraeglichkeit([ vertraeglich('l-gerade-1', 'l-furt-1'), vertraeglich('l-gerade-2', 'l-furt-2'), ]), ); expect(ergebnis.gesperrt).toHaveLength(0); const paare = ergebnis.aufhebbar.map((p) => `${p.ersteId} + ${p.zweiteId}`).sort(); expect(paare).toEqual(['a + b|c', 'a|b + c']); for (const eintrag of ergebnis.aufhebbar) { expect(eintrag.paare, `${eintrag.ersteId} + ${eintrag.zweiteId}`).toHaveLength(1); } }); it('laesst ein feindliches Strompaar nur das eigene Gruppenpaar sperren', () => { // Der zweite Vorschlag ist feindlich. Fielen beide auf denselben // Schluessel, riss er das erste, vertraegliche Gruppenpaar mit in die // Sperre - mit einer Begruendung, die zu ganz anderen Signalgruppen gehoert. const { lageplan } = aufbau([ ['a|b', 'c'], ['a', 'b|c'], ]); const feindlichesPaar: VertraeglichkeitVorschlag = { ...vertraeglich('l-gerade-2', 'l-furt-2'), feindlich: true, grund: 'kreuzung', begruendung: 'Die Fahrlinien kreuzen sich.', abweichung: false, }; const ergebnis = pruefeAufhebbarkeit( lageplan, nurVertraeglichkeit([vertraeglich('l-gerade-1', 'l-furt-1'), feindlichesPaar]), ); expect(ergebnis.gesperrt).toHaveLength(0); expect(ergebnis.aufhebbar).toHaveLength(1); expect(ergebnis.aufhebbar[0]!.ersteId).toBe('a|b'); expect(ergebnis.aufhebbar[0]!.zweiteId).toBe('c'); }); it('fasst bei gewoehnlichen Kennungen weiter je Gruppenpaar zusammen', () => { // Gegenprobe: Die Verdichtung auf Signalgruppenpaare bleibt - beide // Strompaare derselben zwei Gruppen ergeben einen Eintrag. const { lageplan } = aufbau([ ['sg-k1', 'sg-f1'], ['sg-k2', 'sg-f2'], ]); const ergebnis = pruefeAufhebbarkeit( lageplan, nurVertraeglichkeit([ vertraeglich('l-gerade-1', 'l-furt-1'), vertraeglich('l-furt-1', 'l-gerade-1'), vertraeglich('l-gerade-2', 'l-furt-2'), ]), ); expect(ergebnis.aufhebbar).toHaveLength(2); const nachPaar = new Map( ergebnis.aufhebbar.map((p) => [`${p.ersteId} + ${p.zweiteId}`, p.paare.length]), ); expect(nachPaar.get('sg-f1 + sg-k1')).toBe(2); expect(nachPaar.get('sg-f2 + sg-k2')).toBe(1); }); });