lsa-planer

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

/ tests ui kachelSackgasse.test.ts

9,5 KB Rohdatei
tests/ui/kachelSackgasse.test.ts — 263 Zeilen
1 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2 import { leseKoordinate } from '@/domain/geometrie/projektion';
3 import { baueKachelplan, dienstMit, type Kachelplan } from '@/services/kartendienste';
4 import { luftbildDialog } from '@/ui/lageplan/luftbild';
5
6 /**
7 * Eine Kachel, die ankommt, aber kein Bild ergibt.
8 *
9 * DIE SACKGASSE
10 *
11 * Die Abrufschleife legt jede Antwort mit ok=true unmittelbar in den
12 * Zwischenspeicher. Ob daraus ein Bild wird, stellt sich erst beim
13 * Zusammensetzen heraus - also nach der Schleife. Scheitert das Dekodieren,
14 * blieb die untaugliche Kachel bisher liegen: `fehlendeKacheln` meldete null,
15 * der Knopf trug wieder "Ausschnitt holen", und jeder weitere Klick uebersprang
16 * saemtliche Kacheln und lief in genau denselben Fehler - ohne einen einzigen
17 * neuen Abruf. Herausgekommen ist der Anwender nur ueber einen Wechsel von
18 * Dienst, Groesse oder Koordinate; gesagt hat ihm das niemand.
19 *
20 * Damit war zugleich die Zusage gebrochen, die der Quelltext an derselben
21 * Stelle gibt: "Die bereits geholten Kacheln bleiben liegen: Der naechste
22 * Anlauf holt nur die fehlenden."
23 *
24 * DER AUSLOESER
25 *
26 * Der Hauptprozess weist einen leeren Koerper inzwischen ab. Die Anzeige darf
27 * sich darauf nicht verlassen: Sie bekommt ihre Daten ueber die Bruecke und
28 * muss auch mit einer Antwort zurechtkommen, die kein Bild ergibt. Hier ist das
29 * eine Daten-URL ohne Nutzlast.
30 *
31 * jsdom bringt weder Zeichenflaeche noch Bildlader mit; beides wird ersetzt.
32 */
33
34 const ORT = '50.941278 6.958281';
35 const DIENST = 'nw-dop';
36
37 let abrufe: string[] = [];
38 let verdorben = new Set<string>();
39 let eingesetzt: string[] = [];
40
41 /** Daten-URL, an der eine Kachel wiederzuerkennen ist. Nur Base64-Zeichen. */
42 function kachelDaten(nummer: number): string {
43 return `data:image/jpeg;base64,${'A'.repeat(nummer + 1)}`;
44 }
45
46 function planFuer(kantenlaenge: number): Kachelplan {
47 const mitte = leseKoordinate(ORT);
48 if (mitte === null) throw new Error('Die Testkoordinate wird nicht gelesen.');
49 return baueKachelplan({ dienst: dienstMit(DIENST), mitte, kantenlaenge });
50 }
51
52 beforeEach(() => {
53 abrufe = [];
54 verdorben = new Set();
55 eingesetzt = [];
56
57 const plan = planFuer(200);
58
59 (window as unknown as { lsaPlaner: unknown }).lsaPlaner = {
60 platform: 'electron',
61 appVersion: 'test',
62 fetchMapImage: (anfrage: { url: string }) => {
63 abrufe.push(anfrage.url);
64 const nummer = plan.kacheln.findIndex((k) => k.url === anfrage.url);
65 if (nummer < 0) throw new Error(`Unerwartete Abrufadresse: ${anfrage.url}`);
66 if (verdorben.has(anfrage.url)) {
67 // Erfolg gemeldet, aber ohne Nutzlast: genau die Antwort, an der das
68 // Zusammensetzen scheitert.
69 return Promise.resolve({ ok: true, datenUrl: 'data:image/jpeg;base64,', fehler: null });
70 }
71 return Promise.resolve({ ok: true, datenUrl: kachelDaten(nummer), fehler: null });
72 },
73 };
74
75 class Prueflader {
76 onload: (() => void) | null = null;
77 onerror: (() => void) | null = null;
78 width = 0;
79 height = 0;
80 private daten = '';
81
82 get src(): string {
83 return this.daten;
84 }
85
86 set src(wert: string) {
87 this.daten = wert;
88 queueMicrotask(() => this.onload?.());
89 }
90 }
91 (globalThis as unknown as { Image: unknown }).Image = Prueflader;
92
93 const kontext = {
94 imageSmoothingQuality: 'low',
95 fillStyle: '',
96 fillRect(): void {},
97 drawImage(bild: { src: string }): void {
98 eingesetzt.push(bild.src);
99 },
100 };
101 HTMLCanvasElement.prototype.getContext = (() =>
102 kontext) as unknown as HTMLCanvasElement['getContext'];
103 HTMLCanvasElement.prototype.toDataURL = (typ?: string) =>
104 typ === 'image/png' ? 'data:image/png;base64,PPPPPP' : 'data:image/jpeg;base64,JJJJ';
105 });
106
107 afterEach(() => {
108 document.body.replaceChildren();
109 });
110
111 function knopfMit(beschriftung: string): HTMLButtonElement {
112 const treffer = [...document.querySelectorAll('button')].find(
113 (k) => (k.textContent ?? '').trim() === beschriftung,
114 );
115 if (treffer === undefined) {
116 throw new Error(`Keine Schaltflaeche "${beschriftung}" gefunden.`);
117 }
118 return treffer;
119 }
120
121 function knopftexte(): string[] {
122 return [...document.querySelectorAll('button')].map((k) => (k.textContent ?? '').trim());
123 }
124
125 function trageOrtEin(): void {
126 const eingabe = document.querySelector<HTMLInputElement>('.dialog input');
127 if (eingabe === null) throw new Error('Kein Eingabefeld im Dialog.');
128 eingabe.value = ORT;
129 eingabe.dispatchEvent(new Event('input'));
130 }
131
132 async function warteBis(bedingung: () => boolean, was: string): Promise<void> {
133 for (let versuch = 0; versuch < 500; versuch += 1) {
134 if (bedingung()) return;
135 await new Promise((erfuellen) => setTimeout(erfuellen, 10));
136 }
137 throw new Error(`Bedingung nicht eingetreten: ${was}`);
138 }
139
140 function fehlermeldung(): string {
141 return document.querySelector('.meldung-fehler')?.textContent ?? '';
142 }
143
144 /**
145 * Erster Anlauf mit einer verdorbenen Kachel - bis zur Fehlermeldung.
146 *
147 * Zurueck kommt das Versprechen des Dialogs; er bleibt offen, damit der zweite
148 * Anlauf im selben Fenster stattfindet.
149 */
150 async function ersterAnlaufMitVerdorbenerKachel(): Promise<{
151 versprechen: Promise<unknown>;
152 schlechteUrl: string;
153 }> {
154 const plan = planFuer(200);
155 const schlecht = plan.kacheln[2];
156 if (schlecht === undefined) throw new Error('Der Kachelplan hat keine dritte Kachel.');
157 verdorben.add(schlecht.url);
158
159 const versprechen = luftbildDialog();
160 trageOrtEin();
161 knopfMit('Ausschnitt holen').click();
162 await warteBis(() => document.querySelectorAll('.meldung-fehler').length > 0, 'Fehlermeldung');
163
164 // Alle vier Kacheln sind mit ok=true angekommen; gescheitert ist erst das
165 // Zusammensetzen, und zwar an der dritten.
166 expect(abrufe).toHaveLength(4);
167 expect(eingesetzt).toHaveLength(2);
168
169 return { versprechen, schlechteUrl: schlecht.url };
170 }
171
172 describe('Kachel ohne Bildinhalt', () => {
173 it('sagt an, welche Kachel unbrauchbar war, und dass genau sie fehlt', async () => {
174 const { versprechen } = await ersterAnlaufMitVerdorbenerKachel();
175
176 // Die Meldung nennt den Ort der Kachel - sonst steht der Anwender vor einem
177 // Satz ueber "empfangene Daten" ohne jeden Bezug.
178 expect(fehlermeldung()).toContain('Spalte 1, Zeile 2');
179
180 // Und der Knopf sagt an, dass eine Kachel fehlt. Stuende hier wieder
181 // "Ausschnitt holen", uebersprunge der naechste Klick saemtliche Kacheln.
182 expect(knopftexte()).toContain('Fehlende Kachel holen');
183
184 knopfMit('Abbrechen').click();
185 expect(await versprechen).toBeNull();
186 }, 15000);
187
188 it('holt beim zweiten Anlauf genau die untaugliche Kachel neu', async () => {
189 const { versprechen, schlechteUrl } = await ersterAnlaufMitVerdorbenerKachel();
190
191 verdorben.clear();
192 knopfMit('Fehlende Kachel holen').click();
193 const ergebnis = await versprechen;
194
195 // Ein einziger neuer Abruf, und zwar der richtige. Ohne das liefe der
196 // zweite Klick ohne jeden Abruf in denselben Fehler.
197 expect(abrufe).toHaveLength(5);
198 expect(abrufe[4]).toBe(schlechteUrl);
199 expect(ergebnis).not.toBeNull();
200 // Zwei Kacheln aus dem ersten Anlauf, vier aus dem zweiten.
201 expect(eingesetzt).toHaveLength(6);
202 }, 15000);
203
204 /*
205 * WAS DIE STELLE UEBER DIE NACHFOLGENDEN KACHELN WEISS - UND WAS NICHT.
206 *
207 * Das Zusammensetzen bricht bei der ERSTEN untauglichen Kachel ab. Die davor
208 * sind dekodiert und gezeichnet, die dahinter hat die Schleife nie erreicht:
209 * Sie bleiben als "geholt" liegen, obwohl niemand sie geprueft hat. Der
210 * Kommentar in `setzeMosaikZusammen` sagt genau das - und dieser Fall belegt
211 * die Folge daraus: Sind zwei Kacheln verdorben, braucht es zwei weitere
212 * Anlaeufe, und jeder nimmt genau die naechste heraus.
213 */
214 it('nimmt bei zwei verdorbenen Kacheln mit jedem Anlauf die naechste heraus', async () => {
215 const plan = planFuer(200);
216 const dritte = plan.kacheln[2];
217 const vierte = plan.kacheln[3];
218 if (dritte === undefined || vierte === undefined) {
219 throw new Error('Der Kachelplan hat keine vier Kacheln.');
220 }
221 verdorben.add(dritte.url);
222 verdorben.add(vierte.url);
223
224 const fehlermeldungen = (): string[] =>
225 [...document.querySelectorAll('.meldung-fehler')].map((k) => k.textContent ?? '');
226
227 const versprechen = luftbildDialog();
228 trageOrtEin();
229 knopfMit('Ausschnitt holen').click();
230 await warteBis(() => fehlermeldungen().length > 0, 'erste Fehlermeldung');
231
232 // Erster Anlauf: Alle vier Kacheln sind geholt, gescheitert ist die dritte.
233 // Nur sie wird genannt und herausgenommen; ueber die vierte ist an dieser
234 // Stelle nichts bekannt - sie wurde nie dekodiert.
235 expect(abrufe).toHaveLength(4);
236 expect(eingesetzt).toHaveLength(2);
237 expect(fehlermeldung()).toContain('Spalte 1, Zeile 2');
238 expect(knopftexte()).toContain('Fehlende Kachel holen');
239
240 verdorben.delete(dritte.url);
241 knopfMit('Fehlende Kachel holen').click();
242 await warteBis(
243 () => fehlermeldungen().some((text) => text.includes('Spalte 2, Zeile 2')),
244 'zweite Fehlermeldung',
245 );
246
247 // Zweiter Anlauf: ein einziger neuer Abruf - die dritte Kachel. Jetzt kommt
248 // die Schleife bis zur vierten, und nun faellt sie auf und wird ihrerseits
249 // herausgenommen.
250 expect(abrufe).toHaveLength(5);
251 expect(abrufe[4]).toBe(dritte.url);
252 expect(knopftexte()).toContain('Fehlende Kachel holen');
253
254 verdorben.delete(vierte.url);
255 knopfMit('Fehlende Kachel holen').click();
256 const ergebnis = await versprechen;
257
258 // Dritter Anlauf: wieder ein einziger Abruf, und das Bild entsteht.
259 expect(abrufe).toHaveLength(6);
260 expect(abrufe[5]).toBe(vierte.url);
261 expect(ergebnis).not.toBeNull();
262 }, 15000);
263 });