lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui phaseneinteilungAnsicht.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { |
| 4 | leiteAb, |
| 5 | uebernimmSignalgruppen, |
| 6 | uebernimmWege, |
| 7 | wegSchluessel, |
| 8 | } from '@/domain/geometrie/ableitung'; |
| 9 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 10 | import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; |
| 11 | import { phasesView, vergessEinteilungssuche } from '@/ui/views/phasesView'; |
| 12 | import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 13 | import type { Project } from '@/domain/model/project'; |
| 14 | |
| 15 | /** |
| 16 | * Die Karte "Zulaessige Phaseneinteilungen". |
| 17 | * |
| 18 | * SIE ZEIGT ENTWEDER VORSCHLAEGE ODER DEN GRUND, WARUM ES KEINE GIBT. Ein |
| 19 | * leeres Feld waere hier das Schlimmste: Der Bearbeiter hielte es fuer "es gibt |
| 20 | * keine zulaessige Einteilung", und die Wahrheit ist meistens "die |
| 21 | * Vertraeglichkeit ist nicht belegt". |
| 22 | */ |
| 23 | |
| 24 | const STICHTAG = new Date('2026-01-01T00:00:00Z'); |
| 25 | |
| 26 | function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie { |
| 27 | return { |
| 28 | id, |
| 29 | name, |
| 30 | mode: 'kfz', |
| 31 | movement: 'geradeaus', |
| 32 | breiteMeter: REGELBREITE.kfz, |
| 33 | punkte, |
| 34 | haltlinieId: null, |
| 35 | startT: 0.5, |
| 36 | signalGroupId: null, |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | function lageplan(): Lageplan { |
| 41 | return { |
| 42 | arbeitsbereiche: [], |
| 43 | signalgeber: [], |
| 44 | haltlinien: [], |
| 45 | bild: { |
| 46 | datenUrl: 'data:image/png;base64,AAAA', |
| 47 | breite: 400, |
| 48 | hoehe: 400, |
| 49 | herkunft: 'Prüfstück', |
| 50 | geladenAm: STICHTAG.toISOString(), |
| 51 | }, |
| 52 | kalibrierung: { |
| 53 | von: { x: 0, y: 0 }, |
| 54 | bis: { x: 100, y: 0 }, |
| 55 | laengeMeter: 50, |
| 56 | herkunft: 'gemessen', |
| 57 | }, |
| 58 | linien: [ |
| 59 | linie('l-w', 'K1', [ |
| 60 | { x: 0, y: 100 }, |
| 61 | { x: 200, y: 100 }, |
| 62 | ]), |
| 63 | linie('l-s', 'K2', [ |
| 64 | { x: 100, y: 0 }, |
| 65 | { x: 100, y: 200 }, |
| 66 | ]), |
| 67 | linie('l-p', 'K3', [ |
| 68 | { x: 0, y: 160 }, |
| 69 | { x: 200, y: 160 }, |
| 70 | ]), |
| 71 | ], |
| 72 | }; |
| 73 | } |
| 74 | |
| 75 | /** Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt. */ |
| 76 | function belegtesProjekt(): Project { |
| 77 | const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() }; |
| 78 | const gruppen = uebernimmSignalgruppen( |
| 79 | leer, |
| 80 | leer.lageplan, |
| 81 | leer.lageplan.linien.map((l) => l.id), |
| 82 | ); |
| 83 | const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; |
| 84 | const ableitung = leiteAb(mitPlan, mitPlan.lageplan); |
| 85 | return uebernimmWege( |
| 86 | mitPlan, |
| 87 | mitPlan.lageplan, |
| 88 | ableitung, |
| 89 | ableitung.wege.map((w) => wegSchluessel(w)), |
| 90 | STICHTAG, |
| 91 | ).project; |
| 92 | } |
| 93 | |
| 94 | function zeichne(project: Project): { |
| 95 | wurzel: HTMLElement; |
| 96 | store: ProjectStore; |
| 97 | aufraeumen: () => void; |
| 98 | } { |
| 99 | const wurzel = document.createElement('div'); |
| 100 | document.body.append(wurzel); |
| 101 | const store = new ProjectStore(project); |
| 102 | const ende = phasesView.render(wurzel, { |
| 103 | store, |
| 104 | refresh: () => {}, |
| 105 | navigate: () => {}, |
| 106 | target: null, |
| 107 | }); |
| 108 | return { |
| 109 | wurzel, |
| 110 | store, |
| 111 | aufraeumen: () => { |
| 112 | ende(); |
| 113 | wurzel.remove(); |
| 114 | }, |
| 115 | }; |
| 116 | } |
| 117 | |
| 118 | function knopf(wurzel: ParentNode, text: string): HTMLButtonElement { |
| 119 | const treffer = [...wurzel.querySelectorAll('button')].find( |
| 120 | (b) => b.textContent?.trim() === text, |
| 121 | ); |
| 122 | if (!treffer) throw new Error(`Schaltfläche "${text}" nicht gefunden.`); |
| 123 | return treffer; |
| 124 | } |
| 125 | |
| 126 | afterEach(() => { |
| 127 | vergessEinteilungssuche(); |
| 128 | }); |
| 129 | |
| 130 | describe('Phasenansicht - zulässige Einteilungen', () => { |
| 131 | it('sucht erst auf Knopfdruck', () => { |
| 132 | /* |
| 133 | * Die Suche baut je Vorschlag einen ganzen Signalzeitenplan. In einer |
| 134 | * Zeichnung, die bei jeder Tastatureingabe laeuft, hat das nichts zu |
| 135 | * suchen - eine traege Oberflaeche ist das erste, was der Bearbeiter |
| 136 | * merkt. |
| 137 | */ |
| 138 | const { wurzel, aufraeumen } = zeichne(belegtesProjekt()); |
| 139 | try { |
| 140 | expect(wurzel.textContent).toContain('Die Suche läuft auf Knopfdruck'); |
| 141 | expect(wurzel.textContent).not.toContain('gereiht nach der Umlaufzeit'); |
| 142 | knopf(wurzel, 'Einteilungen suchen').click(); |
| 143 | expect(wurzel.textContent).toContain('gereiht nach der Umlaufzeit'); |
| 144 | } finally { |
| 145 | aufraeumen(); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | it('macht ohne Beleg keinen Vorschlag und sagt warum', () => { |
| 150 | /* |
| 151 | * DIE BEDINGUNG DES BEFUNDS, an der Oberfläche. Das Standardprojekt hat |
| 152 | * keinen Lageplan: Seine Verträglichkeit ist nirgends belegt, sie ist nur |
| 153 | * nicht widerlegt. |
| 154 | */ |
| 155 | const { wurzel, aufraeumen } = zeichne( |
| 156 | createStandardIntersectionProject('Hauptstraße', STICHTAG), |
| 157 | ); |
| 158 | try { |
| 159 | knopf(wurzel, 'Einteilungen suchen').click(); |
| 160 | expect(wurzel.textContent).toContain('Keine Vorschläge'); |
| 161 | expect(wurzel.textContent).toContain('nicht belegt'); |
| 162 | expect(wurzel.textContent).toContain('Ohne Beleg sind'); |
| 163 | // Und kein einziger Vorschlag - auch kein gekennzeichneter. |
| 164 | expect(wurzel.textContent).not.toContain('Übernehmen'); |
| 165 | } finally { |
| 166 | aufraeumen(); |
| 167 | } |
| 168 | }); |
| 169 | |
| 170 | it('übernimmt einen Vorschlag erst nach Rückfrage', async () => { |
| 171 | const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt()); |
| 172 | try { |
| 173 | knopf(wurzel, 'Einteilungen suchen').click(); |
| 174 | const vorher = store.getProject().phases.length; |
| 175 | knopf(wurzel, 'Übernehmen').click(); |
| 176 | const dialog = document.querySelector<HTMLElement>('[role="dialog"]'); |
| 177 | expect(dialog, 'Ohne Rückfrage wäre die Einteilung mit einem Klick ersetzt').not.toBeNull(); |
| 178 | expect(dialog!.textContent).toContain('in jedem Tagesprogramm'); |
| 179 | [...dialog!.querySelectorAll('button')] |
| 180 | .find((b) => b.textContent?.trim() === 'Übernehmen')! |
| 181 | .click(); |
| 182 | await new Promise((fertig) => setTimeout(fertig, 0)); |
| 183 | |
| 184 | const nachher = store.getProject(); |
| 185 | expect(nachher.phases.length).toBeGreaterThan(vorher); |
| 186 | // Jede Gruppe hat eine Phase, und die Phasenfolge kennt jede Phase. |
| 187 | const inPhasen = new Set(nachher.phases.flatMap((p) => p.signalGroupIds)); |
| 188 | expect(inPhasen.size).toBe(nachher.signalGroups.length); |
| 189 | expect(nachher.program.phaseOrder).toEqual(nachher.phases.map((p) => p.id)); |
| 190 | } finally { |
| 191 | aufraeumen(); |
| 192 | } |
| 193 | }); |
| 194 | |
| 195 | it('ändert nichts, wenn die Rückfrage abgebrochen wird', async () => { |
| 196 | // Die Rueckfrage ist keine Zierde: Sie ersetzt die ganze Phaseneinteilung |
| 197 | // samt den festen Freigabezeiten jedes Tagesprogramms. |
| 198 | const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt()); |
| 199 | try { |
| 200 | knopf(wurzel, 'Einteilungen suchen').click(); |
| 201 | const vorher = store.getProject(); |
| 202 | knopf(wurzel, 'Übernehmen').click(); |
| 203 | const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!; |
| 204 | [...dialog.querySelectorAll('button')] |
| 205 | .find((b) => b.textContent?.trim() === 'Abbrechen')! |
| 206 | .click(); |
| 207 | await new Promise((fertig) => setTimeout(fertig, 0)); |
| 208 | expect(store.getProject()).toBe(vorher); |
| 209 | } finally { |
| 210 | aufraeumen(); |
| 211 | } |
| 212 | }); |
| 213 | |
| 214 | it('kennzeichnet die heutige Einteilung und bietet sie nicht zum Übernehmen an', async () => { |
| 215 | // Ein Knopf, der nichts aendert, ist eine Falle: Der Bearbeiter druecke |
| 216 | // ihn, bekaeme einen Rueckgaengig-Schritt und dieselbe Einteilung. |
| 217 | const erste = zeichne(belegtesProjekt()); |
| 218 | let uebernommen: Project; |
| 219 | try { |
| 220 | knopf(erste.wurzel, 'Einteilungen suchen').click(); |
| 221 | knopf(erste.wurzel, 'Übernehmen').click(); |
| 222 | const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!; |
| 223 | [...dialog.querySelectorAll('button')] |
| 224 | .find((b) => b.textContent?.trim() === 'Übernehmen')! |
| 225 | .click(); |
| 226 | await new Promise((fertig) => setTimeout(fertig, 0)); |
| 227 | uebernommen = erste.store.getProject(); |
| 228 | } finally { |
| 229 | erste.aufraeumen(); |
| 230 | vergessEinteilungssuche(); |
| 231 | } |
| 232 | |
| 233 | const zweite = zeichne(uebernommen); |
| 234 | try { |
| 235 | knopf(zweite.wurzel, 'Einteilungen suchen').click(); |
| 236 | expect(zweite.wurzel.textContent).toContain('die heutige'); |
| 237 | const uebernehmen = [...zweite.wurzel.querySelectorAll('button')].filter( |
| 238 | (b) => b.textContent?.trim() === 'Übernehmen', |
| 239 | ); |
| 240 | const zeilen = [...zweite.wurzel.querySelectorAll('tbody tr')].filter((tr) => |
| 241 | tr.textContent?.includes('die heutige'), |
| 242 | ); |
| 243 | expect(zeilen.length).toBe(1); |
| 244 | expect(zeilen[0]!.querySelectorAll('button')).toHaveLength(0); |
| 245 | // Die uebrigen Vorschlaege behalten ihren Knopf. |
| 246 | expect(uebernehmen.length).toBeLessThan( |
| 247 | [...zweite.wurzel.querySelectorAll('tbody tr')].length, |
| 248 | ); |
| 249 | } finally { |
| 250 | zweite.aufraeumen(); |
| 251 | } |
| 252 | }); |
| 253 | }); |