lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain variantenvergleich.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 3 | import { legeProgrammAn } from '@/domain/model/programme'; |
| 4 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { vergleicheVarianten, type Vergleichsseite } from '@/domain/plan/vergleich'; |
| 6 | import type { Project } from '@/domain/model/project'; |
| 7 | |
| 8 | /** |
| 9 | * Fassung 5.34.0 - zwei Planfaelle nebeneinander. |
| 10 | * |
| 11 | * SEIN EIGENTLICHER WERT IST SICHERHEITSTECHNISCH: Wer einen Raeumweg oder |
| 12 | * einen Kennwert aendert, sieht heute nur den neuen Stand. Welche Zwischenzeit |
| 13 | * dabei KUERZER geworden ist, sagt ihm niemand - und das ist die gefaehrliche |
| 14 | * Richtung. Diese Faelle bewachen, dass der Vergleich sie zaehlt und benennt. |
| 15 | */ |
| 16 | |
| 17 | const STICHTAG = new Date('2026-01-01T12:00:00Z'); |
| 18 | |
| 19 | /* |
| 20 | * EIN Grundprojekt je Fall, und beide Seiten davon abgeleitet. |
| 21 | * |
| 22 | * `createStandardIntersectionProject` erzeugt bei jedem Aufruf FRISCHE |
| 23 | * Kennungen. Zwei Aufrufe ergaeben zwei Projekte ohne eine einzige gemeinsame |
| 24 | * Kennung - der Vergleich fiele auf die Zuordnung ueber den Namen zurueck, und |
| 25 | * ein Fall, der die Zuordnung ueber die Kennung pruefen will, prueft dann das |
| 26 | * Gegenteil. (Derselbe Fehler ist in 5.30.0 schon einmal unterlaufen.) |
| 27 | */ |
| 28 | function basis(): Project { |
| 29 | return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); |
| 30 | } |
| 31 | |
| 32 | function seite( |
| 33 | name: string, |
| 34 | project: Project, |
| 35 | mehr: Partial<Vergleichsseite> = {}, |
| 36 | ): Vergleichsseite { |
| 37 | return { name, project, plan: buildSignalPlan(project), ...mehr }; |
| 38 | } |
| 39 | |
| 40 | /** Derselbe Knotenpunkt, aber jeder Raeumweg um 5 m kuerzer. */ |
| 41 | function mitKuerzerenWegen(project: Project): Project { |
| 42 | return { |
| 43 | ...project, |
| 44 | conflicts: project.conflicts.map((c) => ({ |
| 45 | ...c, |
| 46 | clearingDistance: Math.max(0, c.clearingDistance - 5), |
| 47 | })), |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | describe('Variantenvergleich - die Verkürzungen werden gezählt', () => { |
| 52 | it('zählt jede Zwischenzeit, die rechts kürzer ist', () => { |
| 53 | /* |
| 54 | * DIE KERNZUSAGE. Der rechte Planfall hat kuerzere Raeumwege, also |
| 55 | * kuerzere Zwischenzeiten. Genau das muss die Zahl oben sagen - und zwar |
| 56 | * bevor jemand eine Tabelle durchsieht. |
| 57 | */ |
| 58 | const roh = basis(); |
| 59 | const vergleich = vergleicheVarianten( |
| 60 | seite('Bestand', roh), |
| 61 | seite('Entwurf', mitKuerzerenWegen(roh)), |
| 62 | ); |
| 63 | |
| 64 | expect(vergleich.verkuerzteZwischenzeiten).toBeGreaterThan(0); |
| 65 | expect(vergleich.verkuerzteZwischenzeiten).toBe( |
| 66 | vergleich.zwischenzeiten.filter((z) => z.zeit.richtung === 'kuerzer').length, |
| 67 | ); |
| 68 | for (const zeile of vergleich.zwischenzeiten) { |
| 69 | if (zeile.zeit.richtung !== 'kuerzer') continue; |
| 70 | expect(zeile.zeit.differenz, `${zeile.von} → ${zeile.nach}`).toBeLessThan(0); |
| 71 | } |
| 72 | }); |
| 73 | |
| 74 | it('nennt keine Verkürzung, wo beide Seiten gleich sind', () => { |
| 75 | const roh = basis(); |
| 76 | const vergleich = vergleicheVarianten(seite('links', roh), seite('rechts', roh)); |
| 77 | expect(vergleich.verkuerzteZwischenzeiten).toBe(0); |
| 78 | expect(vergleich.zwischenzeiten.every((z) => z.zeit.richtung === 'gleich')).toBe(true); |
| 79 | expect(vergleich.umlaufzeit.richtung).toBe('gleich'); |
| 80 | expect(vergleich.umlaufzeit.differenz).toBe(0); |
| 81 | }); |
| 82 | |
| 83 | it('rechnet die Differenz als rechts minus links', () => { |
| 84 | const roh = basis(); |
| 85 | const links = seite('kurz', { |
| 86 | ...roh, |
| 87 | program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, |
| 88 | }); |
| 89 | const rechts = seite('lang', { |
| 90 | ...roh, |
| 91 | program: { ...roh.program, method: 'manuell', manualCycleTime: 90 }, |
| 92 | }); |
| 93 | const vergleich = vergleicheVarianten(links, rechts); |
| 94 | expect(vergleich.umlaufzeit.links).toBe(60); |
| 95 | expect(vergleich.umlaufzeit.rechts).toBe(90); |
| 96 | expect(vergleich.umlaufzeit.differenz).toBe(30); |
| 97 | expect(vergleich.umlaufzeit.richtung).toBe('laenger'); |
| 98 | }); |
| 99 | |
| 100 | it('ändert an beiden Seiten nichts', () => { |
| 101 | // Der Vergleich ist eine Gegenueberstellung und kein Eingriff. Waere er |
| 102 | // einer, traege er eine Zahl aus einem fremden Zusammenhang in diese |
| 103 | // Planung - ohne dass ihre Wege dazu passen muessten. |
| 104 | const linkesProjekt = basis(); |
| 105 | const rechtesProjekt = mitKuerzerenWegen(linkesProjekt); |
| 106 | const vorher = JSON.stringify([linkesProjekt, rechtesProjekt]); |
| 107 | vergleicheVarianten(seite('a', linkesProjekt), seite('b', rechtesProjekt)); |
| 108 | expect(JSON.stringify([linkesProjekt, rechtesProjekt])).toBe(vorher); |
| 109 | }); |
| 110 | }); |
| 111 | |
| 112 | describe('Variantenvergleich - die Zuordnung', () => { |
| 113 | it('ordnet über die Kennung zu, wo es eine gemeinsame gibt', () => { |
| 114 | const roh = basis(); |
| 115 | const vergleich = vergleicheVarianten(seite('a', roh), seite('b', mitKuerzerenWegen(roh))); |
| 116 | expect(vergleich.gruppen.every((g) => g.art === 'kennung')).toBe(true); |
| 117 | expect(vergleich.vorbehalte.join(' ')).not.toContain('über ihren NAMEN'); |
| 118 | }); |
| 119 | |
| 120 | it('ordnet über den Namen zu, wo die Kennungen auseinandergehen – und sagt es', () => { |
| 121 | /* |
| 122 | * Zwei unabhaengig erfasste Projekte haben keine gemeinsame Kennung. Dann |
| 123 | * bleibt nur der Name, und der ist die schwaechere Auskunft: Er sagt |
| 124 | * nicht, dass es derselbe Strom ist. Deshalb steht die Art an der Zeile |
| 125 | * UND ein Vorbehalt darueber. |
| 126 | */ |
| 127 | const roh = basis(); |
| 128 | const umbenannt: Project = { |
| 129 | ...roh, |
| 130 | signalGroups: roh.signalGroups.map((g) => ({ ...g, id: `neu-${g.id}` })), |
| 131 | conflicts: roh.conflicts.map((c) => ({ |
| 132 | ...c, |
| 133 | id: `neu-${c.id}`, |
| 134 | fromId: `neu-${c.fromId}`, |
| 135 | toId: `neu-${c.toId}`, |
| 136 | })), |
| 137 | phases: roh.phases.map((p) => ({ |
| 138 | ...p, |
| 139 | signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`), |
| 140 | })), |
| 141 | demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })), |
| 142 | }; |
| 143 | const vergleich = vergleicheVarianten(seite('a', roh), seite('b', umbenannt)); |
| 144 | expect(vergleich.gruppen.every((g) => g.art === 'name')).toBe(true); |
| 145 | expect(vergleich.vorbehalte.join(' ')).toContain('über ihren NAMEN'); |
| 146 | }); |
| 147 | |
| 148 | it('ordnet einen doppelt vergebenen Namen gar nicht zu', () => { |
| 149 | // Lieber zwei Zeilen "nur links" und "nur rechts" als eine geratene |
| 150 | // Gegenueberstellung: Welcher der beiden gleichnamigen Ströme gemeint ist, |
| 151 | // steht nirgends. |
| 152 | const roh = basis(); |
| 153 | const doppelt: Project = { |
| 154 | ...roh, |
| 155 | signalGroups: roh.signalGroups.map((g, i) => ({ |
| 156 | ...g, |
| 157 | id: `neu-${g.id}`, |
| 158 | name: i < 2 ? 'K1' : g.name, |
| 159 | })), |
| 160 | conflicts: roh.conflicts.map((c) => ({ |
| 161 | ...c, |
| 162 | id: `neu-${c.id}`, |
| 163 | fromId: `neu-${c.fromId}`, |
| 164 | toId: `neu-${c.toId}`, |
| 165 | })), |
| 166 | phases: roh.phases.map((p) => ({ |
| 167 | ...p, |
| 168 | signalGroupIds: p.signalGroupIds.map((id) => `neu-${id}`), |
| 169 | })), |
| 170 | demands: roh.demands.map((d) => ({ ...d, signalGroupId: `neu-${d.signalGroupId}` })), |
| 171 | }; |
| 172 | const mitDoppel: Project = { |
| 173 | ...roh, |
| 174 | signalGroups: roh.signalGroups.map((g, i) => ({ ...g, name: i < 2 ? 'K1' : g.name })), |
| 175 | }; |
| 176 | const vergleich = vergleicheVarianten(seite('a', mitDoppel), seite('b', doppelt)); |
| 177 | const k1 = vergleich.gruppen.filter((g) => g.name === 'K1'); |
| 178 | expect(k1).toHaveLength(4); |
| 179 | expect(k1.filter((g) => g.art === 'nur-links')).toHaveLength(2); |
| 180 | expect(k1.filter((g) => g.art === 'nur-rechts')).toHaveLength(2); |
| 181 | expect(vergleich.unpaarig).toBeGreaterThan(0); |
| 182 | expect(vergleich.vorbehalte.join(' ')).toContain('nur auf einer Seite'); |
| 183 | }); |
| 184 | |
| 185 | it('führt eine Signalgruppe, die es nur auf einer Seite gibt, mit einem Strich', () => { |
| 186 | const roh = basis(); |
| 187 | const ohneLetzte: Project = { ...roh, signalGroups: roh.signalGroups.slice(0, -1) }; |
| 188 | const vergleich = vergleicheVarianten(seite('voll', roh), seite('knapp', ohneLetzte)); |
| 189 | const fehlende = vergleich.gruppen.filter((g) => g.art === 'nur-links'); |
| 190 | expect(fehlende).toHaveLength(1); |
| 191 | expect(fehlende[0]?.freigabezeit.rechts).toBeNull(); |
| 192 | expect(fehlende[0]?.freigabezeit.differenz).toBeNull(); |
| 193 | expect(fehlende[0]?.freigabezeit.richtung).toBe('unbestimmt'); |
| 194 | }); |
| 195 | }); |
| 196 | |
| 197 | describe('Variantenvergleich - die Vorbehalte', () => { |
| 198 | it('warnt, wo eine Seite aus einer älteren Fassung hochgezogen wurde', () => { |
| 199 | /* |
| 200 | * DER FALLSTRICK DES BEFUNDS. Beim Einlesen eines aelteren Projekts werden |
| 201 | * die Zwischenzeiten NEU GERECHNET. Der Vergleich zeigt dann Unterschiede, |
| 202 | * die von der Umstellung kommen und nicht von der Planung - wer das nicht |
| 203 | * liest, haelt sie fuer eine Planungsentscheidung. |
| 204 | */ |
| 205 | const roh = basis(); |
| 206 | const vergleich = vergleicheVarianten( |
| 207 | seite('Altprojekt', roh, { migriert: true, herkunftsfassung: '4.3.3' }), |
| 208 | seite('heute', roh), |
| 209 | ); |
| 210 | const satz = vergleich.vorbehalte.join(' '); |
| 211 | expect(satz).toContain('NEU GERECHNET'); |
| 212 | expect(satz).toContain('4.3.3'); |
| 213 | }); |
| 214 | |
| 215 | it('sagt, dass nur das geöffnete Tagesprogramm verglichen wird', () => { |
| 216 | // Die uebersehene Abhaengigkeit aus dem Befund: Seit Fassung 5.33.0 traegt ein |
| 217 | // Projekt mehrere Programme, und dann ist nicht mehr von selbst bestimmt, |
| 218 | // was verglichen wird. |
| 219 | const roh = basis(); |
| 220 | const vergleich = vergleicheVarianten( |
| 221 | seite('a', legeProgrammAn(roh, 'Nacht')), |
| 222 | seite('b', roh), |
| 223 | ); |
| 224 | expect(vergleich.vorbehalte.join(' ')).toContain('2 Tagesprogramme'); |
| 225 | expect(vergleich.vorbehalte.join(' ')).toContain('Verglichen wird nur das geöffnete'); |
| 226 | expect(vergleich.programme.links).toBe('Nacht'); |
| 227 | expect(vergleich.programme.rechts).toBe('Programm 1'); |
| 228 | }); |
| 229 | |
| 230 | it('schweigt, wo es nichts einzuschränken gibt', () => { |
| 231 | const roh = basis(); |
| 232 | const vergleich = vergleicheVarianten(seite('a', roh), seite('b', roh)); |
| 233 | expect(vergleich.vorbehalte).toEqual([]); |
| 234 | }); |
| 235 | }); |