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