import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { inflateSync } from 'node:zlib'; import { _electron as electron, expect, test, type ElectronApplication, type Page, } from '@playwright/test'; /** * Ein ganzer Durchlauf am gebauten Stand - vom leeren Projekt bis zur Datei. * * WARUM ES DIESE DATEI GIBT. Die Modultests bedienen die Oberflaeche wirklich: * `tests/ui/**` klickt, tippt und loest Ereignisse aus. Nur tut es das in jsdom * - gegen einen nachgebauten Baum, nicht gegen das Erzeugnis. Der Rauchtest * daneben startet das gebaute Programm, klickt darin aber nichts: keine * Eingabe, kein Ansichtswechsel, kein angelegtes Projekt; von den elf Ansichten * wird eine einzige ueberhaupt gezeichnet. Dazwischen lag eine Luecke, und in * ihr lag die Frage, die ein Anwender stellt: Kommt man im echten Fenster von * einem leeren Projekt bis zur fertigen Planunterlage? * * WAS DIESE DATEI NICHT IST: ein zweiter Rauchtest. Der beantwortet, ob das * Erzeugnis heil ist - Bruecke, Abschottung, Sicherheitsrichtlinie - und prueft * dafuer bewusst wenig. Diese Datei beantwortet, ob es sich bedienen laesst. * * SIE IST EINE KETTE, und deshalb `mode: 'serial'`: Jeder Fall setzt auf dem * Stand des vorigen auf. Faellt ein Glied, sind die folgenden uebersprungen und * nicht rot - ein Folgefehler ist kein zweiter Befund. * * WAS SIE NICHT LEISTET: Sie faehrt EINEN Weg durch das Programm, den einer * Kreuzung aus dem gefuehrten Einstieg. Dass jede Anlagenart, jedes * Bewertungsverfahren und jede Verkehrsart rechnet, pruefen die Modultests - * hier geht es um die Kette, nicht um die Breite. */ test.describe.configure({ mode: 'serial' }); const WURZEL = join(__dirname, '..'); /** * Zulaessige Hoechstgeschwindigkeit, auf die der Durchlauf K1 anhebt. * * 70 km/h und nicht 60: Die Gelbzeitstaffel der RiLSA hat ihre Stufen bei 50 * und 60 km/h (`yellowBySpeed` in src/domain/rilsa/constants.ts). 60 km/h * traefe die mittlere Stufe und braechte eine Sekunde mehr Gelb, 70 km/h die * oberste und zwei. Zwei sind der belastbarere Nachweis: Die Zwischenzeit wird * auf ganze Sekunden AUFGERUNDET, ein Unterschied von einer Sekunde kann dabei * verschwinden, einer von zwei Sekunden nicht. */ const ERHOEHTE_GESCHWINDIGKEIT = 70; /** Verkehrsstaerke, die der Durchlauf jeder Kfz-Gruppe gibt (Fz/h). */ const VERKEHRSSTAERKE = 400; let anwendung: ElectronApplication; let fenster: Page; /** Eigenes Verzeichnis fuer die Dateien, die dieser Durchlauf schreibt. */ let ablage: string; const fehlerImLauf: string[] = []; /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ function pdfText(bytes: Buffer): string { const roh = bytes.toString('latin1'); const teile: string[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } for (const stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) { teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')'))); } } return teile.join(' ').replace(/\s+/g, ' '); } /** * Laesst den naechsten Speichern-Dialog ohne Zutun auf `ziel` zeigen. * * Der Dialog ist ein Fenster des Betriebssystems - Playwright kann ihn nicht * bedienen, und ein Durchlauf, der darauf wartet, bliebe stehen. Ersetzt wird * deshalb nur die FRAGE, nicht die Antwort darauf: Alles, was danach kommt - * die Freigabeliste (`approvePath`) und das unteilbare Schreiben ueber die * Nachbardatei (`schreibeUnteilbar`) -, laeuft unveraendert im Hauptprozess. */ async function speichernNach(ziel: string): Promise { await anwendung.evaluate(({ dialog }, pfad) => { dialog.showSaveDialog = () => Promise.resolve({ canceled: false, filePath: pfad }); }, ziel); } /** Dasselbe fuer den Oeffnen-Dialog - aus demselben Grund. */ async function oeffnenAus(quelle: string): Promise { await anwendung.evaluate(({ dialog }, pfad) => { dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [pfad] }); }, quelle); } /** * Traegt eine Zahl in ein Feld ein - so, wie ein Anwender es tut. * * ERST KLICKEN, DANN SCHREIBEN, und das ist kein Zierrat. GEMESSEN: Ohne den * Klick kam beim zweiten und vierten Feld einer Folge am Dokument WEDER ein * `focus`- NOCH ein `change`-Ereignis an, und der Wert blieb leer - die * Tabelle baut sich nach jeder Uebernahme neu auf, und die Eingabe traf dann * einen Knoten, der nicht mehr im Baum stand. Eine Wartezeit half nicht, der * Klick hilft. Ein Anwender klickt ohnehin zuerst hinein. */ async function trageEin(feldname: string, wert: string): Promise { const feld = fenster.getByRole('spinbutton', { name: feldname }); await feld.click(); await feld.fill(wert); await feld.press('Tab'); } /** Wechselt ueber die Navigationsleiste in eine Ansicht - wie ein Anwender. */ async function zurAnsicht(beschriftung: string): Promise { await fenster .getByRole('button', { name: new RegExp(`Schritt \\d+, ${beschriftung}`, 'u') }) .click(); await expect(fenster.locator('#inhalt h1')).toHaveText(beschriftung); } /** * Eine Zelle der Tabelle "Signalzeiten je Signalgruppe" im Signalzeitenplan. * * Ueber die Spaltenueberschrift und nicht ueber eine feste Spaltennummer: Eine * eingeschobene Spalte machte den Fall sonst still falsch, statt ihn rot zu * faerben. */ async function planZelle(gruppe: string, spalte: string): Promise { const text = await fenster.locator('#inhalt').innerText(); const zeilen = text.split('\n'); const kopf = zeilen.find((z) => z.startsWith('Signalgruppe\t')); expect(kopf, 'die Tabelle "Signalzeiten je Signalgruppe" fehlt').toBeDefined(); const spalten = String(kopf).split('\t'); const wo = spalten.indexOf(spalte); expect(wo, `Spalte "${spalte}" nicht gefunden in: ${spalten.join(' | ')}`).toBeGreaterThan(-1); const zeile = zeilen.find((z) => z.startsWith(`${gruppe}\t`)); expect(zeile, `keine Planzeile fuer ${gruppe}`).toBeDefined(); return String(String(zeile).split('\t')[wo]).trim(); } /** * Die gerechneten Zwischenzeiten einer Zeile der Matrix - also die Zeiten, in * denen diese Signalgruppe RAEUMT. * * Was keine reine Ziffernfolge ist, faellt heraus: die leere Hauptdiagonale * und der Punkt fuer "vertraeglich, kein Konflikt". Eine Zwischenzeit von 0 s * faellt damit ebenfalls heraus - die Matrix zeichnet sie mit einem Ausrufe- * zeichen dahinter ("0 !"), und die Kreuzung des gefuehrten Einstiegs hat * keine. Der Fall unten vergleicht die Zeile mit sich selbst; solange nichts * herausfaellt, was vorher darin war, traegt das. */ async function zwischenzeitenZeile(gruppe: string): Promise { const text = await fenster.locator('#inhalt').innerText(); const zeile = text.split('\n').find((z) => z.startsWith(`${gruppe}\t`)); expect(zeile, `keine Matrixzeile fuer ${gruppe}`).toBeDefined(); return String(zeile) .split('\t') .slice(1) .map((z) => z.trim()) .filter((z) => /^\d+$/u.test(z)) .map(Number); } test.beforeAll(async () => { ablage = mkdtempSync(join(tmpdir(), 'lsa-planer-durchlauf-')); /* * Eigenes, leeres Anwenderverzeichnis - aus demselben Grund wie im * Rauchtest: Sonst faengt der Durchlauf nicht bei einem leeren Projekt an, * sondern bei dem, was jemand zuletzt auf diesem Rechner bearbeitet hat. */ anwendung = await electron.launch({ args: [WURZEL, `--user-data-dir=${join(ablage, 'profil')}`], cwd: WURZEL, }); fenster = await anwendung.firstWindow(); fenster.on('pageerror', (fehler) => fehlerImLauf.push(String(fehler))); fenster.on('console', (zeile) => { if (zeile.type() === 'error') fehlerImLauf.push(zeile.text()); }); await fenster.waitForLoadState('domcontentloaded'); await expect(fenster.locator('#inhalt h1')).toHaveText('Projekt'); }); test.afterAll(async () => { /* * Das Schliessen kann eine Rueckfrage ausloesen: Bestehen ungespeicherte * Aenderungen, fragt der Hauptprozess beim Renderer nach, und der zeigt * einen Dialog. Wartete hier nur `close()`, stuende der Lauf bis zur * Zeitschranke - gemessen: 60 s je Datei. Der Durchlauf speichert am Ende * zwar, aber ein abgebrochenes Glied laesst den Stand ungespeichert zurueck. */ /* * Beide Fragezeichen sehen nach dem Typ ueberfluessig aus und sind es * nicht: `anwendung` und `fenster` werden erst in beforeAll gesetzt. * Schlaegt der Start fehl, laeuft afterAll trotzdem - und ohne die Abfrage * ueberdeckte hier ein TypeError den eigentlichen Startfehler, also genau * die Meldung, wegen der es diese Datei gibt. */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- anwendung ist vor beforeAll undefined; siehe darueber const schliessen = anwendung === undefined ? Promise.resolve() : anwendung.close(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- fenster ist vor beforeAll undefined; siehe darueber if (fenster !== undefined) { await fenster .getByRole('button', { name: 'Ohne Speichern schließen' }) .click({ timeout: 3000 }) .catch(() => undefined); } await schliessen; /* * Und aufraeumen. Der Durchlauf legt in seinem Verzeichnis ein * Anwenderprofil, zwei PDF und eine Projektdatei ab - gemessen rund 4 MB je * Lauf. Der Rauchtest daneben laesst seines stehen; das ist kein Grund, es * ihm nachzutun, sondern einer, es hier besser zu machen. `force` und * `maxRetries`, weil Windows eine eben geschlossene Datei noch einen * Augenblick haelt. */ rmSync(ablage, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }); test('der erste Start beginnt bei einem leeren Projekt', async () => { /* * Ohne diesen Fall bewiese die Kette weniger, als sie soll: Faenge sie bei * einem Stand an, den ein frueherer Lauf hinterlassen hat, waere nicht * gezeigt, dass ein Anwender von vorn dorthin kommt. */ await expect(fenster).toHaveTitle('Projekt – Neues Projekt – LSA-Planer Professional'); /* * Die Marke wird VOR dem Wechsel geprueft, und das ist kein Zufall der * Reihenfolge: `markiereLeiste` setzt sie ausdruecklich nicht am Eintrag der * offenen Ansicht - dort sagt `aria-current` schon, wo man ist. Wer erst * hinginge und dann suchte, faende nichts und hielte es fuer einen Befund. */ await expect( fenster.getByRole('button', { name: 'Schritt 3, Signalgruppen , als Nächstes dran' }), 'die Leiste zeigt die Signalgruppen nicht als naechsten Schritt', ).toBeAttached(); await zurAnsicht('Signalgruppen'); await expect(fenster.locator('#inhalt')).toContainText('Noch keine Signalgruppe angelegt'); }); test('der gefuehrte Einstieg legt eine vollstaendige Kreuzung an', async () => { await fenster.getByRole('button', { name: 'Neu', exact: true }).click(); const auswahl = fenster.getByRole('dialog'); await expect(auswahl).toContainText('Womit möchten Sie beginnen?'); await auswahl.getByRole('button', { name: 'Geführter Einstieg' }).click(); // Vier Schritte bei der Kreuzung; die Vorgaben werden uebernommen, wie sie // sind - geprueft wird der Weg, nicht das Ausfuellen jedes Feldes. const assistent = fenster.getByRole('dialog'); await expect(assistent).toContainText('Was für eine Anlage planen Sie?'); for (let schritt = 0; schritt < 3; schritt += 1) { await assistent.getByRole('button', { name: 'Weiter' }).click(); } await assistent.getByRole('button', { name: 'Knotenpunkt anlegen' }).click(); /* * Der Fenstertitel sagt drei Dinge auf einmal, und alle drei gehoeren hier * geprueft: der Punkt am Anfang, dass ungespeicherte Aenderungen bestehen; * "Signalgruppen", dass die Ansicht des vorigen Falls stehen geblieben ist - * ein neues Projekt verschlaegt den Anwender nicht anderswohin; und der Name * aus dem Assistenten, dass wirklich dessen Projekt im Speicher liegt. */ await expect(fenster).toHaveTitle( '• Signalgruppen – Neuer Knotenpunkt – LSA-Planer Professional', ); // Acht Signalgruppen: vier Kfz und vier Fussgaengerfurten - so legt der // Assistent die Kreuzung mit seinen Vorgaben an. await expect(fenster.locator('#inhalt')).toContainText('Signalgruppen (8)'); }); test('ein geaenderter Kennwert wirkt bis in eine andere Ansicht', async () => { /* * DAS EIGENTLICHE STUECK, das bis hierher nirgends am gebauten Stand stand: * ein Planungswert, eingetragen in der einen Ansicht, nachgewiesen in einer * anderen. Alles davor haette auch ein Programm bestanden, das die Eingaben * entgegennimmt und nichts damit tut. * * Gewaehlt ist die zulaessige Hoechstgeschwindigkeit, weil ihre Wirkung im * Regelwerk steht und nicht in einer Annahme dieses Programms: Nach der * Gelbzeitstaffel der RiLSA gehoeren zu ueber 60 km/h 5 s Gelb statt 3 s. * Die Gelbzeit IST im Regelansatz die Ueberfahrzeit tue, und tz = tue + tr − * te - also muss jede Zwischenzeit, in der K1 raeumt, um genau dieselben 2 s * laenger werden. Das ist die SICHERE Richtung; ein Durchlauf, der die * Verlaengerung nicht saehe, saehe eine Verkuerzung ebenso wenig. * * DREI ANSICHTEN, weil erst die dritte den Nachweis fuehrt: eingetragen wird * bei den Signalgruppen, die Gelbzeit steht im Signalzeitenplan, und die * gerechnete Zwischenzeit in der Matrix. */ await zurAnsicht('Zwischenzeiten'); const raeumtVorher = await zwischenzeitenZeile('K1'); expect(raeumtVorher.length, 'K1 raeumt gegen keine einzige Gruppe').toBeGreaterThan(0); await zurAnsicht('Signalzeitenplan'); expect(await planZelle('K1', 'Gelb'), 'Gelbzeit bei 50 km/h').toBe('3 s'); await zurAnsicht('Signalgruppen'); await trageEin('Zulässige Höchstgeschwindigkeit in km/h, K1', String(ERHOEHTE_GESCHWINDIGKEIT)); await zurAnsicht('Signalzeitenplan'); expect(await planZelle('K1', 'Gelb'), 'Gelbzeit bei 70 km/h').toBe('5 s'); await zurAnsicht('Zwischenzeiten'); expect( await zwischenzeitenZeile('K1'), 'die um 2 s laengere Gelbzeit steht nicht in den Zwischenzeiten', ).toEqual(raeumtVorher.map((s) => s + 2)); }); test('eine eingetragene Verkehrsstaerke macht aus dem Plan eine Bemessung', async () => { /* * Der zweite Weg durch das Programm, und ein anderer: Die Geschwindigkeit * oben aendert eine SIGNALZEIT, die Verkehrsstaerke aendert die BEWERTUNG. * Ohne sie steht in der Spalte "Auslastung" ein Gedankenstrich - der Plan * ist gueltig, aber nicht bemessen; die Fuehrungszeile sagt das selbst. */ await zurAnsicht('Signalzeitenplan'); expect(await planZelle('K1', 'Auslastung'), 'ohne Verkehrsstärke keine Auslastung').toBe('–'); await zurAnsicht('Signalgruppen'); for (const gruppe of ['K1', 'K2', 'K3', 'K4']) { await trageEin(`Verkehrsstärke in Fahrzeugen je Stunde, ${gruppe}`, String(VERKEHRSSTAERKE)); } // Alle vier wirklich uebernommen - sonst bewiese der Nachweis unten nur // etwas ueber die erste Gruppe. for (const gruppe of ['K1', 'K2', 'K3', 'K4']) { await expect( fenster.getByRole('spinbutton', { name: `Verkehrsstärke in Fahrzeugen je Stunde, ${gruppe}`, }), `die Verkehrsstaerke von ${gruppe} steht nicht im Feld`, ).toHaveValue(String(VERKEHRSSTAERKE)); } await zurAnsicht('Signalzeitenplan'); /* * Auf eine ZAHL geprueft und nicht bloss auf "nicht mehr der * Gedankenstrich": Ein leerer Wert, ein Wort oder ein "NaN" haetten die * schwaechere Fassung ebenso bestanden. Der Auslastungsgrad x = q/C steht in * der Tabelle als Verhaeltniszahl in deutscher Schreibweise, nicht als * Prozentwert. */ expect(await planZelle('K1', 'Auslastung'), 'kein Auslastungsgrad in der Tabelle').toMatch( /^\d+,\d+$/u, ); // Und die Kennzahl ueber der Tabelle ebenso. Auf die Beschriftung allein // pruefen brachte nichts - die steht auch ohne jede Verkehrsstaerke da. await expect(fenster.locator('#inhalt')).toContainText(/Größte Auslastung\s*\d/u); }); test('vor dem Ausdruck fragt das Programm nach den Warnungen', async () => { /* * Die Rueckfrage ist kein Zierrat: Der Ausdruck geht in ein * verkehrsbehoerdliches Verfahren, und die Warnungen sind fachlich zu * begruenden. Geprueft wird hier BEIDES - dass gefragt wird, und dass ein * Abbruch wirklich abbricht. Eine Rueckfrage, nach deren Abbruch trotzdem * eine Datei entstuende, waere schlimmer als keine. */ await zurAnsicht('Ausgabe'); const nichtErzeugt = join(ablage, 'nicht-erzeugt.pdf'); await speichernNach(nichtErzeugt); await fenster.locator('#inhalt').getByRole('button', { name: 'PDF erzeugen' }).click(); const rueckfrage = fenster.getByRole('dialog'); await expect(rueckfrage).toContainText('Warnungen im Prüfbericht'); await rueckfrage.getByRole('button', { name: 'Abbrechen' }).click(); await expect(rueckfrage).toHaveCount(0); expect(existsSync(nichtErzeugt), 'nach dem Abbruch steht trotzdem eine Datei da').toBe(false); }); test('die Planunterlage entsteht wirklich als Datei', async () => { const ziel = join(ablage, 'planunterlage.pdf'); await speichernNach(ziel); await fenster.locator('#inhalt').getByRole('button', { name: 'PDF erzeugen' }).click(); await fenster.getByRole('dialog').getByRole('button', { name: 'PDF erzeugen' }).click(); await expect .poll(() => existsSync(ziel), { timeout: 30_000, message: 'es entstand keine PDF-Datei' }) .toBe(true); const bytes = readFileSync(ziel); expect(bytes.subarray(0, 5).toString('latin1'), 'kein PDF-Kopf').toBe('%PDF-'); const text = pdfText(bytes); /* * Und es ist die vollstaendige Unterlage DIESES Projekts - nicht irgendein * PDF und nicht bloss ein Deckblatt. Die Abschnittsueberschriften stehen * hier, weil eine Unterlage, die nach dem Deckblatt abbricht, den Kopf und * die Kennzahlen trotzdem truege. */ for (const abschnitt of [ 'Phasen und Phasenübergänge', 'Signalzeitenplan', 'Zwischenzeiten', 'Leistungsfähigkeit', 'Prüfbericht', 'Grundlagen der Kennwerte', ]) { expect(text, `der Abschnitt "${abschnitt}" fehlt in der Unterlage`).toContain(abschnitt); } expect(text, 'der Projektname steht nicht in der Unterlage').toContain('Neuer Knotenpunkt'); /* * DIE EINGABE VON HAND, und zwar an ihrer Tabellenzeile statt an der blossen * Zahl. GEMESSEN: "70 km/h" steht in JEDEM Ausdruck zweimal, auch bei * unveraenderten 50 km/h - einmal in der Erlaeuterung der Gelbzeitstaffel * ("3 s bis 50 km/h, 4 s bis 60 km/h, 5 s bis 70 km/h") und einmal im * Fundstellenverzeichnis. `toContain('70 km/h')` haette deshalb auch dann * bestanden, wenn der eingetragene Wert nirgends im Papier angekommen waere. * Die Zeile der Signalgruppe entsteht dagegen nur aus dem Wert selbst. */ expect(text, 'die Eingabe von Hand fehlt in der Signalgruppentabelle').toContain( `K1 Kraftfahrzeuge geradeaus ${ERHOEHTE_GESCHWINDIGKEIT} km/h`, ); // Die Nachbardatei des unteilbaren Schreibens muss fort sein. expect( readdirSync(ablage).filter((n) => n.endsWith('.teil')), 'eine Nachbardatei ist liegen geblieben', ).toEqual([]); }); /** Wohin der Durchlauf sein Projekt speichert - der naechste Fall liest es. */ const PROJEKTDATEI = 'durchlauf.lsap'; test('die Projektdatei wird geschrieben und traegt den Stand des Durchlaufs', async () => { const ziel = join(ablage, PROJEKTDATEI); await speichernNach(ziel); await fenster.getByRole('button', { name: 'Speichern', exact: true }).click(); await expect .poll(() => existsSync(ziel), { timeout: 30_000, message: 'es entstand keine Projektdatei' }) .toBe(true); /* * Gelesen wird hier von Hand und nicht ueber das Programm: Ein zweiter Weg * auf dieselbe Datei sagt mehr als derselbe Weg zurueck. Geprueft wird, dass * darin wirklich der Stand des Durchlaufs steht - einschliesslich der Zahl, * die von Hand eingetragen wurde. Ueber das Programm liest sie der Fall * darunter. */ const roh: unknown = JSON.parse(readFileSync(ziel, 'utf8')); const projekt = roh as { signalGroups?: readonly { name?: unknown; vZul?: unknown }[]; }; expect(projekt.signalGroups, 'die Datei hat keine Signalgruppen').toHaveLength(8); const k1 = projekt.signalGroups?.find((g) => g.name === 'K1'); expect(k1?.vZul, 'die geaenderte Geschwindigkeit steht nicht in der Datei').toBe( ERHOEHTE_GESCHWINDIGKEIT, ); expect( readdirSync(ablage).filter((n) => n.endsWith('.teil')), 'eine Nachbardatei ist liegen geblieben', ).toEqual([]); }); test('dieselbe Datei laesst sich oeffnen und bringt den Stand zurueck', async () => { /* * ERST DAS SCHLIESSEN DES KREISES macht die Datei zur Planunterlage: Eine * Datei, die geschrieben, aber nie wieder geoeffnet wird, ist ein Ausdruck * auf Papier. Geprueft wird deshalb der Weg zurueck durch das Programm - * und damit auch alles, was daran haengt: der Oeffnen-Dialog, die * Freigabeliste des Hauptprozesses, das Einlesen samt Schemapruefung. * * Damit das Oeffnen ueberhaupt etwas beweisen kann, wird der Stand vorher * VERAENDERT. Ohne diesen Schritt saehe man dem Ergebnis nicht an, ob die * Datei gelesen wurde oder ob einfach stehen blieb, was ohnehin da war. */ await zurAnsicht('Signalgruppen'); await trageEin('Zulässige Höchstgeschwindigkeit in km/h, K1', '50'); await zurAnsicht('Signalzeitenplan'); expect(await planZelle('K1', 'Gelb'), 'die Aenderung ist gar nicht angekommen').toBe('3 s'); await oeffnenAus(join(ablage, PROJEKTDATEI)); await fenster.getByRole('button', { name: 'Öffnen', exact: true }).click(); // Der geaenderte Stand ist ungespeichert - davor warnt das Programm, und // ohne diese Antwort geschieht nichts. const rueckfrage = fenster.getByRole('dialog'); await expect(rueckfrage).toContainText('Ungespeicherte Änderungen'); await rueckfrage.getByRole('button', { name: 'Verwerfen und öffnen' }).click(); await zurAnsicht('Signalzeitenplan'); expect(await planZelle('K1', 'Gelb'), 'die Datei hat den Stand nicht zurueckgebracht').toBe( '5 s', ); }); test('der Renderer hat waehrend des ganzen Durchlaufs nichts gemeldet', () => { /* * Zum Schluss und nicht am Anfang: Der Rauchtest prueft den START auf * Fehlerfreiheit, dieser Fall den BETRIEB. Eine Ausnahme, die erst beim * dritten Ansichtswechsel oder beim Erzeugen des PDF geworfen wird, faellt * nur hier auf. * * DASS ER DER LETZTE IST, HAT EINEN PREIS, und der gehoert genannt: In einer * Kette mit `mode: 'serial'` wird uebersprungen, was auf einen gefallenen * Fall folgt. Bricht der Durchlauf vorher ab, laeuft dieser Fall gar nicht - * die gesammelten Meldungen bleiben dann ungelesen. Der Bruch selbst faellt * trotzdem auf; nur die Konsolenmeldung dazu nicht. */ expect(fehlerImLauf).toEqual([]); });