lsa-planer

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

/ tests ui ausgabeFokus.test.ts

6,6 KB Rohdatei
tests/ui/ausgabeFokus.test.ts — 169 Zeilen
1 import { afterEach, describe, expect, it, vi } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import { validateProject } from '@/domain/validation';
6 import type { FileOperationResult } from '@/services/storage';
7 import type { ViewContext } from '@/ui/shell';
8 import { exportView } from '@/ui/views/exportView';
9
10 /**
11 * Befund 49: Die Ansicht "Ausgabe" zeichnete sich selbst neu, ohne den Fokus
12 * zu bewahren.
13 *
14 * WAS DER ANWENDER SONST ERLEBT
15 *
16 * Weg 1 - Tastatur und Kontrollkaestchen: Er stellt in der Ausgabe mit dem
17 * Tabulator zusammen, was in die Planunterlage soll, und drueckt zwischendurch
18 * Strg+S. Bei gesetztem Dateipfad speichert das ohne Dialog, `markSaved()`
19 * benachrichtigt die Empfaenger, die Ansicht baut ihren ganzen Bedienteil neu
20 * auf - und der Fokus faellt auf den Seitenkoerper. Der naechste Tabulator
21 * beginnt wieder bei der Sprungmarke am Dokumentanfang, also acht Kaestchen,
22 * sechs Tabellenknoepfe und die ganze Navigation weiter vorn.
23 *
24 * Weg 2 - PDF: Er geht mit dem Tabulator auf "PDF erzeugen" und drueckt die
25 * Eingabetaste. Der Knopf wird fuer die Dauer des Laufs gesperrt, die Ansicht
26 * zeichnet neu, der gedrueckte Knopf ist danach nicht mehr im Baum. Nach dem
27 * Speichern zeichnet sie ein zweites Mal neu. Beide Male bleibt der Fokus auf
28 * dem Seitenkoerper liegen; wer nur die Tastatur hat, muss sich zum eben
29 * betaetigten Knopf zurueckarbeiten.
30 */
31
32 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
33
34 /**
35 * Ersetzt wird nur die Aussenwelt: der Speicherdialog und das Erzeugen der
36 * PDF-Bytes. Alles andere an der Ansicht bleibt das echte Modul - sonst
37 * pruefte dieser Fall eine Nachbildung statt der Anwendung.
38 */
39 const stub = vi.hoisted(() => ({ gespeichert: 0 }));
40
41 vi.mock('@/services/storage', async (importOriginal) => {
42 const echt = await importOriginal<typeof import('@/services/storage')>();
43 return {
44 ...echt,
45 saveDataToFile: (): Promise<FileOperationResult> => {
46 stub.gespeichert += 1;
47 return Promise.resolve({
48 ok: true,
49 canceled: false,
50 filePath: 'C:/Plan.pdf',
51 message: 'Gespeichert: C:/Plan.pdf',
52 });
53 },
54 };
55 });
56
57 vi.mock('@/services/export/pdf', async (importOriginal) => {
58 const echt = await importOriginal<typeof import('@/services/export/pdf')>();
59 return { ...echt, buildProjectPdf: (): Uint8Array => new Uint8Array([37, 80, 68, 70]) };
60 });
61
62 function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement {
63 const gefunden = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].find(
64 (b) => b.textContent?.trim() === beschriftung,
65 );
66 if (!gefunden) throw new Error(`Kein Knopf "${beschriftung}"`);
67 return gefunden;
68 }
69
70 function baueAnsicht(): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
71 const projekt = createStandardIntersectionProject('Fokus Ausgabe', FESTES_DATUM);
72 // Die Ansicht ist nur bei freiem Export vollstaendig bedienbar.
73 expect(
74 validateProject(projekt, buildSignalPlan(projekt), FESTES_DATUM).exportBlocked,
75 'Vorbedingung: Export nicht gesperrt',
76 ).toBe(false);
77 const wurzel = document.createElement('div');
78 document.body.append(wurzel);
79 const store = new ProjectStore(projekt);
80 const kontext: ViewContext = {
81 store,
82 refresh: () => {},
83 navigate: () => {},
84 target: null,
85 };
86 const schliesse = exportView.render(wurzel, kontext);
87 return {
88 wurzel,
89 store,
90 aufraeumen: () => {
91 schliesse();
92 wurzel.remove();
93 },
94 };
95 }
96
97 /** Einen Durchlauf der Aufgabenschlange abwarten. */
98 const abwarten = (): Promise<void> =>
99 new Promise((fertig) => {
100 setTimeout(fertig, 0);
101 });
102
103 afterEach(() => {
104 stub.gespeichert = 0;
105 document.body.replaceChildren();
106 });
107
108 describe('Ausgabe: Der Fokus ueberlebt den Neuaufbau der Ansicht (Befund 49)', () => {
109 it('bleibt beim Speichern im Kontrollkaestchen stehen, statt auf den Seitenkoerper zu fallen', () => {
110 const { wurzel, store, aufraeumen } = baueAnsicht();
111 try {
112 const kaestchen = [...wurzel.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
113 // Neun seit dem 11.09.2026: Die Begruendung der Umlaufzeit ist ein
114 // eigener, abwaehlbarer Abschnitt der Unterlage.
115 expect(kaestchen.length, 'neun Schalter fuer den Inhalt der Planunterlagen').toBe(9);
116 const viertes = kaestchen[3]!;
117 viertes.focus();
118 expect(document.activeElement).toBe(viertes);
119
120 // Strg+S bei gesetztem Dateipfad: speichert ohne Dialog und
121 // benachrichtigt die Empfaenger.
122 store.markSaved('C:/Projekt.lsap');
123
124 // Der Neuaufbau ist wirklich gelaufen - sonst prueft der Fall nichts.
125 expect(viertes.isConnected, 'die Ansicht muss neu gezeichnet worden sein').toBe(false);
126
127 const jetzt = document.activeElement as HTMLElement;
128 expect(jetzt.tagName, 'der Fokus darf nicht auf den Seitenkoerper fallen').toBe('INPUT');
129 const nachher = [...wurzel.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
130 expect(nachher.indexOf(jetzt as HTMLInputElement), 'dasselbe Kaestchen wie vorher').toBe(3);
131 } finally {
132 aufraeumen();
133 }
134 });
135
136 it('kehrt nach dem PDF-Lauf auf den wieder freigegebenen Knopf "PDF erzeugen" zurueck', async () => {
137 const { wurzel, aufraeumen } = baueAnsicht();
138 try {
139 const vorher = knopf(wurzel, 'PDF erzeugen');
140 expect(vorher.disabled).toBe(false);
141 vorher.focus();
142 expect(document.activeElement).toBe(vorher);
143
144 vorher.click();
145
146 // Der Standardknotenpunkt traegt Warnungen; die Rueckfrage ist zu
147 // bestaetigen, bevor der Lauf beginnt.
148 const dialog = document.querySelector('[role="dialog"]');
149 expect(dialog, 'die Rueckfrage zu den Warnungen muss erscheinen').not.toBeNull();
150 knopf(dialog!, 'PDF erzeugen').click();
151
152 await abwarten();
153 await abwarten();
154 await abwarten();
155 expect(stub.gespeichert, 'der Speicherweg muss durchlaufen sein').toBe(1);
156
157 // Beide Neuaufbauten sind gelaufen - der alte Knopf haengt nicht mehr.
158 expect(vorher.isConnected, 'die Ansicht muss neu gezeichnet worden sein').toBe(false);
159
160 const jetzt = document.activeElement as HTMLElement;
161 expect(jetzt.tagName, 'der Fokus darf nicht auf den Seitenkoerper fallen').toBe('BUTTON');
162 expect((jetzt.textContent ?? '').trim()).toBe('PDF erzeugen');
163 expect(wurzel.contains(jetzt), 'und zwar auf den Knopf der Ansicht').toBe(true);
164 expect((jetzt as HTMLButtonElement).disabled, 'wieder freigegeben').toBe(false);
165 } finally {
166 aufraeumen();
167 }
168 });
169 });