import { describe, expect, it } from 'vitest'; import { leiteAb, uebernimmSignalgruppen, uebernimmWege, wegSchluessel, } from '@/domain/geometrie/ableitung'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import { createEmptyProject, createSignalGroup } from '@/domain/model/factory'; import { MAX_PHASENMENGEN, MAX_VORSCHLAEGE, maximaleMengen, sucheEinteilungen, vertraeglichkeitsbelege, } from '@/domain/plan/phaseneinteilung'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import type { Project } from '@/domain/model/project'; /** * Fassung 5.36.0 - zulaessige Phaseneinteilungen. * * DIE BEDINGUNG, UNTER DER DIESER PUNKT UEBERHAUPT GEBAUT WERDEN DURFTE: * "Ohne positiven Beleg der Vertraeglichkeit jedes Paares darf kein Vorschlag * erscheinen. Das ist keine Feinheit." Der erste Abschnitt dieser Datei * bewacht genau das - und zwar in beide Richtungen: kein Vorschlag ohne Beleg, * und Vorschlaege, sobald er da ist. * * Der Grund: Eine Reihung nach kleinster Umlaufzeit bevorzugt wenige Phasen, * also viele gleichzeitig freigegebene Gruppen. Im Datenbestand sieht * "geprueft vertraeglich" aber genauso aus wie "noch nicht betrachtet", und * mit jedem entfallenden Phasenuebergang faellt eine Zwischenzeit weg. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie { return { id, name, mode: 'kfz', movement: 'geradeaus', breiteMeter: REGELBREITE.kfz, punkte, haltlinieId: null, startT: 0.5, signalGroupId: null, }; } /** * Drei Stroeme: K1 und K3 laufen parallel, K2 kreuzt beide. * * Damit gibt es genau eine sinnvolle Einteilung - {K1, K3} und {K2} -, und * jede Aussage dieser Datei laesst sich von Hand nachvollziehen. */ function lageplan(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], haltlinien: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: 400, hoehe: 400, herkunft: 'Prüfstück', geladenAm: STICHTAG.toISOString(), }, // 100 Bildpunkte sind 50 m. 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 }, ]), linie('l-p', 'K3', [ { x: 0, y: 160 }, { x: 200, y: 160 }, ]), ], }; } /** * Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt. * * Der Weg ist derselbe, den die Oberflaeche geht: Signalgruppen aus den * Fahrlinien uebernehmen, danach die Wege. Ein von Hand gebautes Projekt * haette den Beleg nicht - und genau darum geht es hier. */ function belegtesProjekt(): Project { const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() }; const alleLinien = leer.lageplan.linien.map((l) => l.id); const gruppen = uebernimmSignalgruppen(leer, leer.lageplan, alleLinien); const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; const ableitung = leiteAb(mitPlan, mitPlan.lageplan); const wege = uebernimmWege( mitPlan, mitPlan.lageplan, ableitung, ableitung.wege.map((w) => wegSchluessel(w)), STICHTAG, ); return wege.project; } describe('Phaseneinteilung - ohne Beleg kein Vorschlag', () => { it('macht keinen einzigen Vorschlag, solange ein Paar unbelegt ist', () => { /* * DER FALL, UM DEN ES GEHT. Zwei Signalgruppen, kein Lageplan, kein * erfasster Konflikt: Das Programm HÄLT sie für gleichzeitig freigebbar – * der Signalzeitenplan tut es jedenfalls. Ein Vorschlag würde daraus eine * Empfehlung machen, und niemand hätte je geprüft, ob sich die beiden * Ströme vertragen. */ const roh = createEmptyProject('ohne Plan', STICHTAG); const ohnePlan: Project = { ...roh, signalGroups: [{ ...leereGruppe('sg-a', 'K1') }, { ...leereGruppe('sg-b', 'K2') }], }; const suche = sucheEinteilungen(ohnePlan); expect(suche.hindernis).toBe('ohne-beleg'); expect(suche.vorschlaege).toEqual([]); expect(suche.beleg.ohneBeleg).toHaveLength(1); expect(suche.beleg.ohneBeleg[0]?.grund).toContain('nicht belegt'); }); it('nennt die unbelegten Paare beim Namen', () => { // Ein leeres Ergebnis ohne Grund waere das Schlimmste: Der Bearbeiter // hielte es fuer "es gibt keine Einteilung". const projekt = belegtesProjekt(); const zusatz: Project = { ...projekt, signalGroups: [...projekt.signalGroups, leereGruppe('sg-neu', 'K9')], }; const suche = sucheEinteilungen(zusatz); expect(suche.hindernis).toBe('ohne-beleg'); const namen = suche.beleg.ohneBeleg.map((p) => `${p.aName}/${p.bName}`); expect(namen.every((n) => n.includes('K9'))).toBe(true); expect(namen.length).toBe(3); }); it('nimmt den Beleg aus dem Lageplan und schlägt dann vor', () => { const suche = sucheEinteilungen(belegtesProjekt()); expect(suche.hindernis).toBeNull(); expect(suche.beleg.vollstaendig).toBe(true); expect(suche.vorschlaege.length).toBeGreaterThan(0); // Der Beleg sagt, woher er kommt - und nennt die geprüften Strompaare. const belegt = suche.beleg.paare.filter((p) => p.art === 'belegt'); expect(belegt.length).toBeGreaterThan(0); expect(belegt[0]?.grund).toContain('Im Lageplan geprüft'); }); it('belegt kein Paar, dessen Fahrlinien sich kreuzen - auch ohne erfassten Konflikt', () => { /* * DER GEFAEHRLICHSTE FALL. Wer eine Konfliktbeziehung von Hand aufhebt, * ohne die Zeichnung zu aendern, hinterlaesst ein Paar OHNE erfassten * Konflikt, dessen Fahrlinien sich kreuzen. Wuerde die Zeichnung hier * ignoriert, gaelte das Paar als "belegt vertraeglich" - und der Vorschlag * gaebe zwei kreuzenden Stroemen gleichzeitig Gruen. */ const projekt = belegtesProjekt(); const ohneKonflikte: Project = { ...projekt, conflicts: [] }; const stand = vertraeglichkeitsbelege(ohneKonflikte); expect(stand.paare.some((p) => p.art === 'feindlich')).toBe(false); const kreuzend = stand.paare.filter((p) => p.art === 'ohne-beleg'); expect(kreuzend.length, 'die kreuzenden Paare dürfen nicht als belegt gelten').toBe(2); expect(kreuzend[0]?.grund).toContain('uneindeutig'); expect(sucheEinteilungen(ohneKonflikte).hindernis).toBe('ohne-beleg'); }); it('führt eine erfasste feindliche Beziehung als Aussage, nicht als Lücke', () => { const suche = sucheEinteilungen(belegtesProjekt()); const feindlich = suche.beleg.paare.filter((p) => p.art === 'feindlich'); expect(feindlich.length).toBeGreaterThan(0); expect(feindlich[0]?.grund).toContain('erfasst'); }); }); describe('Phaseneinteilung - was vorgeschlagen wird', () => { it('fasst nur paarweise verträgliche Gruppen in eine Phase', () => { /* * DIE ZWEITE SICHERHEITSZUSAGE. Eine Phase ist eine Menge gleichzeitig * freigegebener Gruppen; steht darin ein feindliches Paar, bekommen zwei * kreuzende Ströme zugleich Grün. */ const projekt = belegtesProjekt(); const feindlich = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|'))); for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { for (const phase of vorschlag.phasen) { for (let i = 0; i < phase.length; i += 1) { for (let j = i + 1; j < phase.length; j += 1) { expect(feindlich.has([phase[i]!, phase[j]!].sort().join('|'))).toBe(false); } } } } }); it('fasst jede Phase so weit wie möglich zusammen', () => { /* * Eine Phase, der sich eine weitere vertraegliche Gruppe hinzufuegen * liesse, verschenkt Freigabezeit - sie waere nie die Antwort auf "welche * Einteilung ergibt die kuerzeste Umlaufzeit". Solche Vorschlaege * blaehten die Liste auf und stuenden mit schlechteren Zahlen darin. */ const projekt = belegtesProjekt(); const belegt = new Set( vertraeglichkeitsbelege(projekt) .paare.filter((p) => p.art === 'belegt') .map((p) => [p.aId, p.bId].sort().join('|')), ); const vertraeglich = (a: string, b: string): boolean => belegt.has([a, b].sort().join('|')); for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { for (const phase of vorschlag.phasen) { const erweiterbar = projekt.signalGroups .map((g) => g.id) .filter((id) => !phase.includes(id) && phase.every((p) => vertraeglich(p, id))); expect(erweiterbar, `Phase ${phase.join('+')} ließe sich erweitern`).toEqual([]); } } }); it('gibt jeder Signalgruppe wenigstens eine Phase', () => { // Eine Gruppe ohne Phase bekommt nie Freigabe - ein Vorschlag, der sie // auslaesst, waere kein Signalzeitenplan. const projekt = belegtesProjekt(); const alle = new Set(projekt.signalGroups.map((g) => g.id)); for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { const enthalten = new Set(vorschlag.phasen.flat()); expect(enthalten).toEqual(alle); } }); it('reiht nach Umlaufzeit', () => { /* * MIT VIER GRUPPEN, damit es ueberhaupt etwas zu reihen gibt: Bei drei * Stroemen bleibt eine einzige Einteilung uebrig, und eine Liste mit einem * Eintrag ist in jeder Reihenfolge sortiert - ein Fall, der das nicht * beachtet, bewacht nichts. */ const vorschlaege = sucheEinteilungen(vierStroeme()).vorschlaege; expect(vorschlaege.length, 'zum Reihen braucht es mehr als einen Vorschlag').toBeGreaterThan(1); const zeiten = vorschlaege.map((v) => v.umlaufzeit); expect(new Set(zeiten).size, 'und mehr als eine Umlaufzeit').toBeGreaterThan(1); expect([...zeiten].sort((a, b) => a - b)).toEqual(zeiten); for (const zeit of zeiten) expect(zeit).toBeGreaterThan(0); }); it('erkennt die heutige Einteilung wieder', () => { /* * Ohne diesen Vermerk sucht der Bearbeiter in der Liste nach dem, was er * schon hat - und findet es nicht, weil die Phasen anders heissen. */ const projekt = belegtesProjekt(); const suche = sucheEinteilungen(projekt); const erste = suche.vorschlaege[0]; expect(erste).toBeDefined(); const uebernommen: Project = { ...projekt, phases: erste!.phasen.map((gruppen, index) => ({ id: `p-${String(index)}`, name: `Phase ${String(index + 1)}`, signalGroupIds: [...gruppen], manualGreen: null, })), }; const danach = sucheEinteilungen(uebernommen); expect(danach.vorschlaege.some((v) => v.heutige)).toBe(true); }); it('bleibt unter der Höchstzahl der Vorschläge', () => { expect(sucheEinteilungen(belegtesProjekt()).vorschlaege.length).toBeLessThanOrEqual( MAX_VORSCHLAEGE, ); }); it('sagt bei einer einzigen Gruppe, dass es nichts einzuteilen gibt', () => { const roh = createEmptyProject('eine', STICHTAG); const eine: Project = { ...roh, signalGroups: [leereGruppe('sg-a', 'K1')] }; expect(sucheEinteilungen(eine).hindernis).toBe('zu-wenige-gruppen'); }); }); /** Eine Signalgruppe ohne Bezug zum Lageplan - der unbelegte Fall. */ function leereGruppe(id: string, name: string): Project['signalGroups'][number] { return { ...createSignalGroup({ mode: 'kfz', name }), id, }; } /** * Vier Stroeme, deren Vertraeglichkeit einen Viererkreis bildet: A vertraegt * sich mit B und C, D mit B und C - aber A nicht mit D und B nicht mit C. * * Erst diese Form ergibt ZWEI verschiedene Zweiphaseneinteilungen ({A,B} + * {C,D} und {A,C} + {B,D}) und dazu dreiphasige. Ein Pruefstueck mit nur einer * moeglichen Einteilung koennte eine Reihung nicht pruefen: Eine Liste mit * einem Eintrag ist in jeder Reihenfolge sortiert. */ function vierStroeme(): Project { const erweitert: Lageplan = { ...lageplan(), linien: [ linie('a', 'A', [ { x: 0, y: 100 }, { x: 200, y: 100 }, ]), linie('b', 'B', [ { x: 0, y: 160 }, { x: 200, y: 160 }, ]), // Kreuzt B, nicht A. linie('c', 'C', [ { x: 40, y: 140 }, { x: 40, y: 260 }, ]), // Kreuzt A, nicht B. linie('d', 'D', [ { x: 100, y: 0 }, { x: 100, y: 130 }, ]), ], }; const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: erweitert }; const gruppen = uebernimmSignalgruppen( leer, leer.lageplan, leer.lageplan.linien.map((l) => l.id), ); const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; const ableitung = leiteAb(mitPlan, mitPlan.lageplan); return uebernimmWege( mitPlan, mitPlan.lageplan, ableitung, ableitung.wege.map((w) => wegSchluessel(w)), STICHTAG, ).project; } describe('Phaseneinteilung - gezaehlt wird, bevor aufgezaehlt wird', () => { it('bricht ab, wo zu viele Phasenmengen möglich sind', () => { /* * DAS ZWEITE RISIKO DES BEFUNDS: Der Suchraum haengt an der Zahl der * ERFASSTEN Konflikte, nicht an der Zahl der Gruppen - ausgerechnet das * schlecht gepflegte Projekt haette den groessten Suchraum und bekaeme die * meisten Vorschlaege. * * Gebaut ist hier der schlimmste Fall in klein: sieben Dreiergruppen, die * untereinander alle vertraeglich sind, innerhalb der Gruppe aber nicht. * Das ergibt 3^7 = 2187 maximale Mengen - weit ueber der Schranke. */ const ids = Array.from({ length: 21 }, (_, i) => `g${String(i)}`); const drittel = (id: string): number => Math.floor(Number(id.slice(1)) / 3); const ergebnis = maximaleMengen(ids, (a, b) => drittel(a) !== drittel(b)); expect(ergebnis.abgebrochen).toBe(true); expect(ergebnis.mengen.length).toBeLessThanOrEqual(MAX_PHASENMENGEN); }); it('zählt bei überschaubaren Verhältnissen zu Ende', () => { const ergebnis = maximaleMengen( ['a', 'b', 'c'], (x, y) => !(x === 'a' && y === 'b') && !(x === 'b' && y === 'a'), ); expect(ergebnis.abgebrochen).toBe(false); expect(ergebnis.mengen.map((m) => [...m].sort().join('+')).sort()).toEqual(['a+c', 'b+c']); }); }); describe('Phaseneinteilung - der Belegstand selbst', () => { it('unterscheidet drei Zustände und wirft keinen in den anderen', () => { const projekt = belegtesProjekt(); const arten = new Set(vertraeglichkeitsbelege(projekt).paare.map((p) => p.art)); expect(arten.has('feindlich')).toBe(true); expect(arten.has('belegt')).toBe(true); expect(arten.has('ohne-beleg')).toBe(false); }); it('lässt ohne auswertbaren Lageplan nichts als belegt gelten', () => { const projekt = belegtesProjekt(); const ohneMassstab: Project = { ...projekt, lageplan: { ...projekt.lageplan, kalibrierung: null }, }; const stand = vertraeglichkeitsbelege(ohneMassstab); expect(stand.paare.some((p) => p.art === 'belegt')).toBe(false); expect(stand.vollstaendig).toBe(false); }); });