import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ERSTES_PROGRAMM, PROGRAMM_HOECHSTZAHL, aktiverProgrammsatz, legeProgrammAn, loescheProgramm, programmanzahl, programmfolge, programmplatz, wechsleProgramm, } from '@/domain/model/programme'; import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; import { loadProject } from '@/domain/model/migrate'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { programmPruefungen, validateProject } from '@/domain/validation'; /** * Fassung 5.33.0 - mehrere Tagesprogramme in einem Projekt. * * DIE EINE SICHERHEITSRELEVANTE BEDINGUNG DES BEFUNDS: Pruefung und * Ausgabesperre laufen ueber ALLE Programme und nicht ueber das sichtbare. * Sonst geht ein Nachtprogramm mit unterschrittener Mindestfreigabezeit * ungeprueft in die Anordnungsunterlage. Sie steht in diesem Fall ganz unten * und ist der Grund, aus dem alles Uebrige gebaut wurde. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); function basis(): Project { return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); } /** Ein Programm, dessen Umlaufzeit die Schranke des Knotenpunkts reisst. */ function mitFehler(project: Project): Project { return { ...project, program: { ...project.program, method: 'manuell', manualCycleTime: 300 }, }; } describe('Programmbibliothek - das Umschalten traegt die Daten', () => { it('hält die Verkehrsstärken jedes Programms auseinander', () => { /* * DIE KERNZUSAGE. Fruehspitze und Nacht beschreiben denselben Knotenpunkt * mit verschiedenen Zaehlungen. Liefe eine Zahl in das andere Programm * ueber, waere die Bemessung der Nacht die der Spitze - und die * Freigabezeiten fielen fuer die falsche Stunde aus. */ const tag = basis(); const k1 = tag.signalGroups[0]!.id; const mitTag = actions.setDemand(tag, k1, { volume: 900 }); const nacht = legeProgrammAn(mitTag, 'Nacht'); expect(nacht.demands, 'ein neues Programm beginnt ohne Zählung').toEqual([]); const mitNacht = actions.setDemand(nacht, k1, { volume: 120 }); const zurueck = wechsleProgramm(mitNacht, ERSTES_PROGRAMM); expect(zurueck.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(900); const wiederNacht = wechsleProgramm(zurueck, mitNacht.program.id); expect(wiederNacht.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(120); }); it('hält die festen Freigabezeiten jedes Programms auseinander', () => { const tag = basis(); const phase = tag.phases[0]!.id; const mitTag = actions.updatePhase(tag, phase, { manualGreen: 42 }); const nacht = legeProgrammAn(mitTag, 'Nacht'); expect(nacht.phases.find((p) => p.id === phase)?.manualGreen).toBeNull(); const mitNacht = actions.updatePhase(nacht, phase, { manualGreen: 12 }); const zurueck = wechsleProgramm(mitNacht, ERSTES_PROGRAMM); expect(zurueck.phases.find((p) => p.id === phase)?.manualGreen).toBe(42); expect( wechsleProgramm(zurueck, mitNacht.program.id).phases.find((p) => p.id === phase)?.manualGreen, ).toBe(12); }); it('führt keine Angabe zweimal', () => { /* * Die Form dieses Modells - aktives Programm oben, uebrige in der * Bibliothek - traegt nur, solange sich die beiden Seiten nicht * ueberschneiden. Stuende das aktive Programm auch in der Bibliothek, gaebe * es zwei Staende derselben Sache, und einer davon wuerde beim naechsten * Umschalten der andere. */ let projekt = basis(); projekt = legeProgrammAn(projekt, 'Nacht'); projekt = legeProgrammAn(projekt, 'Frühspitze'); for (const eintrag of programmfolge(projekt)) { const gewechselt = wechsleProgramm(projekt, eintrag.satz.programm.id); expect( gewechselt.programmbibliothek.map((s) => s.programm.id), `nach dem Wechsel auf ${eintrag.satz.programm.name}`, ).not.toContain(gewechselt.program.id); expect(programmanzahl(gewechselt)).toBe(3); } }); it('lässt die Reihenfolge stehen', () => { // Ohne festgehaltenen Platz wanderte das bearbeitete Programm bei jedem // Umschalten an den Anfang, und die Auswahlliste sortierte sich unter der // Hand um - fuer den Bearbeiter sieht das aus, als habe er etwas // verschoben. let projekt = basis(); projekt = legeProgrammAn(projekt, 'Nacht'); projekt = legeProgrammAn(projekt, 'Frühspitze'); const namen = programmfolge(projekt).map((e) => e.satz.programm.name); expect(namen).toEqual(['Programm 1', 'Nacht', 'Frühspitze']); for (const eintrag of programmfolge(projekt)) { const gewechselt = wechsleProgramm(projekt, eintrag.satz.programm.id); expect( programmfolge(gewechselt).map((e) => e.satz.programm.name), `nach dem Wechsel auf ${eintrag.satz.programm.name}`, ).toEqual(namen); } }); it('übernimmt Einstellungen, aber keine Zählungen', () => { const roh = basis(); const tag: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 80, offset: 17 }, }; const nacht = legeProgrammAn(tag, 'Nacht'); expect(nacht.program.method).toBe('manuell'); expect(nacht.program.manualCycleTime).toBe(80); expect(nacht.program.offset).toBe(17); expect(nacht.program.phaseOrder).toEqual(tag.program.phaseOrder); expect(nacht.demands).toEqual([]); expect(aktiverProgrammsatz(nacht).festeFreigabezeiten).toEqual([]); }); it('bleibt bei unbekannter Kennung und beim letzten Programm stehen', () => { const projekt = basis(); expect(wechsleProgramm(projekt, 'gibt-es-nicht')).toBe(projekt); expect(wechsleProgramm(projekt, projekt.program.id)).toBe(projekt); // Ein Projekt ohne Programm hat keinen Signalzeitenplan. expect(loescheProgramm(projekt, projekt.program.id)).toBe(projekt); }); it('lässt beim Löschen den Nachbarn eintreten', () => { let projekt = basis(); projekt = legeProgrammAn(projekt, 'Nacht'); projekt = legeProgrammAn(projekt, 'Frühspitze'); const nachtId = programmfolge(projekt)[1]!.satz.programm.id; // Das aktive Programm loeschen: der Nachbar tritt an seine Stelle. const ohneAktives = loescheProgramm(projekt, projekt.program.id); expect(programmanzahl(ohneAktives)).toBe(2); expect(ohneAktives.program.id).toBe(nachtId); // Ein eingelagertes loeschen: das aktive bleibt aktiv. const ohneNacht = loescheProgramm(projekt, nachtId); expect(ohneNacht.program.id).toBe(projekt.program.id); expect(programmfolge(ohneNacht).map((e) => e.satz.programm.name)).toEqual([ 'Programm 1', 'Frühspitze', ]); }); it('nimmt nicht mehr als die Höchstzahl auf', () => { let projekt = basis(); for (let i = 2; i <= PROGRAMM_HOECHSTZAHL; i += 1) { projekt = legeProgrammAn(projekt, `Programm ${String(i)}`); } expect(programmanzahl(projekt)).toBe(PROGRAMM_HOECHSTZAHL); expect(legeProgrammAn(projekt, 'eins zu viel')).toBe(projekt); }); it('klemmt einen unmöglichen Platz, statt ein Loch zu lassen', () => { const projekt = legeProgrammAn(basis(), 'Nacht'); for (const platz of [-3, 99, Number.NaN, 1.7]) { const verbogen: Project = { ...projekt, programmplatz: platz }; expect(programmplatz(verbogen), String(platz)).toBeGreaterThanOrEqual(0); expect(programmplatz(verbogen), String(platz)).toBeLessThanOrEqual(1); expect(programmfolge(verbogen), String(platz)).toHaveLength(2); } }); }); describe('Programmbibliothek - die Aktionen der Oberfläche', () => { it('reichen an den Fachkern durch, ohne etwas hinzuzutun', () => { /* * Die drei Aktionen sind Durchreichen und sollen es bleiben: Rechnen * geschieht in `domain/`, und eine Aktion, die unterwegs etwas anderes * tut als die Fachfunktion, waere eine zweite Fassung derselben Regel. * Verglichen wird deshalb Ergebnis gegen Ergebnis. */ const roh = basis(); // Beim Anlegen kann nur der Inhalt verglichen werden: Die Kennung des neuen // Programms wird erzeugt und faellt bei jedem Aufruf anders aus. const ueberAktion = actions.programmAnlegen(roh, 'Nacht'); const ueberFachkern = legeProgrammAn(roh, 'Nacht'); expect({ ...ueberAktion, program: { ...ueberAktion.program, id: '' } }).toEqual({ ...ueberFachkern, program: { ...ueberFachkern.program, id: '' }, }); const zwei = legeProgrammAn(roh, 'Nacht'); expect(actions.programmWechseln(zwei, ERSTES_PROGRAMM)).toEqual( wechsleProgramm(zwei, ERSTES_PROGRAMM), ); expect(actions.programmEntfernen(zwei, zwei.program.id)).toEqual( loescheProgramm(zwei, zwei.program.id), ); expect(programmanzahl(actions.programmEntfernen(zwei, zwei.program.id))).toBe(1); }); }); describe('Programmbibliothek - was allen Programmen gehört', () => { it('hängt eine neue Phase an jede Programmreihenfolge', () => { // Eine Phase, die in einem Programm nicht vorkommt, bekommt dort nie // Freigabe. Das darf nicht davon abhaengen, welches Programm beim Anlegen // gerade sichtbar war. let projekt = legeProgrammAn(basis(), 'Nacht'); projekt = actions.addPhase(projekt); const neue = projekt.phases[projekt.phases.length - 1]!.id; for (const eintrag of programmfolge(projekt)) { expect(eintrag.satz.programm.phaseOrder, eintrag.satz.programm.name).toContain(neue); } }); it('entfernt eine gelöschte Phase aus jedem Programm', () => { let projekt = basis(); const phase = projekt.phases[0]!.id; projekt = actions.updatePhase(projekt, phase, { manualGreen: 33 }); projekt = legeProgrammAn(projekt, 'Nacht'); projekt = actions.updatePhase(projekt, phase, { manualGreen: 11 }); projekt = actions.removePhase(projekt, phase); for (const eintrag of programmfolge(projekt)) { expect(eintrag.satz.programm.phaseOrder, eintrag.satz.programm.name).not.toContain(phase); expect( eintrag.satz.festeFreigabezeiten.map((f) => f.phaseId), eintrag.satz.programm.name, ).not.toContain(phase); } expect(projekt.phases.some((p) => p.id === phase)).toBe(false); }); it('räumt eine gelöschte Signalgruppe aus jedem Programm', () => { /* * Sonst fuehrt das Nachtprogramm eine Verkehrsstaerke fuer einen Strom, den * es nicht mehr gibt. Beim naechsten Umschalten stuende sie wieder im * Projekt - genau der Fehler, an dem der Altbestand mit seiner ueber * Feldindizes gefuehrten Konfliktmatrix scheiterte. */ let projekt = basis(); const k1 = projekt.signalGroups[0]!.id; projekt = legeProgrammAn(projekt, 'Nacht'); projekt = actions.setDemand(projekt, k1, { volume: 150 }); projekt = wechsleProgramm(projekt, ERSTES_PROGRAMM); projekt = actions.removeSignalGroup(projekt, k1); for (const eintrag of programmfolge(projekt)) { expect( eintrag.satz.demands.map((d) => d.signalGroupId), eintrag.satz.programm.name, ).not.toContain(k1); } }); }); describe('Programmbibliothek - die Prüfung läuft über alle Programme', () => { it('sperrt die Ausgabe wegen eines Fehlers in einem nicht angezeigten Programm', () => { /* * DER FALL, UM DEN ES GEHT. Das sichtbare Programm ist in Ordnung, das * eingelagerte nicht. Wer nur das sichtbare prueft, gibt eine * Anordnungsunterlage aus, in der ein Programm steckt, das niemand geprueft * hat. Eine Anordnung nach § 45 StVO gilt der ANLAGE, nicht einer * Tageszeit. */ const sauber = basis(); const sauberBericht = validateProject(sauber, buildSignalPlan(sauber), STICHTAG); expect(sauberBericht.errorCount, 'Vorbedingung: das Grundprojekt ist fehlerfrei').toBe(0); expect(sauberBericht.exportBlocked).toBe(false); // Das Nachtprogramm bekommt den Fehler, danach zurueck auf das saubere. const mitNacht = wechsleProgramm(mitFehler(legeProgrammAn(sauber, 'Nacht')), sauber.program.id); expect(mitNacht.program.id, 'sichtbar ist das fehlerfreie Programm').toBe(sauber.program.id); const bericht = validateProject(mitNacht, buildSignalPlan(mitNacht), STICHTAG); expect(bericht.exportBlocked, 'die Ausgabe ist gesperrt').toBe(true); const befund = bericht.findings.find((f) => f.rule === 'programm.fehler-in-anderem-programm'); expect(befund?.severity).toBe('fehler'); expect(befund?.message).toContain('Nacht'); }); it('meldet Warnungen eines anderen Programms als Warnung, nicht als Fehler', () => { const sauber = basis(); // Ein Programm ohne Verkehrsstaerken: der Plan steht, aber die Bemessung // fehlt - das ist eine Warnung und keine Sperre. const mitLeerem = wechsleProgramm(legeProgrammAn(sauber, 'Nacht'), sauber.program.id); const bericht = validateProject(mitLeerem, buildSignalPlan(mitLeerem), STICHTAG); expect(bericht.findings.some((f) => f.rule === 'programm.fehler-in-anderem-programm')).toBe( false, ); const warnung = bericht.findings.find((f) => f.rule === 'programm.warnung-in-anderem-programm'); expect(warnung?.severity).toBe('warnung'); expect(bericht.exportBlocked).toBe(false); }); it('zählt keinen Fehler doppelt und prüft nicht im Kreis', () => { /* * Ohne den Schalter `ohneProgrammpruefung` pruefte die Pruefung jedes * Programms alle uebrigen mit: bei vier Programmen sechzehn Plaene, und * jeder Fehler stuende viermal im Bericht. Die Spaltensumme der Uebersicht * waere dann groesser als die Zahl der Fehler. */ const mitNacht = wechsleProgramm(mitFehler(legeProgrammAn(basis(), 'Nacht')), ERSTES_PROGRAMM); const pruefungen = programmPruefungen(mitNacht, STICHTAG); expect(pruefungen).toHaveLength(2); expect(pruefungen[0]?.aktiv).toBe(true); expect(pruefungen[0]?.errorCount, 'das sichtbare Programm ist fehlerfrei').toBe(0); expect(pruefungen[1]?.name).toBe('Nacht'); expect(pruefungen[1]?.errorCount).toBeGreaterThan(0); expect(pruefungen[1]?.aktiv).toBe(false); }); it('rechnet jedem Programm seine eigene Umlaufzeit', () => { const roh = basis(); let projekt: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, }; projekt = legeProgrammAn(projekt, 'Nacht'); projekt = { ...projekt, program: { ...projekt.program, method: 'manuell', manualCycleTime: 90 }, }; const pruefungen = programmPruefungen(projekt, STICHTAG); expect(pruefungen.map((p) => p.umlaufzeit)).toEqual([60, 90]); expect(pruefungen.map((p) => p.verfahren)).toEqual(['manuell', 'manuell']); }); }); describe('Programmbibliothek - in der Datei', () => { it('trägt die Programme über Speichern und Laden', () => { let projekt = basis(); const k1 = projekt.signalGroups[0]!.id; const phase = projekt.phases[0]!.id; projekt = legeProgrammAn(projekt, 'Nacht'); projekt = actions.setDemand(projekt, k1, { volume: 130 }); projekt = actions.updatePhase(projekt, phase, { manualGreen: 9 }); projekt = wechsleProgramm(projekt, ERSTES_PROGRAMM); const geladen = loadProject(JSON.parse(JSON.stringify(projekt)) as unknown, STICHTAG); expect(geladen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); expect(programmanzahl(geladen.project)).toBe(2); const nacht = geladen.project.programmbibliothek[0]; expect(nacht?.programm.name).toBe('Nacht'); expect(nacht?.demands.find((d) => d.signalGroupId === k1)?.volume).toBe(130); expect(nacht?.festeFreigabezeiten).toEqual([{ phaseId: phase, sekunden: 9 }]); expect(geladen.issues.filter((i) => i.path.startsWith('programm'))).toEqual([]); }); it('macht aus einer Datei ohne Bibliothek genau ein Programm', () => { // Eine Datei bis Schema 15 kennt weder Kennung noch Bibliothek. Sie muss // ohne Beanstandung als Einprogramm-Projekt ankommen - alles andere waere // ein Vorwurf an eine Datei, die nichts falsch macht. const roh = JSON.parse(JSON.stringify(basis())) as Record; roh['schemaVersion'] = 15; delete roh['programmbibliothek']; delete roh['programmplatz']; delete (roh['program'] as Record)['id']; const geladen = loadProject(roh, STICHTAG); expect(geladen.project.program.id).toBe(ERSTES_PROGRAMM); expect(geladen.project.programmbibliothek).toEqual([]); expect(geladen.project.programmplatz).toBe(0); expect(geladen.issues.filter((i) => i.severity !== 'hinweis')).toEqual([]); }); it('ersetzt eine doppelt vergebene Programmkennung, statt das Programm zu verwerfen', () => { /* * Zwei gleiche Kennungen liessen die Reihenfolge entscheiden, welches * Programm gemeint ist - der Bearbeiter saehe die Verkehrsstaerken eines * anderen. Ein ganzes Tagesprogramm stillschweigend fallen zu lassen waere * aber der groessere Verlust; deshalb wird umbenannt und gemeldet. */ const roh = JSON.parse(JSON.stringify(legeProgrammAn(basis(), 'Nacht'))) as Record< string, unknown >; const bibliothek = roh['programmbibliothek'] as Record[]; const aktiveKennung = (roh['program'] as Record)['id']; (bibliothek[0]!['programm'] as Record)['id'] = aktiveKennung; const geladen = loadProject(roh, STICHTAG); expect(programmanzahl(geladen.project)).toBe(2); const kennungen = programmfolge(geladen.project).map((e) => e.satz.programm.id); expect(new Set(kennungen).size, 'jede Kennung genau einmal').toBe(2); expect(geladen.issues.some((i) => i.message.includes('mehrfach vergeben'))).toBe(true); }); });