lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui ausgabebalken.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 4 | import { validateProject } from '@/domain/validation'; |
| 5 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 6 | import type { Project } from '@/domain/model/project'; |
| 7 | import { nurHinweiseUeberschrift } from '@/ui/inputs'; |
| 8 | import type { ViewDefinition } from '@/ui/shell'; |
| 9 | import { exportView } from '@/ui/views/exportView'; |
| 10 | |
| 11 | /** |
| 12 | * Ausgabe-Ansicht, Befunde zur Fassung 5.10.0. |
| 13 | * |
| 14 | * BEFUND 27: Der Balken kannte nur `exportBlocked` und `warningCount`. Ein |
| 15 | * Bericht mit ausschliesslich Hinweisen fiel deshalb auf "Keine |
| 16 | * Beanstandungen" zurueck, obwohl der Pruefbericht Eintraege fuehrt - und |
| 17 | * "Beanstandung" meint in diesem Programm alle drei Schweregrade (die |
| 18 | * Planunterlage sagt den Satz nur bei `report.findings.length === 0`, |
| 19 | * src/services/export/pdf.ts). Der Anwender bekam die Freigabe zur Ausgabe, |
| 20 | * ohne dass ihm die Luecke genannt wurde. |
| 21 | * |
| 22 | * BEFUND 29: Balken und Rueckfrage sagten unbedingt zu, die Warnungen stuenden |
| 23 | * im Ausdruck. Ist der Schalter "Prüfbericht" abgewaehlt, druckt die Unterlage |
| 24 | * keinen einzigen Wortlaut - nur die Anzahl auf dem Deckblatt. |
| 25 | * |
| 26 | * BEFUND 58: Balken und Rueckfrage setzten die Zahl unbesehen vor |
| 27 | * "Warnungen"; bei genau einer Warnung stand dort "1 Warnungen", waehrend der |
| 28 | * Abschnitt "Tabellen" und der Ansageraum derselben Ansicht "1 Warnung" |
| 29 | * bildeten. |
| 30 | */ |
| 31 | |
| 32 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 33 | |
| 34 | function zeichne( |
| 35 | view: ViewDefinition, |
| 36 | project: Project, |
| 37 | ): { wurzel: HTMLElement; aufraeumen: () => void } { |
| 38 | const wurzel = document.createElement('div'); |
| 39 | document.body.append(wurzel); |
| 40 | const store = new ProjectStore(project); |
| 41 | const aufraeumen = view.render(wurzel, { |
| 42 | store, |
| 43 | refresh: () => {}, |
| 44 | navigate: () => {}, |
| 45 | target: null, |
| 46 | }); |
| 47 | return { |
| 48 | wurzel, |
| 49 | aufraeumen: () => { |
| 50 | aufraeumen(); |
| 51 | wurzel.remove(); |
| 52 | }, |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function mitMeta(p: Project, meta: Partial<Project['meta']>): Project { |
| 57 | return { ...p, meta: { ...p.meta, ...meta } }; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Der Normalzustand einer fertigen Planung: alle vier Pflichtangaben der |
| 62 | * Projektdaten gefuellt, die beiden Anordnungsangaben nicht. Am Knotenpunkt |
| 63 | * sind das genau zwei HINWEISE (engine.ts, checkAnordnungsangaben). |
| 64 | */ |
| 65 | function nurHinweise(): Project { |
| 66 | const p = mitMeta(createStandardIntersectionProject('Nur Hinweise', FESTES_DATUM), { |
| 67 | projectNumber: '2026-001', |
| 68 | client: 'Stadt', |
| 69 | planner: 'Bearbeiterin', |
| 70 | location: 'Hauptstraße', |
| 71 | }); |
| 72 | const bericht = validateProject(p, buildSignalPlan(p), FESTES_DATUM); |
| 73 | expect([bericht.errorCount, bericht.warningCount, bericht.hintCount]).toEqual([0, 0, 2]); |
| 74 | return p; |
| 75 | } |
| 76 | |
| 77 | /** Genau eine offene Warnung: nur der Ort fehlt. */ |
| 78 | function eineWarnung(): Project { |
| 79 | const p = mitMeta(createStandardIntersectionProject('Eine Warnung', FESTES_DATUM), { |
| 80 | projectNumber: '2026-002', |
| 81 | client: 'Stadt', |
| 82 | planner: 'Bearbeiterin', |
| 83 | }); |
| 84 | const bericht = validateProject(p, buildSignalPlan(p), FESTES_DATUM); |
| 85 | expect(bericht.errorCount).toBe(0); |
| 86 | expect(bericht.warningCount).toBe(1); |
| 87 | return p; |
| 88 | } |
| 89 | |
| 90 | function balken(wurzel: ParentNode): HTMLElement { |
| 91 | const gefunden = wurzel.querySelector<HTMLElement>('.hinweisbalken'); |
| 92 | if (gefunden === null) throw new Error('Kein Hinweisbalken in der Ansicht'); |
| 93 | return gefunden; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Der Ansageraum der Ansicht - der einzige Live-Bereich, der den Neuaufbau |
| 98 | * ueberlebt (Befund L5). Der Rechenweg-Vermerk traegt zwar auch `role="status"`, |
| 99 | * aber kein `aria-live`. |
| 100 | */ |
| 101 | function ansageKnoten(wurzel: ParentNode): HTMLElement { |
| 102 | const gefunden = wurzel.querySelector<HTMLElement>('[role="status"][aria-live]'); |
| 103 | if (gefunden === null) throw new Error('Kein Ansageraum in der Ansicht'); |
| 104 | return gefunden; |
| 105 | } |
| 106 | |
| 107 | function ansage(wurzel: ParentNode): string { |
| 108 | return ansageKnoten(wurzel).textContent ?? ''; |
| 109 | } |
| 110 | |
| 111 | /** Die Ueberschrift des Balkens - die fette Zeile darin. */ |
| 112 | function balkenkopf(wurzel: ParentNode): string { |
| 113 | return balken(wurzel).querySelector('strong')?.textContent ?? ''; |
| 114 | } |
| 115 | |
| 116 | function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement { |
| 117 | const gefunden = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].find( |
| 118 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 119 | ); |
| 120 | if (!gefunden) throw new Error(`Kein Knopf "${beschriftung}"`); |
| 121 | return gefunden; |
| 122 | } |
| 123 | |
| 124 | /** Kontrollkaestchen ueber seine sichtbare Beschriftung. */ |
| 125 | function schalter(wurzel: ParentNode, beschriftung: string): HTMLInputElement { |
| 126 | const marke = [...wurzel.querySelectorAll<HTMLLabelElement>('label')].find( |
| 127 | (l) => (l.textContent ?? '').trim() === beschriftung, |
| 128 | ); |
| 129 | if (!marke) throw new Error(`Kein Schalter "${beschriftung}"`); |
| 130 | const feld = wurzel.querySelector<HTMLInputElement>(`#${marke.htmlFor}`); |
| 131 | if (feld === null) throw new Error(`Schalter "${beschriftung}" ohne Eingabefeld`); |
| 132 | return feld; |
| 133 | } |
| 134 | |
| 135 | function schalte(feld: HTMLInputElement, an: boolean): void { |
| 136 | feld.checked = an; |
| 137 | feld.dispatchEvent(new Event('change')); |
| 138 | } |
| 139 | |
| 140 | /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */ |
| 141 | async function ruhe(): Promise<void> { |
| 142 | for (let i = 0; i < 6; i += 1) { |
| 143 | await new Promise((erfuellen) => setTimeout(erfuellen, 0)); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | function dialogtext(): string { |
| 148 | return document.querySelector('.dialog .dialog-text')?.textContent ?? ''; |
| 149 | } |
| 150 | |
| 151 | function knopfImFenster(beschriftung: string): HTMLButtonElement { |
| 152 | const fenster = document.querySelector<HTMLElement>('.dialog'); |
| 153 | if (fenster === null) throw new Error('Es steht kein Fenster offen.'); |
| 154 | const treffer = [...fenster.querySelectorAll<HTMLButtonElement>('button')].find( |
| 155 | (b) => (b.textContent ?? '').trim() === beschriftung, |
| 156 | ); |
| 157 | if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`); |
| 158 | return treffer; |
| 159 | } |
| 160 | |
| 161 | afterEach(() => { |
| 162 | document.body.replaceChildren(); |
| 163 | }); |
| 164 | |
| 165 | describe('Ausgabe: Balken und Rückfrage nennen den Prüfstand richtig', () => { |
| 166 | it('nennt Hinweise, statt "Keine Beanstandungen" zu melden (Befund 27)', () => { |
| 167 | const { wurzel, aufraeumen } = zeichne(exportView, nurHinweise()); |
| 168 | try { |
| 169 | const text = balken(wurzel).textContent ?? ''; |
| 170 | expect(text, 'der Balken bestreitet vorhandene Beanstandungen').not.toContain( |
| 171 | 'Keine Beanstandungen', |
| 172 | ); |
| 173 | expect(text).toContain('2 Hinweise'); |
| 174 | // Und zwar mit dem Wortlaut, den auch der Pruefbericht fuehrt: Er steht |
| 175 | // an EINER Stelle (`nurHinweiseUeberschrift` in src/ui/inputs.ts) und |
| 176 | // nicht zweimal getrennt zusammengesetzt. |
| 177 | expect(balkenkopf(wurzel)).toBe(nurHinweiseUeberschrift(2)); |
| 178 | // Die Ausgabe bleibt moeglich - Hinweise sperren nicht, und das ist so |
| 179 | // gewollt (engine.ts: exportBlocked haengt allein an errorCount). |
| 180 | expect(text).toContain('Die Planunterlagen können ausgegeben werden.'); |
| 181 | expect(knopf(wurzel, 'PDF erzeugen').disabled).toBe(false); |
| 182 | // Und der Ansageraum sagt dasselbe wie der Balken. |
| 183 | expect(ansage(wurzel)).not.toContain('keine Beanstandungen'); |
| 184 | expect(ansage(wurzel)).toContain('2 Hinweise'); |
| 185 | } finally { |
| 186 | aufraeumen(); |
| 187 | } |
| 188 | }); |
| 189 | |
| 190 | it('bildet die Einzahl bei genau einer Warnung (Befund 58)', () => { |
| 191 | const { wurzel, aufraeumen } = zeichne(exportView, eineWarnung()); |
| 192 | try { |
| 193 | const text = balken(wurzel).textContent ?? ''; |
| 194 | expect(text).toContain('1 Warnung'); |
| 195 | expect(text, '"1 Warnungen" widerspricht dem Abschnitt "Tabellen"').not.toContain( |
| 196 | '1 Warnungen', |
| 197 | ); |
| 198 | // Was der Ansageraum schon immer richtig machte, steht jetzt auch im |
| 199 | // sichtbaren Balken. |
| 200 | expect(ansage(wurzel)).toContain('1 Warnung im Prüfbericht'); |
| 201 | } finally { |
| 202 | aufraeumen(); |
| 203 | } |
| 204 | }); |
| 205 | |
| 206 | it('bindet die Zusage "im Ausdruck" an den Schalter "Prüfbericht" (Befund 29, Balken)', () => { |
| 207 | const { wurzel, aufraeumen } = zeichne(exportView, eineWarnung()); |
| 208 | try { |
| 209 | expect(balken(wurzel).textContent).toContain('Die Warnungen erscheinen im Ausdruck'); |
| 210 | schalte(schalter(wurzel, 'Prüfbericht'), false); |
| 211 | const text = balken(wurzel).textContent ?? ''; |
| 212 | expect(text, 'die Zusage steht trotz abgewähltem Prüfbericht').not.toContain( |
| 213 | 'Die Warnungen erscheinen im Ausdruck', |
| 214 | ); |
| 215 | expect(text).toContain('Der Prüfbericht ist abgewählt'); |
| 216 | expect(text).toContain('Deckblatt'); |
| 217 | // Wieder angewaehlt gilt die Zusage wieder. |
| 218 | schalte(schalter(wurzel, 'Prüfbericht'), true); |
| 219 | expect(balken(wurzel).textContent).toContain('Die Warnungen erscheinen im Ausdruck'); |
| 220 | } finally { |
| 221 | aufraeumen(); |
| 222 | } |
| 223 | }); |
| 224 | |
| 225 | it('sagt den geänderten Vorbehalt im Ansageraum an (Befund 29, Nachtrag)', () => { |
| 226 | /* |
| 227 | * Der Satz im Balken wechselt seinen TEXT, ohne dass der Knoten ersetzt |
| 228 | * wird - genau der Fall, den tests/ui/bfStapelUndAnsageraeume.test.ts dem |
| 229 | * Ansageraum zuordnet. Ein zweites `aria-live` an diesem Absatz taete es |
| 230 | * nicht: Er entsteht in `zeichne()` und wird beim Neuaufbau ersetzt, und |
| 231 | * ein neu eingehaengter Live-Bereich sagt nichts an (Befund L5). Der |
| 232 | * Ansageraum der Ansicht steht ausserhalb des ausgetauschten Teils. |
| 233 | */ |
| 234 | const { wurzel, aufraeumen } = zeichne(exportView, eineWarnung()); |
| 235 | try { |
| 236 | expect(ansage(wurzel)).toBe('Ausgabe möglich – 1 Warnung im Prüfbericht.'); |
| 237 | schalte(schalter(wurzel, 'Prüfbericht'), false); |
| 238 | const abgewaehlt = ansage(wurzel); |
| 239 | expect(abgewaehlt, 'der Ansageraum hat vom geänderten Vorbehalt nichts erfahren').toContain( |
| 240 | 'Der Prüfbericht ist abgewählt', |
| 241 | ); |
| 242 | expect(abgewaehlt).toContain('Deckblatt'); |
| 243 | // Er sagt woertlich das an, was jetzt im Balken steht - zwei Fassungen |
| 244 | // desselben Vorbehalts liefen sonst auseinander. |
| 245 | expect(balken(wurzel).textContent).toContain(abgewaehlt); |
| 246 | // Wieder angewaehlt gilt die Zusage wieder, und auch das wird gesagt. |
| 247 | schalte(schalter(wurzel, 'Prüfbericht'), true); |
| 248 | expect(ansage(wurzel)).toContain('Die Warnungen erscheinen im Ausdruck'); |
| 249 | } finally { |
| 250 | aufraeumen(); |
| 251 | } |
| 252 | }); |
| 253 | |
| 254 | it('sagt in der Rückfrage vor dem PDF, was der abgewählte Prüfbericht bedeutet (Befund 29)', async () => { |
| 255 | const { wurzel, aufraeumen } = zeichne(exportView, eineWarnung()); |
| 256 | try { |
| 257 | schalte(schalter(wurzel, 'Prüfbericht'), false); |
| 258 | knopf(wurzel, 'PDF erzeugen').click(); |
| 259 | await ruhe(); |
| 260 | const text = dialogtext(); |
| 261 | expect(text, 'die Rückfrage sagt den Wortlaut im Ausdruck zu').not.toContain( |
| 262 | 'Sie werden im Ausdruck aufgeführt', |
| 263 | ); |
| 264 | expect(text).toContain('Der Prüfbericht ist abgewählt'); |
| 265 | expect(text).toContain('Deckblatt'); |
| 266 | // Und die Einzahl stimmt auch hier (Befund 58). |
| 267 | expect(text).toContain('1 Warnung.'); |
| 268 | expect(text).not.toContain('1 Warnungen'); |
| 269 | knopfImFenster('Abbrechen').click(); |
| 270 | await ruhe(); |
| 271 | } finally { |
| 272 | aufraeumen(); |
| 273 | } |
| 274 | }); |
| 275 | |
| 276 | it('behält bei angewähltem Prüfbericht die bisherige Zusage in der Rückfrage', async () => { |
| 277 | const p = createStandardIntersectionProject('Vier Warnungen', FESTES_DATUM); |
| 278 | const bericht = validateProject(p, buildSignalPlan(p), FESTES_DATUM); |
| 279 | expect(bericht.warningCount).toBeGreaterThan(1); |
| 280 | const { wurzel, aufraeumen } = zeichne(exportView, p); |
| 281 | try { |
| 282 | knopf(wurzel, 'PDF erzeugen').click(); |
| 283 | await ruhe(); |
| 284 | const text = dialogtext(); |
| 285 | expect(text).toContain(`Die Prüfung meldet ${bericht.warningCount} Warnungen.`); |
| 286 | expect(text).toContain('Sie werden im Ausdruck aufgeführt'); |
| 287 | knopfImFenster('Abbrechen').click(); |
| 288 | await ruhe(); |
| 289 | } finally { |
| 290 | aufraeumen(); |
| 291 | } |
| 292 | }); |
| 293 | }); |