import { afterAll, afterEach, beforeAll, 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 { LuftbildErgebnis } from '@/ui/lageplan/luftbild'; import { lageplanView } from '@/ui/views/lageplanView'; /** * `ladeLuftbild()` raeumte den halb gezeichneten Entwurf nicht. * * WAS DER ANWENDER SONST ERLEBT * * Er setzt zwei Punkte einer Fahrlinie und holt dann - noch mitten im Zug - * ein amtliches Luftbild. Das Bild wird ersetzt, das Werkzeug springt auf * "Ansehen", und ueber dem neuen Ausschnitt steht weiter das gestrichelte * Vorschauband des alten Entwurfs. Seine Punkte sind Bildpunkte des BISHERIGEN * Bildes und bezeichnen auf dem neuen eine andere Stelle. Wer den Zug spaeter * fortsetzt, beginnt seine Fahrlinie an einem Punkt, den er nie dort gesetzt * hat - und der erste Punkt ist der Nullpunkt von Raeum- und Einfahrweg. * * `ladeBild()` raeumt seit Befund 22; nur dieser zweite Weg tat es nicht. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Farbe des Entwurfs einer Fahrlinie (leinwand.ts, FARBE.entwurf). */ const ENTWURF = '#1f5fa8'; /** Farbe der Haltlinien - und damit auch des gestrichelten Haltlinienbandes. */ const HALTLINIE = '#111827'; interface Strich { readonly farbe: string; readonly breite: number; readonly strich: string; } /** * Zeichenkontext, der jeden Strich mitschreibt. * * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nicht das * Bild, sondern die Frage, OB das Vorschauband noch gezeichnet wird - und das * ist eine Folge von Anweisungen. */ class Aufzeichner { readonly striche: Strich[] = []; strokeStyle: string = ''; fillStyle: string = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; imageSmoothingQuality = 'low'; private strich = ''; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} arc(): void {} fill(): void {} fillText(): void {} setTransform(): void {} measureText(): { width: number } { return { width: 24 }; } setLineDash(muster: readonly number[] = []): void { this.strich = muster.join(','); } stroke(): void { this.striche.push({ farbe: String(this.strokeStyle), breite: this.lineWidth, strich: this.strich, }); } leeren(): void { this.striche.length = 0; } } const stift = new Aufzeichner(); /** * Was der Luftbildabruf liefert. * * Ersetzt wird nur die Aussenwelt: der Abrufweg zum Kartendienst und sein * Fenster. Alles andere an der Ansicht bleibt echt. */ const LUFTBILD: LuftbildErgebnis = { bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 2000, hoehe: 2000, herkunft: 'Luftbild Prüfland', geladenAm: FESTES_DATUM, }, kantenlaenge: 200, }; vi.mock('@/ui/lageplan/luftbild', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, luftbildVerfuegbar: (): boolean => true, luftbildDialog: (): Promise => Promise.resolve(LUFTBILD), }; }); /** Bildelement, das sofort geladen meldet - jsdom holt keine Daten-URL ab. */ class SofortBild { onload: (() => void) | null = null; onerror: (() => void) | null = null; naturalWidth = 2000; naturalHeight = 2000; set src(_wert: string) { this.onload?.(); } } const urspruenglichesPath2D = (globalThis as { Path2D?: unknown }).Path2D; const urspruenglichesImage = (globalThis as { Image?: unknown }).Image; beforeAll(() => { (globalThis as { Path2D?: unknown }).Path2D = class { moveTo(): void {} lineTo(): void {} }; (globalThis as { Image?: unknown }).Image = SofortBild; vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => stift as unknown as CanvasRenderingContext2D, ); }); afterAll(() => { (globalThis as { Path2D?: unknown }).Path2D = urspruenglichesPath2D; (globalThis as { Image?: unknown }).Image = urspruenglichesImage; }); /** Plan mit Maßstab und einer waagerechten Haltlinie, aber noch ohne Bild. */ function planMitHaltlinie(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, haltlinien: [ { id: 'hl1', name: 'Nord', art: 'zufahrt', armId: null, von: { x: 100, y: 200 }, bis: { x: 220, y: 200 }, }, ], linien: [], }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly leinwand: HTMLCanvasElement; readonly aufraeumen: () => void; } function baueAnsicht(): Ansicht { const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); store.update((p) => actions.setLageplan(p, planMitHaltlinie()), { label: 'Pruefplan' }); const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); const leinwand = wurzel.querySelector('canvas'); if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.'); return { store, wurzel, leinwand, aufraeumen: () => { leinwand.blur(); schliessen(); wurzel.remove(); }, }; } afterEach(() => { document.body.replaceChildren(); stift.leeren(); }); /** 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 waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { const knopf = [ ...ansicht.wurzel.querySelectorAll('.lageplan-werkzeuge button'), ].find((b) => (b.textContent ?? '').trim() === beschriftung); if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); knopf.click(); } 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; } function tippe(key: string, male = 1): void { for (let i = 0; i < male; i += 1) { const ziel = (document.activeElement as HTMLElement | null) ?? document.body; ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); } } /** Wurde das gestrichelte Vorschauband des Entwurfs gezeichnet? */ function vorschauband(): boolean { return stift.striche.some((s) => s.farbe === ENTWURF && s.breite === 2 && s.strich === '5,3'); } /** Wurde das gestrichelte Band einer halb gesetzten Haltlinie gezeichnet? */ function haltlinienband(): boolean { return stift.striche.some((s) => s.farbe === HALTLINIE && s.breite === 4 && s.strich === '8,5'); } /** Setzt zwei Punkte einer Fahrlinie, ohne die Linie abzuschließen. */ function halberZug(ansicht: Ansicht): void { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowDown', 6); tippe(' '); } describe('Amtliches Luftbild holen mit halb gezeichneter Fahrlinie', () => { it('räumt den Entwurf, statt sein Vorschauband über dem neuen Ausschnitt stehen zu lassen', async () => { const ansicht = baueAnsicht(); try { halberZug(ansicht); // Grundlage des Falls: Es steht wirklich ein Entwurf. expect(ansicht.wurzel.textContent ?? '').toContain('2 Punkt(e) gesetzt'); stift.leeren(); knopf(ansicht, 'Amtliches Luftbild').click(); await ruhe(); // Grundlage: Das Luftbild ist wirklich angekommen - sonst zeichnet die // Flaeche nur "Kein Lageplan geladen" und der Fall waere nicht gestellt. expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000); expect(vorschauband(), 'das Vorschauband des alten Entwurfs steht noch').toBe(false); } finally { ansicht.aufraeumen(); } }); it('räumt auch den halb gesetzten Anfang einer Haltlinie', async () => { // Dieselbe Ursache, andere Grösse: `haltlinienStart` ist ebenfalls ein // Bildpunkt des bisherigen Bildes. Bliebe er liegen, deckte sein Band // ausserdem jede weitere Vorschau zu - `zeichneEntwurf` prüft ihn zuerst // und kehrt danach zurück. const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe('ArrowDown', 6); tippe(' '); stift.leeren(); knopf(ansicht, 'Amtliches Luftbild').click(); await ruhe(); expect(ansicht.store.getProject().lageplan.bild?.breite).toBe(2000); expect(haltlinienband(), 'das Band der halb gesetzten Haltlinie steht noch').toBe(false); } finally { ansicht.aufraeumen(); } }); });