lsa-planer

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

/ e2e durchlauf.spec.ts

22,8 KB Rohdatei
e2e/durchlauf.spec.ts — 533 Zeilen
1 import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2 import { tmpdir } from 'node:os';
3 import { join } from 'node:path';
4 import { inflateSync } from 'node:zlib';
5
6 import {
7 _electron as electron,
8 expect,
9 test,
10 type ElectronApplication,
11 type Page,
12 } from '@playwright/test';
13
14 /**
15 * Ein ganzer Durchlauf am gebauten Stand - vom leeren Projekt bis zur Datei.
16 *
17 * WARUM ES DIESE DATEI GIBT. Die Modultests bedienen die Oberflaeche wirklich:
18 * `tests/ui/**` klickt, tippt und loest Ereignisse aus. Nur tut es das in jsdom
19 * - gegen einen nachgebauten Baum, nicht gegen das Erzeugnis. Der Rauchtest
20 * daneben startet das gebaute Programm, klickt darin aber nichts: keine
21 * Eingabe, kein Ansichtswechsel, kein angelegtes Projekt; von den elf Ansichten
22 * wird eine einzige ueberhaupt gezeichnet. Dazwischen lag eine Luecke, und in
23 * ihr lag die Frage, die ein Anwender stellt: Kommt man im echten Fenster von
24 * einem leeren Projekt bis zur fertigen Planunterlage?
25 *
26 * WAS DIESE DATEI NICHT IST: ein zweiter Rauchtest. Der beantwortet, ob das
27 * Erzeugnis heil ist - Bruecke, Abschottung, Sicherheitsrichtlinie - und prueft
28 * dafuer bewusst wenig. Diese Datei beantwortet, ob es sich bedienen laesst.
29 *
30 * SIE IST EINE KETTE, und deshalb `mode: 'serial'`: Jeder Fall setzt auf dem
31 * Stand des vorigen auf. Faellt ein Glied, sind die folgenden uebersprungen und
32 * nicht rot - ein Folgefehler ist kein zweiter Befund.
33 *
34 * WAS SIE NICHT LEISTET: Sie faehrt EINEN Weg durch das Programm, den einer
35 * Kreuzung aus dem gefuehrten Einstieg. Dass jede Anlagenart, jedes
36 * Bewertungsverfahren und jede Verkehrsart rechnet, pruefen die Modultests -
37 * hier geht es um die Kette, nicht um die Breite.
38 */
39
40 test.describe.configure({ mode: 'serial' });
41
42 const WURZEL = join(__dirname, '..');
43
44 /**
45 * Zulaessige Hoechstgeschwindigkeit, auf die der Durchlauf K1 anhebt.
46 *
47 * 70 km/h und nicht 60: Die Gelbzeitstaffel der RiLSA hat ihre Stufen bei 50
48 * und 60 km/h (`yellowBySpeed` in src/domain/rilsa/constants.ts). 60 km/h
49 * traefe die mittlere Stufe und braechte eine Sekunde mehr Gelb, 70 km/h die
50 * oberste und zwei. Zwei sind der belastbarere Nachweis: Die Zwischenzeit wird
51 * auf ganze Sekunden AUFGERUNDET, ein Unterschied von einer Sekunde kann dabei
52 * verschwinden, einer von zwei Sekunden nicht.
53 */
54 const ERHOEHTE_GESCHWINDIGKEIT = 70;
55
56 /** Verkehrsstaerke, die der Durchlauf jeder Kfz-Gruppe gibt (Fz/h). */
57 const VERKEHRSSTAERKE = 400;
58
59 let anwendung: ElectronApplication;
60 let fenster: Page;
61 /** Eigenes Verzeichnis fuer die Dateien, die dieser Durchlauf schreibt. */
62 let ablage: string;
63 const fehlerImLauf: string[] = [];
64
65 /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */
66 function pdfText(bytes: Buffer): string {
67 const roh = bytes.toString('latin1');
68 const teile: string[] = [];
69 const suche = /stream\r?\n/g;
70 let treffer: RegExpExecArray | null;
71
72 while ((treffer = suche.exec(roh)) !== null) {
73 const start = treffer.index + treffer[0].length;
74 const ende = roh.indexOf('endstream', start);
75 if (ende < 0) continue;
76 let inhalt: string;
77 try {
78 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
79 } catch {
80 continue;
81 }
82 for (const stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) {
83 teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')')));
84 }
85 }
86 return teile.join(' ').replace(/\s+/g, ' ');
87 }
88
89 /**
90 * Laesst den naechsten Speichern-Dialog ohne Zutun auf `ziel` zeigen.
91 *
92 * Der Dialog ist ein Fenster des Betriebssystems - Playwright kann ihn nicht
93 * bedienen, und ein Durchlauf, der darauf wartet, bliebe stehen. Ersetzt wird
94 * deshalb nur die FRAGE, nicht die Antwort darauf: Alles, was danach kommt -
95 * die Freigabeliste (`approvePath`) und das unteilbare Schreiben ueber die
96 * Nachbardatei (`schreibeUnteilbar`) -, laeuft unveraendert im Hauptprozess.
97 */
98 async function speichernNach(ziel: string): Promise<void> {
99 await anwendung.evaluate(({ dialog }, pfad) => {
100 dialog.showSaveDialog = () => Promise.resolve({ canceled: false, filePath: pfad });
101 }, ziel);
102 }
103
104 /** Dasselbe fuer den Oeffnen-Dialog - aus demselben Grund. */
105 async function oeffnenAus(quelle: string): Promise<void> {
106 await anwendung.evaluate(({ dialog }, pfad) => {
107 dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [pfad] });
108 }, quelle);
109 }
110
111 /**
112 * Traegt eine Zahl in ein Feld ein - so, wie ein Anwender es tut.
113 *
114 * ERST KLICKEN, DANN SCHREIBEN, und das ist kein Zierrat. GEMESSEN: Ohne den
115 * Klick kam beim zweiten und vierten Feld einer Folge am Dokument WEDER ein
116 * `focus`- NOCH ein `change`-Ereignis an, und der Wert blieb leer - die
117 * Tabelle baut sich nach jeder Uebernahme neu auf, und die Eingabe traf dann
118 * einen Knoten, der nicht mehr im Baum stand. Eine Wartezeit half nicht, der
119 * Klick hilft. Ein Anwender klickt ohnehin zuerst hinein.
120 */
121 async function trageEin(feldname: string, wert: string): Promise<void> {
122 const feld = fenster.getByRole('spinbutton', { name: feldname });
123 await feld.click();
124 await feld.fill(wert);
125 await feld.press('Tab');
126 }
127
128 /** Wechselt ueber die Navigationsleiste in eine Ansicht - wie ein Anwender. */
129 async function zurAnsicht(beschriftung: string): Promise<void> {
130 await fenster
131 .getByRole('button', { name: new RegExp(`Schritt \\d+, ${beschriftung}`, 'u') })
132 .click();
133 await expect(fenster.locator('#inhalt h1')).toHaveText(beschriftung);
134 }
135
136 /**
137 * Eine Zelle der Tabelle "Signalzeiten je Signalgruppe" im Signalzeitenplan.
138 *
139 * Ueber die Spaltenueberschrift und nicht ueber eine feste Spaltennummer: Eine
140 * eingeschobene Spalte machte den Fall sonst still falsch, statt ihn rot zu
141 * faerben.
142 */
143 async function planZelle(gruppe: string, spalte: string): Promise<string> {
144 const text = await fenster.locator('#inhalt').innerText();
145 const zeilen = text.split('\n');
146 const kopf = zeilen.find((z) => z.startsWith('Signalgruppe\t'));
147 expect(kopf, 'die Tabelle "Signalzeiten je Signalgruppe" fehlt').toBeDefined();
148 const spalten = String(kopf).split('\t');
149 const wo = spalten.indexOf(spalte);
150 expect(wo, `Spalte "${spalte}" nicht gefunden in: ${spalten.join(' | ')}`).toBeGreaterThan(-1);
151
152 const zeile = zeilen.find((z) => z.startsWith(`${gruppe}\t`));
153 expect(zeile, `keine Planzeile fuer ${gruppe}`).toBeDefined();
154 return String(String(zeile).split('\t')[wo]).trim();
155 }
156
157 /**
158 * Die gerechneten Zwischenzeiten einer Zeile der Matrix - also die Zeiten, in
159 * denen diese Signalgruppe RAEUMT.
160 *
161 * Was keine reine Ziffernfolge ist, faellt heraus: die leere Hauptdiagonale
162 * und der Punkt fuer "vertraeglich, kein Konflikt". Eine Zwischenzeit von 0 s
163 * faellt damit ebenfalls heraus - die Matrix zeichnet sie mit einem Ausrufe-
164 * zeichen dahinter ("0 !"), und die Kreuzung des gefuehrten Einstiegs hat
165 * keine. Der Fall unten vergleicht die Zeile mit sich selbst; solange nichts
166 * herausfaellt, was vorher darin war, traegt das.
167 */
168 async function zwischenzeitenZeile(gruppe: string): Promise<readonly number[]> {
169 const text = await fenster.locator('#inhalt').innerText();
170 const zeile = text.split('\n').find((z) => z.startsWith(`${gruppe}\t`));
171 expect(zeile, `keine Matrixzeile fuer ${gruppe}`).toBeDefined();
172 return String(zeile)
173 .split('\t')
174 .slice(1)
175 .map((z) => z.trim())
176 .filter((z) => /^\d+$/u.test(z))
177 .map(Number);
178 }
179
180 test.beforeAll(async () => {
181 ablage = mkdtempSync(join(tmpdir(), 'lsa-planer-durchlauf-'));
182
183 /*
184 * Eigenes, leeres Anwenderverzeichnis - aus demselben Grund wie im
185 * Rauchtest: Sonst faengt der Durchlauf nicht bei einem leeren Projekt an,
186 * sondern bei dem, was jemand zuletzt auf diesem Rechner bearbeitet hat.
187 */
188 anwendung = await electron.launch({
189 args: [WURZEL, `--user-data-dir=${join(ablage, 'profil')}`],
190 cwd: WURZEL,
191 });
192
193 fenster = await anwendung.firstWindow();
194 fenster.on('pageerror', (fehler) => fehlerImLauf.push(String(fehler)));
195 fenster.on('console', (zeile) => {
196 if (zeile.type() === 'error') fehlerImLauf.push(zeile.text());
197 });
198
199 await fenster.waitForLoadState('domcontentloaded');
200 await expect(fenster.locator('#inhalt h1')).toHaveText('Projekt');
201 });
202
203 test.afterAll(async () => {
204 /*
205 * Das Schliessen kann eine Rueckfrage ausloesen: Bestehen ungespeicherte
206 * Aenderungen, fragt der Hauptprozess beim Renderer nach, und der zeigt
207 * einen Dialog. Wartete hier nur `close()`, stuende der Lauf bis zur
208 * Zeitschranke - gemessen: 60 s je Datei. Der Durchlauf speichert am Ende
209 * zwar, aber ein abgebrochenes Glied laesst den Stand ungespeichert zurueck.
210 */
211 /*
212 * Beide Fragezeichen sehen nach dem Typ ueberfluessig aus und sind es
213 * nicht: `anwendung` und `fenster` werden erst in beforeAll gesetzt.
214 * Schlaegt der Start fehl, laeuft afterAll trotzdem - und ohne die Abfrage
215 * ueberdeckte hier ein TypeError den eigentlichen Startfehler, also genau
216 * die Meldung, wegen der es diese Datei gibt.
217 */
218 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- anwendung ist vor beforeAll undefined; siehe darueber
219 const schliessen = anwendung === undefined ? Promise.resolve() : anwendung.close();
220 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- fenster ist vor beforeAll undefined; siehe darueber
221 if (fenster !== undefined) {
222 await fenster
223 .getByRole('button', { name: 'Ohne Speichern schließen' })
224 .click({ timeout: 3000 })
225 .catch(() => undefined);
226 }
227 await schliessen;
228
229 /*
230 * Und aufraeumen. Der Durchlauf legt in seinem Verzeichnis ein
231 * Anwenderprofil, zwei PDF und eine Projektdatei ab - gemessen rund 4 MB je
232 * Lauf. Der Rauchtest daneben laesst seines stehen; das ist kein Grund, es
233 * ihm nachzutun, sondern einer, es hier besser zu machen. `force` und
234 * `maxRetries`, weil Windows eine eben geschlossene Datei noch einen
235 * Augenblick haelt.
236 */
237 rmSync(ablage, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
238 });
239
240 test('der erste Start beginnt bei einem leeren Projekt', async () => {
241 /*
242 * Ohne diesen Fall bewiese die Kette weniger, als sie soll: Faenge sie bei
243 * einem Stand an, den ein frueherer Lauf hinterlassen hat, waere nicht
244 * gezeigt, dass ein Anwender von vorn dorthin kommt.
245 */
246 await expect(fenster).toHaveTitle('Projekt – Neues Projekt – LSA-Planer Professional');
247
248 /*
249 * Die Marke wird VOR dem Wechsel geprueft, und das ist kein Zufall der
250 * Reihenfolge: `markiereLeiste` setzt sie ausdruecklich nicht am Eintrag der
251 * offenen Ansicht - dort sagt `aria-current` schon, wo man ist. Wer erst
252 * hinginge und dann suchte, faende nichts und hielte es fuer einen Befund.
253 */
254 await expect(
255 fenster.getByRole('button', { name: 'Schritt 3, Signalgruppen , als Nächstes dran' }),
256 'die Leiste zeigt die Signalgruppen nicht als naechsten Schritt',
257 ).toBeAttached();
258
259 await zurAnsicht('Signalgruppen');
260 await expect(fenster.locator('#inhalt')).toContainText('Noch keine Signalgruppe angelegt');
261 });
262
263 test('der gefuehrte Einstieg legt eine vollstaendige Kreuzung an', async () => {
264 await fenster.getByRole('button', { name: 'Neu', exact: true }).click();
265
266 const auswahl = fenster.getByRole('dialog');
267 await expect(auswahl).toContainText('Womit möchten Sie beginnen?');
268 await auswahl.getByRole('button', { name: 'Geführter Einstieg' }).click();
269
270 // Vier Schritte bei der Kreuzung; die Vorgaben werden uebernommen, wie sie
271 // sind - geprueft wird der Weg, nicht das Ausfuellen jedes Feldes.
272 const assistent = fenster.getByRole('dialog');
273 await expect(assistent).toContainText('Was für eine Anlage planen Sie?');
274 for (let schritt = 0; schritt < 3; schritt += 1) {
275 await assistent.getByRole('button', { name: 'Weiter' }).click();
276 }
277 await assistent.getByRole('button', { name: 'Knotenpunkt anlegen' }).click();
278
279 /*
280 * Der Fenstertitel sagt drei Dinge auf einmal, und alle drei gehoeren hier
281 * geprueft: der Punkt am Anfang, dass ungespeicherte Aenderungen bestehen;
282 * "Signalgruppen", dass die Ansicht des vorigen Falls stehen geblieben ist -
283 * ein neues Projekt verschlaegt den Anwender nicht anderswohin; und der Name
284 * aus dem Assistenten, dass wirklich dessen Projekt im Speicher liegt.
285 */
286 await expect(fenster).toHaveTitle(
287 '• Signalgruppen – Neuer Knotenpunkt – LSA-Planer Professional',
288 );
289
290 // Acht Signalgruppen: vier Kfz und vier Fussgaengerfurten - so legt der
291 // Assistent die Kreuzung mit seinen Vorgaben an.
292 await expect(fenster.locator('#inhalt')).toContainText('Signalgruppen (8)');
293 });
294
295 test('ein geaenderter Kennwert wirkt bis in eine andere Ansicht', async () => {
296 /*
297 * DAS EIGENTLICHE STUECK, das bis hierher nirgends am gebauten Stand stand:
298 * ein Planungswert, eingetragen in der einen Ansicht, nachgewiesen in einer
299 * anderen. Alles davor haette auch ein Programm bestanden, das die Eingaben
300 * entgegennimmt und nichts damit tut.
301 *
302 * Gewaehlt ist die zulaessige Hoechstgeschwindigkeit, weil ihre Wirkung im
303 * Regelwerk steht und nicht in einer Annahme dieses Programms: Nach der
304 * Gelbzeitstaffel der RiLSA gehoeren zu ueber 60 km/h 5 s Gelb statt 3 s.
305 * Die Gelbzeit IST im Regelansatz die Ueberfahrzeit tue, und tz = tue + tr −
306 * te - also muss jede Zwischenzeit, in der K1 raeumt, um genau dieselben 2 s
307 * laenger werden. Das ist die SICHERE Richtung; ein Durchlauf, der die
308 * Verlaengerung nicht saehe, saehe eine Verkuerzung ebenso wenig.
309 *
310 * DREI ANSICHTEN, weil erst die dritte den Nachweis fuehrt: eingetragen wird
311 * bei den Signalgruppen, die Gelbzeit steht im Signalzeitenplan, und die
312 * gerechnete Zwischenzeit in der Matrix.
313 */
314 await zurAnsicht('Zwischenzeiten');
315 const raeumtVorher = await zwischenzeitenZeile('K1');
316 expect(raeumtVorher.length, 'K1 raeumt gegen keine einzige Gruppe').toBeGreaterThan(0);
317
318 await zurAnsicht('Signalzeitenplan');
319 expect(await planZelle('K1', 'Gelb'), 'Gelbzeit bei 50 km/h').toBe('3 s');
320
321 await zurAnsicht('Signalgruppen');
322 await trageEin('Zulässige Höchstgeschwindigkeit in km/h, K1', String(ERHOEHTE_GESCHWINDIGKEIT));
323
324 await zurAnsicht('Signalzeitenplan');
325 expect(await planZelle('K1', 'Gelb'), 'Gelbzeit bei 70 km/h').toBe('5 s');
326
327 await zurAnsicht('Zwischenzeiten');
328 expect(
329 await zwischenzeitenZeile('K1'),
330 'die um 2 s laengere Gelbzeit steht nicht in den Zwischenzeiten',
331 ).toEqual(raeumtVorher.map((s) => s + 2));
332 });
333
334 test('eine eingetragene Verkehrsstaerke macht aus dem Plan eine Bemessung', async () => {
335 /*
336 * Der zweite Weg durch das Programm, und ein anderer: Die Geschwindigkeit
337 * oben aendert eine SIGNALZEIT, die Verkehrsstaerke aendert die BEWERTUNG.
338 * Ohne sie steht in der Spalte "Auslastung" ein Gedankenstrich - der Plan
339 * ist gueltig, aber nicht bemessen; die Fuehrungszeile sagt das selbst.
340 */
341 await zurAnsicht('Signalzeitenplan');
342 expect(await planZelle('K1', 'Auslastung'), 'ohne Verkehrsstärke keine Auslastung').toBe('–');
343
344 await zurAnsicht('Signalgruppen');
345 for (const gruppe of ['K1', 'K2', 'K3', 'K4']) {
346 await trageEin(`Verkehrsstärke in Fahrzeugen je Stunde, ${gruppe}`, String(VERKEHRSSTAERKE));
347 }
348 // Alle vier wirklich uebernommen - sonst bewiese der Nachweis unten nur
349 // etwas ueber die erste Gruppe.
350 for (const gruppe of ['K1', 'K2', 'K3', 'K4']) {
351 await expect(
352 fenster.getByRole('spinbutton', {
353 name: `Verkehrsstärke in Fahrzeugen je Stunde, ${gruppe}`,
354 }),
355 `die Verkehrsstaerke von ${gruppe} steht nicht im Feld`,
356 ).toHaveValue(String(VERKEHRSSTAERKE));
357 }
358
359 await zurAnsicht('Signalzeitenplan');
360 /*
361 * Auf eine ZAHL geprueft und nicht bloss auf "nicht mehr der
362 * Gedankenstrich": Ein leerer Wert, ein Wort oder ein "NaN" haetten die
363 * schwaechere Fassung ebenso bestanden. Der Auslastungsgrad x = q/C steht in
364 * der Tabelle als Verhaeltniszahl in deutscher Schreibweise, nicht als
365 * Prozentwert.
366 */
367 expect(await planZelle('K1', 'Auslastung'), 'kein Auslastungsgrad in der Tabelle').toMatch(
368 /^\d+,\d+$/u,
369 );
370 // Und die Kennzahl ueber der Tabelle ebenso. Auf die Beschriftung allein
371 // pruefen brachte nichts - die steht auch ohne jede Verkehrsstaerke da.
372 await expect(fenster.locator('#inhalt')).toContainText(/Größte Auslastung\s*\d/u);
373 });
374
375 test('vor dem Ausdruck fragt das Programm nach den Warnungen', async () => {
376 /*
377 * Die Rueckfrage ist kein Zierrat: Der Ausdruck geht in ein
378 * verkehrsbehoerdliches Verfahren, und die Warnungen sind fachlich zu
379 * begruenden. Geprueft wird hier BEIDES - dass gefragt wird, und dass ein
380 * Abbruch wirklich abbricht. Eine Rueckfrage, nach deren Abbruch trotzdem
381 * eine Datei entstuende, waere schlimmer als keine.
382 */
383 await zurAnsicht('Ausgabe');
384 const nichtErzeugt = join(ablage, 'nicht-erzeugt.pdf');
385 await speichernNach(nichtErzeugt);
386
387 await fenster.locator('#inhalt').getByRole('button', { name: 'PDF erzeugen' }).click();
388
389 const rueckfrage = fenster.getByRole('dialog');
390 await expect(rueckfrage).toContainText('Warnungen im Prüfbericht');
391 await rueckfrage.getByRole('button', { name: 'Abbrechen' }).click();
392
393 await expect(rueckfrage).toHaveCount(0);
394 expect(existsSync(nichtErzeugt), 'nach dem Abbruch steht trotzdem eine Datei da').toBe(false);
395 });
396
397 test('die Planunterlage entsteht wirklich als Datei', async () => {
398 const ziel = join(ablage, 'planunterlage.pdf');
399 await speichernNach(ziel);
400
401 await fenster.locator('#inhalt').getByRole('button', { name: 'PDF erzeugen' }).click();
402 await fenster.getByRole('dialog').getByRole('button', { name: 'PDF erzeugen' }).click();
403
404 await expect
405 .poll(() => existsSync(ziel), { timeout: 30_000, message: 'es entstand keine PDF-Datei' })
406 .toBe(true);
407
408 const bytes = readFileSync(ziel);
409 expect(bytes.subarray(0, 5).toString('latin1'), 'kein PDF-Kopf').toBe('%PDF-');
410
411 const text = pdfText(bytes);
412
413 /*
414 * Und es ist die vollstaendige Unterlage DIESES Projekts - nicht irgendein
415 * PDF und nicht bloss ein Deckblatt. Die Abschnittsueberschriften stehen
416 * hier, weil eine Unterlage, die nach dem Deckblatt abbricht, den Kopf und
417 * die Kennzahlen trotzdem truege.
418 */
419 for (const abschnitt of [
420 'Phasen und Phasenübergänge',
421 'Signalzeitenplan',
422 'Zwischenzeiten',
423 'Leistungsfähigkeit',
424 'Prüfbericht',
425 'Grundlagen der Kennwerte',
426 ]) {
427 expect(text, `der Abschnitt "${abschnitt}" fehlt in der Unterlage`).toContain(abschnitt);
428 }
429
430 expect(text, 'der Projektname steht nicht in der Unterlage').toContain('Neuer Knotenpunkt');
431
432 /*
433 * DIE EINGABE VON HAND, und zwar an ihrer Tabellenzeile statt an der blossen
434 * Zahl. GEMESSEN: "70 km/h" steht in JEDEM Ausdruck zweimal, auch bei
435 * unveraenderten 50 km/h - einmal in der Erlaeuterung der Gelbzeitstaffel
436 * ("3 s bis 50 km/h, 4 s bis 60 km/h, 5 s bis 70 km/h") und einmal im
437 * Fundstellenverzeichnis. `toContain('70 km/h')` haette deshalb auch dann
438 * bestanden, wenn der eingetragene Wert nirgends im Papier angekommen waere.
439 * Die Zeile der Signalgruppe entsteht dagegen nur aus dem Wert selbst.
440 */
441 expect(text, 'die Eingabe von Hand fehlt in der Signalgruppentabelle').toContain(
442 `K1 Kraftfahrzeuge geradeaus ${ERHOEHTE_GESCHWINDIGKEIT} km/h`,
443 );
444
445 // Die Nachbardatei des unteilbaren Schreibens muss fort sein.
446 expect(
447 readdirSync(ablage).filter((n) => n.endsWith('.teil')),
448 'eine Nachbardatei ist liegen geblieben',
449 ).toEqual([]);
450 });
451
452 /** Wohin der Durchlauf sein Projekt speichert - der naechste Fall liest es. */
453 const PROJEKTDATEI = 'durchlauf.lsap';
454
455 test('die Projektdatei wird geschrieben und traegt den Stand des Durchlaufs', async () => {
456 const ziel = join(ablage, PROJEKTDATEI);
457 await speichernNach(ziel);
458
459 await fenster.getByRole('button', { name: 'Speichern', exact: true }).click();
460 await expect
461 .poll(() => existsSync(ziel), { timeout: 30_000, message: 'es entstand keine Projektdatei' })
462 .toBe(true);
463
464 /*
465 * Gelesen wird hier von Hand und nicht ueber das Programm: Ein zweiter Weg
466 * auf dieselbe Datei sagt mehr als derselbe Weg zurueck. Geprueft wird, dass
467 * darin wirklich der Stand des Durchlaufs steht - einschliesslich der Zahl,
468 * die von Hand eingetragen wurde. Ueber das Programm liest sie der Fall
469 * darunter.
470 */
471 const roh: unknown = JSON.parse(readFileSync(ziel, 'utf8'));
472 const projekt = roh as {
473 signalGroups?: readonly { name?: unknown; vZul?: unknown }[];
474 };
475 expect(projekt.signalGroups, 'die Datei hat keine Signalgruppen').toHaveLength(8);
476 const k1 = projekt.signalGroups?.find((g) => g.name === 'K1');
477 expect(k1?.vZul, 'die geaenderte Geschwindigkeit steht nicht in der Datei').toBe(
478 ERHOEHTE_GESCHWINDIGKEIT,
479 );
480
481 expect(
482 readdirSync(ablage).filter((n) => n.endsWith('.teil')),
483 'eine Nachbardatei ist liegen geblieben',
484 ).toEqual([]);
485 });
486
487 test('dieselbe Datei laesst sich oeffnen und bringt den Stand zurueck', async () => {
488 /*
489 * ERST DAS SCHLIESSEN DES KREISES macht die Datei zur Planunterlage: Eine
490 * Datei, die geschrieben, aber nie wieder geoeffnet wird, ist ein Ausdruck
491 * auf Papier. Geprueft wird deshalb der Weg zurueck durch das Programm -
492 * und damit auch alles, was daran haengt: der Oeffnen-Dialog, die
493 * Freigabeliste des Hauptprozesses, das Einlesen samt Schemapruefung.
494 *
495 * Damit das Oeffnen ueberhaupt etwas beweisen kann, wird der Stand vorher
496 * VERAENDERT. Ohne diesen Schritt saehe man dem Ergebnis nicht an, ob die
497 * Datei gelesen wurde oder ob einfach stehen blieb, was ohnehin da war.
498 */
499 await zurAnsicht('Signalgruppen');
500 await trageEin('Zulässige Höchstgeschwindigkeit in km/h, K1', '50');
501 await zurAnsicht('Signalzeitenplan');
502 expect(await planZelle('K1', 'Gelb'), 'die Aenderung ist gar nicht angekommen').toBe('3 s');
503
504 await oeffnenAus(join(ablage, PROJEKTDATEI));
505 await fenster.getByRole('button', { name: 'Öffnen', exact: true }).click();
506
507 // Der geaenderte Stand ist ungespeichert - davor warnt das Programm, und
508 // ohne diese Antwort geschieht nichts.
509 const rueckfrage = fenster.getByRole('dialog');
510 await expect(rueckfrage).toContainText('Ungespeicherte Änderungen');
511 await rueckfrage.getByRole('button', { name: 'Verwerfen und öffnen' }).click();
512
513 await zurAnsicht('Signalzeitenplan');
514 expect(await planZelle('K1', 'Gelb'), 'die Datei hat den Stand nicht zurueckgebracht').toBe(
515 '5 s',
516 );
517 });
518
519 test('der Renderer hat waehrend des ganzen Durchlaufs nichts gemeldet', () => {
520 /*
521 * Zum Schluss und nicht am Anfang: Der Rauchtest prueft den START auf
522 * Fehlerfreiheit, dieser Fall den BETRIEB. Eine Ausnahme, die erst beim
523 * dritten Ansichtswechsel oder beim Erzeugen des PDF geworfen wird, faellt
524 * nur hier auf.
525 *
526 * DASS ER DER LETZTE IST, HAT EINEN PREIS, und der gehoert genannt: In einer
527 * Kette mit `mode: 'serial'` wird uebersprungen, was auf einen gefallenen
528 * Fall folgt. Bricht der Durchlauf vorher ab, laeuft dieser Fall gar nicht -
529 * die gesammelten Meldungen bleiben dann ungelesen. Der Bruch selbst faellt
530 * trotzdem auf; nur die Konsolenmeldung dazu nicht.
531 */
532 expect(fehlerImLauf).toEqual([]);
533 });