lsa-planer

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

/ tests ui bildwechselMassstab.test.ts

8,4 KB Rohdatei
tests/ui/bildwechselMassstab.test.ts — 238 Zeilen
1 import { afterEach, describe, expect, it, vi } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import type { Lageplan } from '@/domain/geometrie/lageplan';
5 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
6 import type { ImportErgebnis } from '@/ui/lageplan/bildImport';
7 import { lageplanView } from '@/ui/views/lageplanView';
8
9 /**
10 * Befund 9: Ein neues Hintergrundbild uebernahm stillschweigend den Massstab
11 * des alten Bildes.
12 *
13 * WAS DER ANWENDER SONST ERLEBT
14 *
15 * Er holt ein amtliches Luftbild mit 200 m Kantenlaenge; der Ausschnitt hat
16 * 2000 Bildpunkte, der Massstab steht damit bei 0,10 m je Bildpunkt und traegt
17 * die Herkunft "georeferenziert". Danach - noch ohne eine einzige Fahrlinie,
18 * weshalb die bisherige Rueckfrage gar nicht erschien - liest er ueber "Bild
19 * ersetzen" seinen eigenen Planauszug desselben Ausschnitts mit 1200
20 * Bildpunkten Breite ein. Wahr waeren 0,167 m je Bildpunkt; gerechnet wurde
21 * weiter mit 0,10. Jeder danach abgegriffene Weg fiel auf 60 Prozent seiner
22 * wirklichen Laenge - Raeumwege und damit Zwischenzeiten zu KURZ, die
23 * gefaehrliche Richtung. Die Seitenleiste behauptete dabei weiter "Massstab aus
24 * dem Kartendienst uebernommen", und die gedruckte Planunterlage trug die
25 * Herkunft "georeferenziert" ueber ein Bild, das nie georeferenziert war.
26 *
27 * Die Kalibrierung ist in Bildpunkten des ALTEN Bildes festgelegt und mit
28 * dessen Wegfall gegenstandslos. Sie wird deshalb beim Bildwechsel verworfen,
29 * und die Rueckfrage sagt das vorher an.
30 */
31
32 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
33
34 /**
35 * Was der Dateidialog liefert und was das Einlesen daraus macht.
36 *
37 * Ersetzt wird nur die Aussenwelt - der Dateidialog und das Dekodieren des
38 * Bildes. Alles andere an `bildImport` bleibt das echte Modul; sonst pruefte
39 * dieser Fall eine Nachbildung statt der Anwendung.
40 */
41 const stub = vi.hoisted(() => ({
42 datei: null as File | null,
43 breite: 1200,
44 hoehe: 900,
45 }));
46
47 vi.mock('@/ui/lageplan/bildImport', async (importOriginal) => {
48 const echt = await importOriginal<typeof import('@/ui/lageplan/bildImport')>();
49 return {
50 ...echt,
51 waehleBilddatei: (): Promise<File | null> => Promise.resolve(stub.datei),
52 leseBild: (): Promise<ImportErgebnis> =>
53 Promise.resolve({
54 bild: {
55 datenUrl: 'data:image/png;base64,iVBORw0KGgo=',
56 breite: stub.breite,
57 hoehe: stub.hoehe,
58 herkunft: 'Katasterauszug.png',
59 geladenAm: FESTES_DATUM,
60 },
61 verkleinert: false,
62 groesseKb: 42,
63 }),
64 };
65 });
66
67 /** Der Stand nach "Amtliches Luftbild holen": 2000 Bildpunkte auf 200 m. */
68 function planNachLuftbild(): Lageplan {
69 return {
70 arbeitsbereiche: [],
71 signalgeber: [],
72 bild: {
73 datenUrl: 'data:image/png;base64,iVBORw0KGgo=',
74 breite: 2000,
75 hoehe: 2000,
76 herkunft: 'Luftbild Nordrhein-Westfalen',
77 geladenAm: FESTES_DATUM,
78 },
79 kalibrierung: {
80 von: { x: 0, y: 0 },
81 bis: { x: 2000, y: 0 },
82 laengeMeter: 200,
83 herkunft: 'georeferenziert',
84 },
85 haltlinien: [],
86 linien: [],
87 };
88 }
89
90 interface Ansicht {
91 readonly store: ProjectStore;
92 readonly wurzel: HTMLElement;
93 readonly aufraeumen: () => void;
94 }
95
96 function baueAnsicht(lageplan: Lageplan): Ansicht {
97 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
98 store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' });
99
100 const wurzel = document.createElement('div');
101 document.body.append(wurzel);
102 const schliessen = lageplanView.render(wurzel, {
103 store,
104 refresh: () => {},
105 navigate: () => {},
106 target: null,
107 });
108
109 return {
110 store,
111 wurzel,
112 aufraeumen: () => {
113 schliessen();
114 wurzel.remove();
115 },
116 };
117 }
118
119 afterEach(() => {
120 document.body.replaceChildren();
121 stub.datei = null;
122 stub.breite = 1200;
123 stub.hoehe = 900;
124 });
125
126 /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */
127 async function ruhe(): Promise<void> {
128 for (let i = 0; i < 6; i += 1) {
129 await new Promise((erfuellen) => setTimeout(erfuellen, 0));
130 }
131 }
132
133 function knopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement {
134 const treffer = [...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('button')].find(
135 (b) => (b.textContent ?? '').trim() === beschriftung,
136 );
137 if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}"`);
138 return treffer;
139 }
140
141 /** Das offene Rueckfragefenster - oder null, wenn keines dasteht. */
142 function rueckfrage(): HTMLElement | null {
143 return document.querySelector<HTMLElement>('.dialog');
144 }
145
146 function knopfImFenster(beschriftung: string): HTMLButtonElement {
147 const fenster = rueckfrage();
148 if (fenster === null) throw new Error('Es steht kein Fenster offen.');
149 const treffer = [...fenster.querySelectorAll<HTMLButtonElement>('button')].find(
150 (b) => (b.textContent ?? '').trim() === beschriftung,
151 );
152 if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`);
153 return treffer;
154 }
155
156 /**
157 * Bestaetigt die Rueckfrage, falls eine dasteht.
158 *
159 * Ob sie ueberhaupt erscheint, ist Gegenstand des ersten Falls. Die beiden
160 * folgenden pruefen den Massstab und sollen genau daran scheitern, nicht an
161 * einem fehlenden Fenster.
162 */
163 async function bestaetigeWechsel(): Promise<void> {
164 if (rueckfrage() !== null) knopfImFenster('Trotzdem ersetzen').click();
165 await ruhe();
166 }
167
168 describe('Bildwechsel und Maßstab', () => {
169 it('fragt vor dem Ersetzen nach, obwohl noch keine Fahrlinie gezeichnet ist, und nennt den Maßstab', async () => {
170 const ansicht = baueAnsicht(planNachLuftbild());
171 try {
172 stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' });
173 knopf(ansicht, 'Bild ersetzen').click();
174 await ruhe();
175
176 const fenster = rueckfrage();
177 expect(fenster, 'keine Rückfrage vor dem Bildwechsel').not.toBeNull();
178 // Ohne diesen Satz ersetzt der Anwender das Bild in der Annahme, der
179 // Massstab gelte weiter - er hat ihn nie selbst gesetzt.
180 expect(fenster?.textContent ?? '').toContain('Maßstab');
181
182 knopfImFenster('Abbrechen').click();
183 await ruhe();
184
185 // Abgebrochen heisst: nichts angefasst.
186 expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000);
187 expect(ansicht.store.getProject().lageplan.kalibrierung).not.toBeNull();
188 } finally {
189 ansicht.aufraeumen();
190 }
191 });
192
193 it('verwirft den Maßstab des alten Bildes, statt ihn auf das neue zu übertragen', async () => {
194 const ansicht = baueAnsicht(planNachLuftbild());
195 try {
196 stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' });
197 knopf(ansicht, 'Bild ersetzen').click();
198 await ruhe();
199 await bestaetigeWechsel();
200
201 const lageplan = ansicht.store.getProject().lageplan;
202 expect(lageplan.bild?.breite, 'das neue Bild steht nicht im Projekt').toBe(1200);
203 // Der Kern des Befunds: 2000 Bildpunkte auf 200 m gelten nicht fuer ein
204 // Bild mit 1200 Bildpunkten Breite. Weitergerechnet waere jeder Weg um
205 // 40 Prozent zu kurz.
206 expect(lageplan.kalibrierung, 'der Maßstab des alten Bildes steht noch').toBeNull();
207 } finally {
208 ansicht.aufraeumen();
209 }
210 });
211
212 it('sperrt danach die Zeichenwerkzeuge und zeigt den Warnbalken „Maßstab fehlt“', async () => {
213 const ansicht = baueAnsicht(planNachLuftbild());
214 try {
215 stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' });
216 knopf(ansicht, 'Bild ersetzen').click();
217 await ruhe();
218 await bestaetigeWechsel();
219
220 const werkzeuge = [
221 ...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button'),
222 ];
223 const finde = (name: string): HTMLButtonElement | undefined =>
224 werkzeuge.find((b) => (b.textContent ?? '').trim() === name);
225 expect(finde('Haltlinie')?.disabled, 'Haltlinie ohne Maßstab bedienbar').toBe(true);
226 expect(finde('Fahrlinie')?.disabled, 'Fahrlinie ohne Maßstab bedienbar').toBe(true);
227
228 const seite = ansicht.wurzel.textContent ?? '';
229 expect(seite).toContain('Maßstab fehlt');
230 // Die Behauptung des Luftbilds darf nicht ueber das eingelesene Bild
231 // stehen bleiben - sie stuende sonst im selben Ausdruck neben einer
232 // Kantenlaenge, die etwas anderes sagt.
233 expect(seite).not.toContain('Maßstab aus dem Kartendienst übernommen');
234 } finally {
235 ansicht.aufraeumen();
236 }
237 });
238 });