lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui ausgabebalken.test.ts

11,5 KB Rohdatei
tests/ui/ausgabebalken.test.ts — 293 Zeilen
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 });