import { afterEach, describe, expect, it, vi } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { Lageplan } from '@/domain/geometrie/lageplan'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import type { ImportErgebnis } from '@/ui/lageplan/bildImport'; import { lageplanView } from '@/ui/views/lageplanView'; /** * Befund 9: Ein neues Hintergrundbild uebernahm stillschweigend den Massstab * des alten Bildes. * * WAS DER ANWENDER SONST ERLEBT * * Er holt ein amtliches Luftbild mit 200 m Kantenlaenge; der Ausschnitt hat * 2000 Bildpunkte, der Massstab steht damit bei 0,10 m je Bildpunkt und traegt * die Herkunft "georeferenziert". Danach - noch ohne eine einzige Fahrlinie, * weshalb die bisherige Rueckfrage gar nicht erschien - liest er ueber "Bild * ersetzen" seinen eigenen Planauszug desselben Ausschnitts mit 1200 * Bildpunkten Breite ein. Wahr waeren 0,167 m je Bildpunkt; gerechnet wurde * weiter mit 0,10. Jeder danach abgegriffene Weg fiel auf 60 Prozent seiner * wirklichen Laenge - Raeumwege und damit Zwischenzeiten zu KURZ, die * gefaehrliche Richtung. Die Seitenleiste behauptete dabei weiter "Massstab aus * dem Kartendienst uebernommen", und die gedruckte Planunterlage trug die * Herkunft "georeferenziert" ueber ein Bild, das nie georeferenziert war. * * Die Kalibrierung ist in Bildpunkten des ALTEN Bildes festgelegt und mit * dessen Wegfall gegenstandslos. Sie wird deshalb beim Bildwechsel verworfen, * und die Rueckfrage sagt das vorher an. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** * Was der Dateidialog liefert und was das Einlesen daraus macht. * * Ersetzt wird nur die Aussenwelt - der Dateidialog und das Dekodieren des * Bildes. Alles andere an `bildImport` bleibt das echte Modul; sonst pruefte * dieser Fall eine Nachbildung statt der Anwendung. */ const stub = vi.hoisted(() => ({ datei: null as File | null, breite: 1200, hoehe: 900, })); vi.mock('@/ui/lageplan/bildImport', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, waehleBilddatei: (): Promise => Promise.resolve(stub.datei), leseBild: (): Promise => Promise.resolve({ bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: stub.breite, hoehe: stub.hoehe, herkunft: 'Katasterauszug.png', geladenAm: FESTES_DATUM, }, verkleinert: false, groesseKb: 42, }), }; }); /** Der Stand nach "Amtliches Luftbild holen": 2000 Bildpunkte auf 200 m. */ function planNachLuftbild(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 2000, hoehe: 2000, herkunft: 'Luftbild Nordrhein-Westfalen', geladenAm: FESTES_DATUM, }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 2000, y: 0 }, laengeMeter: 200, herkunft: 'georeferenziert', }, haltlinien: [], linien: [], }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly aufraeumen: () => void; } function baueAnsicht(lageplan: Lageplan): Ansicht { const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); store.update((p) => actions.setLageplan(p, lageplan), { label: 'Pruefplan' }); const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { store, wurzel, aufraeumen: () => { schliessen(); wurzel.remove(); }, }; } afterEach(() => { document.body.replaceChildren(); stub.datei = null; stub.breite = 1200; stub.hoehe = 900; }); /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */ async function ruhe(): Promise { for (let i = 0; i < 6; i += 1) { await new Promise((erfuellen) => setTimeout(erfuellen, 0)); } } function knopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement { const treffer = [...ansicht.wurzel.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}"`); return treffer; } /** Das offene Rueckfragefenster - oder null, wenn keines dasteht. */ function rueckfrage(): HTMLElement | null { return document.querySelector('.dialog'); } function knopfImFenster(beschriftung: string): HTMLButtonElement { const fenster = rueckfrage(); if (fenster === null) throw new Error('Es steht kein Fenster offen.'); const treffer = [...fenster.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`); return treffer; } /** * Bestaetigt die Rueckfrage, falls eine dasteht. * * Ob sie ueberhaupt erscheint, ist Gegenstand des ersten Falls. Die beiden * folgenden pruefen den Massstab und sollen genau daran scheitern, nicht an * einem fehlenden Fenster. */ async function bestaetigeWechsel(): Promise { if (rueckfrage() !== null) knopfImFenster('Trotzdem ersetzen').click(); await ruhe(); } describe('Bildwechsel und Maßstab', () => { it('fragt vor dem Ersetzen nach, obwohl noch keine Fahrlinie gezeichnet ist, und nennt den Maßstab', async () => { const ansicht = baueAnsicht(planNachLuftbild()); try { stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' }); knopf(ansicht, 'Bild ersetzen').click(); await ruhe(); const fenster = rueckfrage(); expect(fenster, 'keine Rückfrage vor dem Bildwechsel').not.toBeNull(); // Ohne diesen Satz ersetzt der Anwender das Bild in der Annahme, der // Massstab gelte weiter - er hat ihn nie selbst gesetzt. expect(fenster?.textContent ?? '').toContain('Maßstab'); knopfImFenster('Abbrechen').click(); await ruhe(); // Abgebrochen heisst: nichts angefasst. expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000); expect(ansicht.store.getProject().lageplan.kalibrierung).not.toBeNull(); } finally { ansicht.aufraeumen(); } }); it('verwirft den Maßstab des alten Bildes, statt ihn auf das neue zu übertragen', async () => { const ansicht = baueAnsicht(planNachLuftbild()); try { stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' }); knopf(ansicht, 'Bild ersetzen').click(); await ruhe(); await bestaetigeWechsel(); const lageplan = ansicht.store.getProject().lageplan; expect(lageplan.bild?.breite, 'das neue Bild steht nicht im Projekt').toBe(1200); // Der Kern des Befunds: 2000 Bildpunkte auf 200 m gelten nicht fuer ein // Bild mit 1200 Bildpunkten Breite. Weitergerechnet waere jeder Weg um // 40 Prozent zu kurz. expect(lageplan.kalibrierung, 'der Maßstab des alten Bildes steht noch').toBeNull(); } finally { ansicht.aufraeumen(); } }); it('sperrt danach die Zeichenwerkzeuge und zeigt den Warnbalken „Maßstab fehlt“', async () => { const ansicht = baueAnsicht(planNachLuftbild()); try { stub.datei = new File([], 'Katasterauszug.png', { type: 'image/png' }); knopf(ansicht, 'Bild ersetzen').click(); await ruhe(); await bestaetigeWechsel(); const werkzeuge = [ ...ansicht.wurzel.querySelectorAll('.lageplan-werkzeuge button'), ]; const finde = (name: string): HTMLButtonElement | undefined => werkzeuge.find((b) => (b.textContent ?? '').trim() === name); expect(finde('Haltlinie')?.disabled, 'Haltlinie ohne Maßstab bedienbar').toBe(true); expect(finde('Fahrlinie')?.disabled, 'Fahrlinie ohne Maßstab bedienbar').toBe(true); const seite = ansicht.wurzel.textContent ?? ''; expect(seite).toContain('Maßstab fehlt'); // Die Behauptung des Luftbilds darf nicht ueber das eingelesene Bild // stehen bleiben - sie stuende sonst im selben Ausdruck neben einer // Kantenlaenge, die etwas anderes sagt. expect(seite).not.toContain('Maßstab aus dem Kartendienst übernommen'); } finally { ansicht.aufraeumen(); } }); });