lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui projektangaben.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import * as actions from '@/app/actions'; |
| 4 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 5 | import type { Project } from '@/domain/model/project'; |
| 6 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 7 | import { mitBewahrtemFokus } from '@/ui/fokus'; |
| 8 | import type { ViewContext } from '@/ui/shell'; |
| 9 | import { projectView } from '@/ui/views/projectView'; |
| 10 | |
| 11 | /** |
| 12 | * Fassung 5.10.0 - Ansicht "Projekt". |
| 13 | * |
| 14 | * - Befund 23 (mittel): Unter der zulaessigen Hoechstgeschwindigkeit des |
| 15 | * KNOTENPUNKTS stand "Bestimmt die Gelbzeit: ...". Die Gelbzeit folgt aber |
| 16 | * ausschliesslich `SignalGroup.vZul`; `intersection.vZul` ist die |
| 17 | * Vorbelegung neuer Zufahrten und Signalgruppen, Grundlage der |
| 18 | * 70-km/h-Warnung und eine Zeile im Ausdruck - eine Rechnung liest ihn |
| 19 | * nicht. Wer ihn hochsetzt, um laengere Gelb- und damit Ueberfahrzeiten zu |
| 20 | * bekommen, behaelt die kuerzeren: die unsichere Richtung. |
| 21 | * |
| 22 | * - Befund 24 (mittel): Der Hinweis am Auswahlfeld "Anlagenart" las die |
| 23 | * Rohschranken aus `ANLAGENART_GRENZEN`. Bemessen und beurteilt wird gegen |
| 24 | * `anlagenartGrenzen(project, defaults)`, das eine ausdruecklich |
| 25 | * eingetragene Vorgabe verengt. Dieselbe Rohschranke stand in der |
| 26 | * Kurzmeldung nach dem Wechsel und in `grenzverletzungen()`, das vor dem |
| 27 | * Wechsel warnt. |
| 28 | * |
| 29 | * - Befund 54 (gering): "Zufahrt hinzufügen" bildete den Vorgabenamen aus der |
| 30 | * ANZAHL der Zufahrten. Nach einem Loeschen entstand ein bereits vergebener |
| 31 | * Name - die Auswahlliste der Signalgruppentabelle fuehrte zwei |
| 32 | * gleichlautende Eintraege, und der Name der Loeschschaltflaeche benannte |
| 33 | * nicht mehr eindeutig. |
| 34 | */ |
| 35 | |
| 36 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 37 | |
| 38 | let aufraeumen: (() => void) | null = null; |
| 39 | afterEach(() => { |
| 40 | aufraeumen?.(); |
| 41 | aufraeumen = null; |
| 42 | document.body.replaceChildren(); |
| 43 | }); |
| 44 | |
| 45 | interface Ansicht { |
| 46 | readonly wurzel: HTMLElement; |
| 47 | readonly store: ProjectStore; |
| 48 | } |
| 49 | |
| 50 | function zeichne(project: Project): Ansicht { |
| 51 | const wurzel = document.createElement('div'); |
| 52 | document.body.append(wurzel); |
| 53 | const store = new ProjectStore(project); |
| 54 | let abmelden: () => void = () => {}; |
| 55 | const context: ViewContext = { |
| 56 | store, |
| 57 | refresh: () => { |
| 58 | mitBewahrtemFokus(wurzel, () => { |
| 59 | abmelden(); |
| 60 | wurzel.replaceChildren(); |
| 61 | abmelden = projectView.render(wurzel, context); |
| 62 | }); |
| 63 | }, |
| 64 | navigate: () => {}, |
| 65 | target: null, |
| 66 | }; |
| 67 | abmelden = projectView.render(wurzel, context); |
| 68 | aufraeumen = () => { |
| 69 | abmelden(); |
| 70 | wurzel.remove(); |
| 71 | }; |
| 72 | return { wurzel, store }; |
| 73 | } |
| 74 | |
| 75 | async function abwarten(): Promise<void> { |
| 76 | await new Promise((fertig) => setTimeout(fertig, 0)); |
| 77 | } |
| 78 | |
| 79 | /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf daneben. */ |
| 80 | function beschriftungstext(label: HTMLLabelElement): string { |
| 81 | return [...label.childNodes] |
| 82 | .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf'))) |
| 83 | .map((knoten) => knoten.textContent ?? '') |
| 84 | .join('') |
| 85 | .trim(); |
| 86 | } |
| 87 | |
| 88 | /** Der vorgelesene Hinweis eines Feldes - ueber `aria-describedby`. */ |
| 89 | function hinweisZu(wurzel: HTMLElement, beschriftung: string): string { |
| 90 | const label = [...wurzel.querySelectorAll('label')].find( |
| 91 | (l) => beschriftungstext(l) === beschriftung, |
| 92 | ); |
| 93 | if (label === undefined) throw new Error(`Kein Feld "${beschriftung}"`); |
| 94 | const feld = wurzel.querySelector(`#${label.htmlFor}`); |
| 95 | const kennung = feld?.getAttribute('aria-describedby') ?? ''; |
| 96 | return kennung |
| 97 | .split(' ') |
| 98 | .map((id) => wurzel.querySelector(`#${id}`)?.textContent ?? '') |
| 99 | .join(' '); |
| 100 | } |
| 101 | |
| 102 | function knopfMit(text: string): HTMLButtonElement | undefined { |
| 103 | return [...document.querySelectorAll('button')].find( |
| 104 | (b) => (b.textContent ?? '').trim() === text, |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | describe('Befund 23 - Feldhinweis an der Hoechstgeschwindigkeit des Knotenpunkts', () => { |
| 109 | /* |
| 110 | * Die Messung, an der der Feldhinweis zu pruefen ist: Der Knotenpunktwert |
| 111 | * bewegt keine Gelbzeit. Sie steht hier und nicht nur im Text, damit die |
| 112 | * Aussage des Hinweises an einem Ergebnis haengt und nicht an einer |
| 113 | * Behauptung. |
| 114 | */ |
| 115 | it('bewegt keine Gelbzeit, wenn er geaendert wird', () => { |
| 116 | const vorher = createStandardIntersectionProject('Gelbzeit', FESTES_DATUM); |
| 117 | const nachher = actions.setIntersection(vorher, { vZul: 70 }); |
| 118 | |
| 119 | expect(nachher.intersection.vZul).toBe(70); |
| 120 | expect(nachher.signalGroups.map((g) => g.vZul)).toEqual(vorher.signalGroups.map((g) => g.vZul)); |
| 121 | expect(buildSignalPlan(nachher).groups.map((g) => g.times.yellow)).toEqual( |
| 122 | buildSignalPlan(vorher).groups.map((g) => g.times.yellow), |
| 123 | ); |
| 124 | }); |
| 125 | |
| 126 | it('schreibt die Gelbzeit nicht dem Knotenpunktwert zu', () => { |
| 127 | const { wurzel } = zeichne(createStandardIntersectionProject('Hinweis', FESTES_DATUM)); |
| 128 | const text = hinweisZu(wurzel, 'Zulässige Höchstgeschwindigkeit'); |
| 129 | |
| 130 | expect( |
| 131 | text, |
| 132 | 'Der Hinweis schreibt dem Knotenpunktwert eine Wirkung zu, die er nicht hat', |
| 133 | ).not.toContain('Bestimmt die Gelbzeit'); |
| 134 | // Was er tatsaechlich tut, und wo die Gelbzeit haengt. |
| 135 | expect(text).toContain('Vorbelegung'); |
| 136 | expect(text).toContain('Signalgruppe'); |
| 137 | // Die 70-km/h-Warnung bleibt am Feld - sie haengt wirklich an ihm. |
| 138 | expect(text).toContain('70'); |
| 139 | }); |
| 140 | }); |
| 141 | |
| 142 | describe('Befund 24 - Anlagenart-Hinweis nennt die wirksamen Schranken', () => { |
| 143 | /** Knotenpunkt mit eingetragenen Umlaufzeitvorgaben 45 / 100 / 80. */ |
| 144 | function mitVorgaben(): Project { |
| 145 | const basis = createStandardIntersectionProject('Vorgaben', FESTES_DATUM); |
| 146 | return { |
| 147 | ...basis, |
| 148 | settings: { |
| 149 | ...basis.settings, |
| 150 | rilsa: { |
| 151 | ...basis.settings.rilsa, |
| 152 | cycleTimeMin: 45, |
| 153 | cycleTimeMax: 100, |
| 154 | cycleTimeRecommendedMax: 80, |
| 155 | }, |
| 156 | }, |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | it('nennt am Auswahlfeld die Schranken, gegen die bemessen wird', () => { |
| 161 | const projekt = mitVorgaben(); |
| 162 | const grenzen = buildSignalPlan(projekt).grenzen.cycleTime; |
| 163 | expect([grenzen.min, grenzen.max, grenzen.recommendedMax]).toEqual([45, 100, 80]); |
| 164 | |
| 165 | const { wurzel } = zeichne(projekt); |
| 166 | const text = hinweisZu(wurzel, 'Anlagenart'); |
| 167 | |
| 168 | expect(text).toContain(`Umlaufzeit ${grenzen.min} bis ${grenzen.max} s`); |
| 169 | expect(text).toContain(`empfohlen ${grenzen.recommendedMin} bis ${grenzen.recommendedMax} s`); |
| 170 | // Die Rohschranken der Anlagenart gelten hier gerade nicht. |
| 171 | expect(text, 'Der Hinweis nennt einen weiteren Bereich, als tatsächlich gilt').not.toContain( |
| 172 | 'Umlaufzeit 30 bis 120 s', |
| 173 | ); |
| 174 | // Unverengt und deshalb unveraendert: fuer sie gibt es keine Vorgabenfelder. |
| 175 | expect(text).toContain('Räumweg bis 500 m'); |
| 176 | }); |
| 177 | |
| 178 | it('warnt vor einem Wechsel, den erst die eingetragene Vorgabe unzulaessig macht', async () => { |
| 179 | const basis = mitVorgaben(); |
| 180 | const projekt: Project = { |
| 181 | ...basis, |
| 182 | anlagenart: 'einstreifig', |
| 183 | program: { ...basis.program, method: 'manuell', manualCycleTime: 110 }, |
| 184 | }; |
| 185 | const { wurzel, store } = zeichne(projekt); |
| 186 | |
| 187 | const auswahl = wurzel.querySelector<HTMLSelectElement>('select'); |
| 188 | expect(auswahl?.value).toBe('einstreifig'); |
| 189 | auswahl!.value = 'knotenpunkt'; |
| 190 | auswahl!.dispatchEvent(new Event('change')); |
| 191 | |
| 192 | const dialog = document.querySelector('.dialog'); |
| 193 | expect( |
| 194 | dialog, |
| 195 | 'Die Umstellung geschah ohne Rückfrage, obwohl 110 s über der wirksamen Schranke von 100 s liegen', |
| 196 | ).not.toBeNull(); |
| 197 | expect(dialog?.textContent ?? '').toContain('100'); |
| 198 | |
| 199 | knopfMit('Umstellen')?.click(); |
| 200 | await abwarten(); |
| 201 | expect(store.getProject().anlagenart).toBe('knotenpunkt'); |
| 202 | |
| 203 | // Die Kurzmeldung nach dem Wechsel nennt dieselben Schranken. |
| 204 | const meldung = [...document.querySelectorAll('.meldung')] |
| 205 | .map((m) => m.textContent ?? '') |
| 206 | .join(' '); |
| 207 | expect(meldung).toContain('100'); |
| 208 | expect(meldung, 'Die Kurzmeldung nennt die Rohschranke der Anlagenart').not.toContain('120'); |
| 209 | }); |
| 210 | }); |
| 211 | |
| 212 | describe('Befund 54 - "Zufahrt hinzufügen" vergibt keinen bereits belegten Namen', () => { |
| 213 | function ohneZufahrten(): Project { |
| 214 | const basis = createStandardIntersectionProject('Zufahrten', FESTES_DATUM); |
| 215 | return { ...basis, intersection: { ...basis.intersection, arms: [] } }; |
| 216 | } |
| 217 | |
| 218 | it('zaehlt an den vergebenen Namen weiter, nicht an der Anzahl', () => { |
| 219 | const { wurzel, store } = zeichne(ohneZufahrten()); |
| 220 | const hinzufuegen = (): void => { |
| 221 | const knopf = [...wurzel.querySelectorAll('button')].find( |
| 222 | (b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen', |
| 223 | ); |
| 224 | if (knopf === undefined) throw new Error('Keine Schaltfläche "Zufahrt hinzufügen"'); |
| 225 | knopf.click(); |
| 226 | }; |
| 227 | |
| 228 | hinzufuegen(); |
| 229 | hinzufuegen(); |
| 230 | expect(store.getProject().intersection.arms.map((a) => a.name)).toEqual([ |
| 231 | 'Zufahrt 1', |
| 232 | 'Zufahrt 2', |
| 233 | ]); |
| 234 | |
| 235 | const erste = store.getProject().intersection.arms[0]!; |
| 236 | store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' }); |
| 237 | hinzufuegen(); |
| 238 | |
| 239 | const namen = store.getProject().intersection.arms.map((a) => a.name); |
| 240 | expect(new Set(namen).size, `Zwei Zufahrten heißen gleich: ${namen.join(' | ')}`).toBe( |
| 241 | namen.length, |
| 242 | ); |
| 243 | }); |
| 244 | |
| 245 | it('haelt die Namen der Loeschschaltflaechen unterscheidbar', () => { |
| 246 | const { wurzel, store } = zeichne(ohneZufahrten()); |
| 247 | const hinzufuegen = (): void => { |
| 248 | [...wurzel.querySelectorAll('button')] |
| 249 | .find((b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen') |
| 250 | ?.click(); |
| 251 | }; |
| 252 | |
| 253 | hinzufuegen(); |
| 254 | hinzufuegen(); |
| 255 | const erste = store.getProject().intersection.arms[0]!; |
| 256 | store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' }); |
| 257 | hinzufuegen(); |
| 258 | |
| 259 | const namen = [...wurzel.querySelectorAll('button')] |
| 260 | .map((b) => b.getAttribute('aria-label') ?? '') |
| 261 | .filter((n) => n.startsWith('Zufahrt "')); |
| 262 | expect(namen).toHaveLength(2); |
| 263 | expect(new Set(namen).size, 'Die Namen sind nicht unterscheidbar').toBe(namen.length); |
| 264 | }); |
| 265 | }); |