lsa-planer

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

/ tests ui luftbildKacheln.test.ts

11,0 KB Rohdatei
tests/ui/luftbildKacheln.test.ts — 297 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, type LuftbildErgebnis } from '@/ui/lageplan/luftbild';
5
6 /**
7 * Grosse Luftbildausschnitte: Kacheln holen, zusammensetzen, Genauigkeit melden.
8 *
9 * Geprueft wird das, was man dem fertigen Bild NICHT ansieht:
10 *
11 * - Wird jede Kachel des Kachelplans geholt und an ihre Stelle gesetzt? Eine
12 * vertauschte Kachel faellt in einem Luftbild kaum auf, verschiebt aber
13 * jeden dort abgegriffenen Raeumweg.
14 * - Entsteht bei einer fehlgeschlagenen Kachel wirklich KEIN Bild? Ein Mosaik
15 * mit einem hellen Loch sieht aus wie eine Freiflaeche - und auf so einem
16 * Bild wird vermessen.
17 * - Nennt die Rueckmeldung die Bodenaufloesung und sagt sie bei einem groben
18 * Ausschnitt ausdruecklich, dass Fussgaengerfurten daraus nicht mehr
19 * abgegriffen werden duerfen?
20 *
21 * jsdom bringt weder eine Zeichenflaeche noch einen Bildlader mit. Beides wird
22 * hier ersetzt: Die Zeichenflaeche schreibt jeden Einsetzvorgang mit, der
23 * Bildlader liefert die Daten-URL unveraendert zurueck. Genau dadurch laesst
24 * sich pruefen, WELCHE Kachel WO gelandet ist.
25 */
26
27 const ORT = '50.941278 6.958281';
28 const DIENST = 'nw-dop';
29
30 interface Einsetzvorgang {
31 readonly quelle: string;
32 readonly x: number;
33 readonly y: number;
34 readonly breite: number;
35 readonly hoehe: number;
36 }
37
38 let abrufe: string[] = [];
39 let fehlerhaft = new Set<string>();
40 let eingesetzt: Einsetzvorgang[] = [];
41
42 /** Daten-URL, an der eine Kachel wiederzuerkennen ist. Nur Base64-Zeichen. */
43 function kachelDaten(nummer: number): string {
44 return `data:image/jpeg;base64,${'A'.repeat(nummer + 1)}`;
45 }
46
47 function planFuer(kantenlaenge: number): Kachelplan {
48 const mitte = leseKoordinate(ORT);
49 if (mitte === null) throw new Error('Die Testkoordinate wird nicht gelesen.');
50 return baueKachelplan({ dienst: dienstMit(DIENST), mitte, kantenlaenge });
51 }
52
53 beforeEach(() => {
54 abrufe = [];
55 fehlerhaft = new Set();
56 eingesetzt = [];
57
58 const plan = planFuer(200);
59 const plan2000 = planFuer(2000);
60 const nummerVon = (url: string): number => {
61 const inPlan = plan.kacheln.findIndex((k) => k.url === url);
62 if (inPlan >= 0) return inPlan;
63 return plan2000.kacheln.findIndex((k) => k.url === url);
64 };
65
66 (window as unknown as { lsaPlaner: unknown }).lsaPlaner = {
67 platform: 'electron',
68 appVersion: 'test',
69 fetchMapImage: (anfrage: { url: string }) => {
70 abrufe.push(anfrage.url);
71 const nummer = nummerVon(anfrage.url);
72 // Eine Adresse, die in keinem Kachelplan steht, ist ein Fehler im
73 // Pruefstand - nicht ein Fehler des Dienstes.
74 if (nummer < 0) throw new Error(`Unerwartete Abrufadresse: ${anfrage.url}`);
75 if (fehlerhaft.has(anfrage.url)) {
76 return Promise.resolve({ ok: false, datenUrl: null, fehler: 'Zeitgrenze ueberschritten.' });
77 }
78 return Promise.resolve({ ok: true, datenUrl: kachelDaten(nummer), fehler: null });
79 },
80 };
81
82 // Bildlader: liefert sofort und merkt sich, welche Daten er bekommen hat.
83 class Prueflader {
84 onload: (() => void) | null = null;
85 onerror: (() => void) | null = null;
86 width = 0;
87 height = 0;
88 naturalWidth = 0;
89 naturalHeight = 0;
90 private daten = '';
91
92 get src(): string {
93 return this.daten;
94 }
95
96 set src(wert: string) {
97 this.daten = wert;
98 queueMicrotask(() => this.onload?.());
99 }
100 }
101 (globalThis as unknown as { Image: unknown }).Image = Prueflader;
102
103 const kontext = {
104 imageSmoothingQuality: 'low',
105 drawImage(bild: { src: string }, x: number, y: number, breite: number, hoehe: number): void {
106 eingesetzt.push({ quelle: bild.src, x, y, breite, hoehe });
107 },
108 };
109 HTMLCanvasElement.prototype.getContext = (() =>
110 kontext) as unknown as HTMLCanvasElement['getContext'];
111 HTMLCanvasElement.prototype.toDataURL = (typ?: string) =>
112 typ === 'image/png' ? 'data:image/png;base64,PPPPPP' : 'data:image/jpeg;base64,JJJJ';
113 });
114
115 afterEach(() => {
116 document.body.replaceChildren();
117 });
118
119 function dialogText(): string {
120 return document.querySelector('.dialog')?.textContent ?? '';
121 }
122
123 function knopfMit(beschriftung: string): HTMLButtonElement {
124 const treffer = [...document.querySelectorAll('button')].find(
125 (k) => (k.textContent ?? '').trim() === beschriftung,
126 );
127 if (treffer === undefined) {
128 throw new Error(`Keine Schaltflaeche "${beschriftung}" gefunden.`);
129 }
130 return treffer;
131 }
132
133 /** Koordinate eintragen - so, wie der Anwender es tut. */
134 function trageOrtEin(): void {
135 const eingabe = document.querySelector<HTMLInputElement>('.dialog input');
136 if (eingabe === null) throw new Error('Kein Eingabefeld im Dialog.');
137 eingabe.value = ORT;
138 eingabe.dispatchEvent(new Event('input'));
139 }
140
141 function waehleKantenlaenge(meter: number): void {
142 const felder = document.querySelectorAll<HTMLSelectElement>('.dialog select');
143 const groesse = felder[1];
144 if (groesse === undefined) throw new Error('Keine Auswahl der Ausschnittgroesse.');
145 groesse.value = String(meter);
146 groesse.dispatchEvent(new Event('change'));
147 }
148
149 /** Wartet, bis eine Bedingung eintritt - der Abruf laeuft asynchron. */
150 async function warteBis(bedingung: () => boolean, was: string): Promise<void> {
151 for (let versuch = 0; versuch < 500; versuch += 1) {
152 if (bedingung()) return;
153 await new Promise((erfuellen) => setTimeout(erfuellen, 10));
154 }
155 throw new Error(`Bedingung nicht eingetreten: ${was}`);
156 }
157
158 function fortschrittstext(): string {
159 return document.querySelector('[role="status"]')?.textContent ?? '';
160 }
161
162 describe('Luftbild aus Kacheln', () => {
163 it('bietet Stufen bis zur Ortsdurchfahrt an', async () => {
164 const versprechen = luftbildDialog();
165 const felder = document.querySelectorAll<HTMLSelectElement>('.dialog select');
166 const groesse = felder[1];
167 const werte = [...(groesse?.options ?? [])].map((o) => o.value);
168 expect(werte).toEqual(['120', '200', '300', '500', '750', '1000', '1500', '2000']);
169
170 const beschriftungen = [...(groesse?.options ?? [])].map((o) => o.textContent ?? '');
171 // Die Beschriftung nennt den Planungsfall, nicht nur die Zahl.
172 expect(beschriftungen.some((t) => t.includes('Grüne Welle'))).toBe(true);
173 expect(beschriftungen.some((t) => t.includes('Ortsdurchfahrt'))).toBe(true);
174 expect(beschriftungen.some((t) => t.includes('Vorschlag'))).toBe(true);
175
176 knopfMit('Abbrechen').click();
177 expect(await versprechen).toBeNull();
178 });
179
180 it('holt jede Kachel des Kachelplans und setzt sie an ihre Stelle', async () => {
181 const plan = planFuer(200);
182 expect(plan.kacheln.length).toBeGreaterThan(1);
183
184 const versprechen = luftbildDialog();
185 trageOrtEin();
186 knopfMit('Ausschnitt holen').click();
187 const ergebnis = await versprechen;
188
189 // Genau die Adressen des Kachelplans, jede genau einmal.
190 expect([...abrufe].sort()).toEqual([...plan.kacheln.map((k) => k.url)].sort());
191
192 // Und jede Kachel an ihrem Platz: Eine vertauschte Kachel waere im Luftbild
193 // kaum zu sehen, verschoebe aber jeden dort abgegriffenen Raeumweg.
194 expect(eingesetzt).toHaveLength(plan.kacheln.length);
195 for (const vorgang of eingesetzt) {
196 const nummer = plan.kacheln.findIndex(
197 (k) => kachelDaten(plan.kacheln.indexOf(k)) === vorgang.quelle,
198 );
199 const kachel = plan.kacheln[nummer];
200 expect(kachel).toBeDefined();
201 expect({ x: vorgang.x, y: vorgang.y, breite: vorgang.breite, hoehe: vorgang.hoehe }).toEqual({
202 x: kachel?.x,
203 y: kachel?.y,
204 breite: kachel?.kante,
205 hoehe: kachel?.kante,
206 });
207 }
208
209 expect(ergebnis).not.toBeNull();
210 const geliefert = ergebnis as LuftbildErgebnis;
211 expect(geliefert.kantenlaenge).toBe(200);
212 expect(geliefert.bild.breite).toBe(plan.bildkante);
213 expect(geliefert.bild.hoehe).toBe(plan.bildkante);
214 expect(geliefert.bild.herkunft).toBe(plan.herkunft);
215 // Herausgegeben wird, was die Zeichenflaeche geschrieben hat - nicht die
216 // Daten des Dienstes.
217 expect(geliefert.bild.datenUrl.startsWith('data:image/')).toBe(true);
218 expect(geliefert.bild.datenUrl).not.toContain(kachelDaten(0).split(',')[1]);
219 });
220
221 it('erzeugt bei einer fehlgeschlagenen Kachel kein Bild und behaelt die geholten', async () => {
222 const plan = planFuer(200);
223 const gestoert = plan.kacheln[2];
224 expect(gestoert).toBeDefined();
225 fehlerhaft.add(gestoert?.url ?? '');
226
227 let geloest: LuftbildErgebnis | null | undefined;
228 const versprechen = luftbildDialog().then((wert) => {
229 geloest = wert;
230 return wert;
231 });
232 trageOrtEin();
233 knopfMit('Ausschnitt holen').click();
234
235 // Zwei gute Kacheln, dann die gestoerte zweimal (ein Wiederholversuch).
236 await warteBis(() => abrufe.length === 4, 'vier Abrufe');
237 await warteBis(() => document.querySelectorAll('.meldung-fehler').length > 0, 'Fehlermeldung');
238
239 // Kein Bild - und der Dialog bleibt offen, damit der Anwender es erneut
240 // versuchen kann.
241 expect(geloest).toBeUndefined();
242 expect(eingesetzt).toHaveLength(0);
243 expect(document.querySelector('.meldung-fehler')?.textContent ?? '').toContain(
244 'Spalte 1, Zeile 2',
245 );
246
247 // Der Fortschritt steht in einer Live-Region und nennt den Stand.
248 const bereich = document.querySelector('[role="status"]');
249 expect(bereich?.getAttribute('aria-live')).toBe('polite');
250 expect(fortschrittstext()).toContain('2 von 4');
251
252 // Zweiter Anlauf: nur die fehlenden Kacheln werden geholt.
253 fehlerhaft.clear();
254 expect(knopfMit('Fehlende 2 Kacheln holen')).toBeDefined();
255 knopfMit('Fehlende 2 Kacheln holen').click();
256 const ergebnis = await versprechen;
257
258 expect(ergebnis).not.toBeNull();
259 expect(eingesetzt).toHaveLength(4);
260 const ersteKachel = plan.kacheln[0]?.url ?? '';
261 expect(abrufe.filter((u) => u === ersteKachel)).toHaveLength(1);
262 // 2 gute + 2 Versuche der gestoerten + 2 fehlende im zweiten Anlauf.
263 expect(abrufe).toHaveLength(6);
264 }, 15000);
265
266 it('nennt die Bodenaufloesung und verbietet Furten auf grobem Ausschnitt', async () => {
267 const versprechen = luftbildDialog();
268 trageOrtEin();
269 waehleKantenlaenge(2000);
270
271 const plan = planFuer(2000);
272 expect(plan.kacheln).toHaveLength(25);
273
274 const text = dialogText();
275 // 7000 Bildpunkte auf 2000 m sind 28,57 cm je Bildpunkt.
276 expect(text).toContain('28,6 cm je Bildpunkt');
277 expect(text).toContain('25 Kacheln');
278 expect(text).toContain('Fußgängerfurten');
279 expect(text).toContain('nicht abgegriffen werden');
280
281 knopfMit('Abbrechen').click();
282 expect(await versprechen).toBeNull();
283 });
284
285 it('nennt die Bodenaufloesung auch beim engen Ausschnitt', async () => {
286 const versprechen = luftbildDialog();
287 trageOrtEin();
288
289 const text = dialogText();
290 expect(text).toContain('10,0 cm je Bildpunkt');
291 // Bei 200 m entstehen 2 x 2 Kacheln - das dauert und muss vorher dastehen.
292 expect(text).toContain('4 Kacheln');
293
294 knopfMit('Abbrechen').click();
295 expect(await versprechen).toBeNull();
296 });
297 });