import { describe, expect, it } from 'vitest'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { legeProgrammAn } from '@/domain/model/programme'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { vergleicheVarianten, type Vergleichsseite } from '@/domain/plan/vergleich'; import type { Project } from '@/domain/model/project'; /** * Fassung 5.34.0 - zwei Planfaelle nebeneinander. * * SEIN EIGENTLICHER WERT IST SICHERHEITSTECHNISCH: Wer einen Raeumweg oder * einen Kennwert aendert, sieht heute nur den neuen Stand. Welche Zwischenzeit * dabei KUERZER geworden ist, sagt ihm niemand - und das ist die gefaehrliche * Richtung. Diese Faelle bewachen, dass der Vergleich sie zaehlt und benennt. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); /* * EIN Grundprojekt je Fall, und beide Seiten davon abgeleitet. * * `createStandardIntersectionProject` erzeugt bei jedem Aufruf FRISCHE * Kennungen. Zwei Aufrufe ergaeben zwei Projekte ohne eine einzige gemeinsame * Kennung - der Vergleich fiele auf die Zuordnung ueber den Namen zurueck, und * ein Fall, der die Zuordnung ueber die Kennung pruefen will, prueft dann das * Gegenteil. (Derselbe Fehler ist in 5.30.0 schon einmal unterlaufen.) */ function basis(): Project { return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); } function seite( name: string, project: Project, mehr: Partial = {}, ): Vergleichsseite { return { name, project, plan: buildSignalPlan(project), ...mehr }; } /** Derselbe Knotenpunkt, aber jeder Raeumweg um 5 m kuerzer. */ function mitKuerzerenWegen(project: Project): Project { return { ...project, conflicts: project.conflicts.map((c) => ({ ...c, clearingDistance: Math.max(0, c.clearingDistance - 5), })), }; } describe('Variantenvergleich - die Verkürzungen werden gezählt', () => { it('zählt jede Zwischenzeit, die rechts kürzer ist', () => { /* * DIE KERNZUSAGE. Der rechte Planfall hat kuerzere Raeumwege, also * kuerzere Zwischenzeiten. Genau das muss die Zahl oben sagen - und zwar * bevor jemand eine Tabelle durchsieht. */ const roh = basis(); const vergleich = vergleicheVarianten( seite('Bestand', roh), seite('Entwurf', mitKuerzerenWegen(roh)), ); expect(vergleich.verkuerzteZwischenzeiten).toBeGreaterThan(0); expect(vergleich.verkuerzteZwischenzeiten).toBe( vergleich.zwischenzeiten.filter((z) => z.zeit.richtung === 'kuerzer').length, ); for (const zeile of vergleich.zwischenzeiten) { if (zeile.zeit.richtung !== 'kuerzer') continue; expect(zeile.zeit.differenz, `${zeile.von} → ${zeile.nach}`).toBeLessThan(0); } }); it('nennt keine Verkürzung, wo beide Seiten gleich sind', () => { const roh = basis(); const vergleich = vergleicheVarianten(seite('links', roh), seite('rechts', roh)); expect(vergleich.verkuerzteZwischenzeiten).toBe(0); expect(vergleich.zwischenzeiten.every((z) => z.zeit.richtung === 'gleich')).toBe(true); expect(vergleich.umlaufzeit.richtung).toBe('gleich'); expect(vergleich.umlaufzeit.differenz).toBe(0); }); it('rechnet die Differenz als rechts minus links', () => { const roh = basis(); const links = seite('kurz', { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, }); const rechts = seite('lang', { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 90 }, }); const vergleich = vergleicheVarianten(links, rechts); expect(vergleich.umlaufzeit.links).toBe(60); expect(vergleich.umlaufzeit.rechts).toBe(90); expect(vergleich.umlaufzeit.differenz).toBe(30); expect(vergleich.umlaufzeit.richtung).toBe('laenger'); }); it('ändert an beiden Seiten nichts', () => { // Der Vergleich ist eine Gegenueberstellung und kein Eingriff. Waere er // einer, traege er eine Zahl aus einem fremden Zusammenhang in diese // Planung - ohne dass ihre Wege dazu passen muessten. const linkesProjekt = basis(); const rechtesProjekt = mitKuerzerenWegen(linkesProjekt); const vorher = JSON.stringify([linkesProjekt, rechtesProjekt]); vergleicheVarianten(seite('a', linkesProjekt), seite('b', rechtesProjekt)); expect(JSON.stringify([linkesProjekt, rechtesProjekt])).toBe(vorher); }); }); describe('Variantenvergleich - die Zuordnung', () => { it('ordnet über die Kennung zu, wo es eine gemeinsame gibt', () => { const roh = basis(); const vergleich = vergleicheVarianten(seite('a', roh), seite('b', mitKuerzerenWegen(roh))); expect(vergleich.gruppen.every((g) => g.art === 'kennung')).toBe(true); expect(vergleich.vorbehalte.join(' ')).not.toContain('über ihren NAMEN'); }); it('ordnet über den Namen zu, wo die Kennungen auseinandergehen – und sagt es', () => { /* * Zwei unabhaengig erfasste Projekte haben keine gemeinsame Kennung. Dann * bleibt nur der Name, und der ist die schwaechere Auskunft: Er sagt * nicht, dass es derselbe Strom ist. Deshalb steht die Art an der Zeile * UND ein Vorbehalt darueber. */ const roh = basis(); const umbenannt: Project = { ...roh, signalGroups: roh.signalGroups.map((g) => ({ ...g, id: `neu-${g.id}` })), conflicts: roh.conflicts.map((c) => ({ ...c, id: `neu-${c.id}`, fromId: `neu-${c.fromId}`, toId: `neu-${c.toId}`, })), phases: roh.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`), })), demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })), }; const vergleich = vergleicheVarianten(seite('a', roh), seite('b', umbenannt)); expect(vergleich.gruppen.every((g) => g.art === 'name')).toBe(true); expect(vergleich.vorbehalte.join(' ')).toContain('über ihren NAMEN'); }); it('ordnet einen doppelt vergebenen Namen gar nicht zu', () => { // Lieber zwei Zeilen "nur links" und "nur rechts" als eine geratene // Gegenueberstellung: Welcher der beiden gleichnamigen Ströme gemeint ist, // steht nirgends. const roh = basis(); const doppelt: Project = { ...roh, signalGroups: roh.signalGroups.map((g, i) => ({ ...g, id: `neu-${g.id}`, name: i < 2 ? 'K1' : g.name, })), conflicts: roh.conflicts.map((c) => ({ ...c, id: `neu-${c.id}`, fromId: `neu-${c.fromId}`, toId: `neu-${c.toId}`, })), phases: roh.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`), })), demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })), }; const mitDoppel: Project = { ...roh, signalGroups: roh.signalGroups.map((g, i) => ({ ...g, name: i < 2 ? 'K1' : g.name })), }; const vergleich = vergleicheVarianten(seite('a', mitDoppel), seite('b', doppelt)); const k1 = vergleich.gruppen.filter((g) => g.name === 'K1'); expect(k1).toHaveLength(4); expect(k1.filter((g) => g.art === 'nur-links')).toHaveLength(2); expect(k1.filter((g) => g.art === 'nur-rechts')).toHaveLength(2); expect(vergleich.unpaarig).toBeGreaterThan(0); expect(vergleich.vorbehalte.join(' ')).toContain('nur auf einer Seite'); }); it('führt eine Signalgruppe, die es nur auf einer Seite gibt, mit einem Strich', () => { const roh = basis(); const ohneLetzte: Project = { ...roh, signalGroups: roh.signalGroups.slice(0, -1) }; const vergleich = vergleicheVarianten(seite('voll', roh), seite('knapp', ohneLetzte)); const fehlende = vergleich.gruppen.filter((g) => g.art === 'nur-links'); expect(fehlende).toHaveLength(1); expect(fehlende[0]?.freigabezeit.rechts).toBeNull(); expect(fehlende[0]?.freigabezeit.differenz).toBeNull(); expect(fehlende[0]?.freigabezeit.richtung).toBe('unbestimmt'); }); }); describe('Variantenvergleich - die Vorbehalte', () => { it('warnt, wo eine Seite aus einer älteren Fassung hochgezogen wurde', () => { /* * DER FALLSTRICK DES BEFUNDS. Beim Einlesen eines aelteren Projekts werden * die Zwischenzeiten NEU GERECHNET. Der Vergleich zeigt dann Unterschiede, * die von der Umstellung kommen und nicht von der Planung - wer das nicht * liest, haelt sie fuer eine Planungsentscheidung. */ const roh = basis(); const vergleich = vergleicheVarianten( seite('Altprojekt', roh, { migriert: true, herkunftsfassung: '4.3.3' }), seite('heute', roh), ); const satz = vergleich.vorbehalte.join(' '); expect(satz).toContain('NEU GERECHNET'); expect(satz).toContain('4.3.3'); }); it('sagt, dass nur das geöffnete Tagesprogramm verglichen wird', () => { // Die uebersehene Abhaengigkeit aus dem Befund: Seit Fassung 5.33.0 traegt ein // Projekt mehrere Programme, und dann ist nicht mehr von selbst bestimmt, // was verglichen wird. const roh = basis(); const vergleich = vergleicheVarianten( seite('a', legeProgrammAn(roh, 'Nacht')), seite('b', roh), ); expect(vergleich.vorbehalte.join(' ')).toContain('2 Tagesprogramme'); expect(vergleich.vorbehalte.join(' ')).toContain('Verglichen wird nur das geöffnete'); expect(vergleich.programme.links).toBe('Nacht'); expect(vergleich.programme.rechts).toBe('Programm 1'); }); it('schweigt, wo es nichts einzuschränken gibt', () => { const roh = basis(); const vergleich = vergleicheVarianten(seite('a', roh), seite('b', roh)); expect(vergleich.vorbehalte).toEqual([]); }); });