lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 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 | }); |