import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { leseKoordinate } from '@/domain/geometrie/projektion'; import { baueKachelplan, dienstMit, type Kachelplan } from '@/services/kartendienste'; import { luftbildDialog, type LuftbildErgebnis } from '@/ui/lageplan/luftbild'; /** * Grosse Luftbildausschnitte: Kacheln holen, zusammensetzen, Genauigkeit melden. * * Geprueft wird das, was man dem fertigen Bild NICHT ansieht: * * - Wird jede Kachel des Kachelplans geholt und an ihre Stelle gesetzt? Eine * vertauschte Kachel faellt in einem Luftbild kaum auf, verschiebt aber * jeden dort abgegriffenen Raeumweg. * - Entsteht bei einer fehlgeschlagenen Kachel wirklich KEIN Bild? Ein Mosaik * mit einem hellen Loch sieht aus wie eine Freiflaeche - und auf so einem * Bild wird vermessen. * - Nennt die Rueckmeldung die Bodenaufloesung und sagt sie bei einem groben * Ausschnitt ausdruecklich, dass Fussgaengerfurten daraus nicht mehr * abgegriffen werden duerfen? * * jsdom bringt weder eine Zeichenflaeche noch einen Bildlader mit. Beides wird * hier ersetzt: Die Zeichenflaeche schreibt jeden Einsetzvorgang mit, der * Bildlader liefert die Daten-URL unveraendert zurueck. Genau dadurch laesst * sich pruefen, WELCHE Kachel WO gelandet ist. */ const ORT = '50.941278 6.958281'; const DIENST = 'nw-dop'; interface Einsetzvorgang { readonly quelle: string; readonly x: number; readonly y: number; readonly breite: number; readonly hoehe: number; } let abrufe: string[] = []; let fehlerhaft = new Set(); let eingesetzt: Einsetzvorgang[] = []; /** Daten-URL, an der eine Kachel wiederzuerkennen ist. Nur Base64-Zeichen. */ function kachelDaten(nummer: number): string { return `data:image/jpeg;base64,${'A'.repeat(nummer + 1)}`; } function planFuer(kantenlaenge: number): Kachelplan { const mitte = leseKoordinate(ORT); if (mitte === null) throw new Error('Die Testkoordinate wird nicht gelesen.'); return baueKachelplan({ dienst: dienstMit(DIENST), mitte, kantenlaenge }); } beforeEach(() => { abrufe = []; fehlerhaft = new Set(); eingesetzt = []; const plan = planFuer(200); const plan2000 = planFuer(2000); const nummerVon = (url: string): number => { const inPlan = plan.kacheln.findIndex((k) => k.url === url); if (inPlan >= 0) return inPlan; return plan2000.kacheln.findIndex((k) => k.url === url); }; (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { platform: 'electron', appVersion: 'test', fetchMapImage: (anfrage: { url: string }) => { abrufe.push(anfrage.url); const nummer = nummerVon(anfrage.url); // Eine Adresse, die in keinem Kachelplan steht, ist ein Fehler im // Pruefstand - nicht ein Fehler des Dienstes. if (nummer < 0) throw new Error(`Unerwartete Abrufadresse: ${anfrage.url}`); if (fehlerhaft.has(anfrage.url)) { return Promise.resolve({ ok: false, datenUrl: null, fehler: 'Zeitgrenze ueberschritten.' }); } return Promise.resolve({ ok: true, datenUrl: kachelDaten(nummer), fehler: null }); }, }; // Bildlader: liefert sofort und merkt sich, welche Daten er bekommen hat. class Prueflader { onload: (() => void) | null = null; onerror: (() => void) | null = null; width = 0; height = 0; naturalWidth = 0; naturalHeight = 0; private daten = ''; get src(): string { return this.daten; } set src(wert: string) { this.daten = wert; queueMicrotask(() => this.onload?.()); } } (globalThis as unknown as { Image: unknown }).Image = Prueflader; const kontext = { imageSmoothingQuality: 'low', drawImage(bild: { src: string }, x: number, y: number, breite: number, hoehe: number): void { eingesetzt.push({ quelle: bild.src, x, y, breite, hoehe }); }, }; HTMLCanvasElement.prototype.getContext = (() => kontext) as unknown as HTMLCanvasElement['getContext']; HTMLCanvasElement.prototype.toDataURL = (typ?: string) => typ === 'image/png' ? 'data:image/png;base64,PPPPPP' : 'data:image/jpeg;base64,JJJJ'; }); afterEach(() => { document.body.replaceChildren(); }); function dialogText(): string { return document.querySelector('.dialog')?.textContent ?? ''; } function knopfMit(beschriftung: string): HTMLButtonElement { const treffer = [...document.querySelectorAll('button')].find( (k) => (k.textContent ?? '').trim() === beschriftung, ); if (treffer === undefined) { throw new Error(`Keine Schaltflaeche "${beschriftung}" gefunden.`); } return treffer; } /** Koordinate eintragen - so, wie der Anwender es tut. */ function trageOrtEin(): void { const eingabe = document.querySelector('.dialog input'); if (eingabe === null) throw new Error('Kein Eingabefeld im Dialog.'); eingabe.value = ORT; eingabe.dispatchEvent(new Event('input')); } function waehleKantenlaenge(meter: number): void { const felder = document.querySelectorAll('.dialog select'); const groesse = felder[1]; if (groesse === undefined) throw new Error('Keine Auswahl der Ausschnittgroesse.'); groesse.value = String(meter); groesse.dispatchEvent(new Event('change')); } /** Wartet, bis eine Bedingung eintritt - der Abruf laeuft asynchron. */ async function warteBis(bedingung: () => boolean, was: string): Promise { for (let versuch = 0; versuch < 500; versuch += 1) { if (bedingung()) return; await new Promise((erfuellen) => setTimeout(erfuellen, 10)); } throw new Error(`Bedingung nicht eingetreten: ${was}`); } function fortschrittstext(): string { return document.querySelector('[role="status"]')?.textContent ?? ''; } describe('Luftbild aus Kacheln', () => { it('bietet Stufen bis zur Ortsdurchfahrt an', async () => { const versprechen = luftbildDialog(); const felder = document.querySelectorAll('.dialog select'); const groesse = felder[1]; const werte = [...(groesse?.options ?? [])].map((o) => o.value); expect(werte).toEqual(['120', '200', '300', '500', '750', '1000', '1500', '2000']); const beschriftungen = [...(groesse?.options ?? [])].map((o) => o.textContent ?? ''); // Die Beschriftung nennt den Planungsfall, nicht nur die Zahl. expect(beschriftungen.some((t) => t.includes('Grüne Welle'))).toBe(true); expect(beschriftungen.some((t) => t.includes('Ortsdurchfahrt'))).toBe(true); expect(beschriftungen.some((t) => t.includes('Vorschlag'))).toBe(true); knopfMit('Abbrechen').click(); expect(await versprechen).toBeNull(); }); it('holt jede Kachel des Kachelplans und setzt sie an ihre Stelle', async () => { const plan = planFuer(200); expect(plan.kacheln.length).toBeGreaterThan(1); const versprechen = luftbildDialog(); trageOrtEin(); knopfMit('Ausschnitt holen').click(); const ergebnis = await versprechen; // Genau die Adressen des Kachelplans, jede genau einmal. expect([...abrufe].sort()).toEqual([...plan.kacheln.map((k) => k.url)].sort()); // Und jede Kachel an ihrem Platz: Eine vertauschte Kachel waere im Luftbild // kaum zu sehen, verschoebe aber jeden dort abgegriffenen Raeumweg. expect(eingesetzt).toHaveLength(plan.kacheln.length); for (const vorgang of eingesetzt) { const nummer = plan.kacheln.findIndex( (k) => kachelDaten(plan.kacheln.indexOf(k)) === vorgang.quelle, ); const kachel = plan.kacheln[nummer]; expect(kachel).toBeDefined(); expect({ x: vorgang.x, y: vorgang.y, breite: vorgang.breite, hoehe: vorgang.hoehe }).toEqual({ x: kachel?.x, y: kachel?.y, breite: kachel?.kante, hoehe: kachel?.kante, }); } expect(ergebnis).not.toBeNull(); const geliefert = ergebnis as LuftbildErgebnis; expect(geliefert.kantenlaenge).toBe(200); expect(geliefert.bild.breite).toBe(plan.bildkante); expect(geliefert.bild.hoehe).toBe(plan.bildkante); expect(geliefert.bild.herkunft).toBe(plan.herkunft); // Herausgegeben wird, was die Zeichenflaeche geschrieben hat - nicht die // Daten des Dienstes. expect(geliefert.bild.datenUrl.startsWith('data:image/')).toBe(true); expect(geliefert.bild.datenUrl).not.toContain(kachelDaten(0).split(',')[1]); }); it('erzeugt bei einer fehlgeschlagenen Kachel kein Bild und behaelt die geholten', async () => { const plan = planFuer(200); const gestoert = plan.kacheln[2]; expect(gestoert).toBeDefined(); fehlerhaft.add(gestoert?.url ?? ''); let geloest: LuftbildErgebnis | null | undefined; const versprechen = luftbildDialog().then((wert) => { geloest = wert; return wert; }); trageOrtEin(); knopfMit('Ausschnitt holen').click(); // Zwei gute Kacheln, dann die gestoerte zweimal (ein Wiederholversuch). await warteBis(() => abrufe.length === 4, 'vier Abrufe'); await warteBis(() => document.querySelectorAll('.meldung-fehler').length > 0, 'Fehlermeldung'); // Kein Bild - und der Dialog bleibt offen, damit der Anwender es erneut // versuchen kann. expect(geloest).toBeUndefined(); expect(eingesetzt).toHaveLength(0); expect(document.querySelector('.meldung-fehler')?.textContent ?? '').toContain( 'Spalte 1, Zeile 2', ); // Der Fortschritt steht in einer Live-Region und nennt den Stand. const bereich = document.querySelector('[role="status"]'); expect(bereich?.getAttribute('aria-live')).toBe('polite'); expect(fortschrittstext()).toContain('2 von 4'); // Zweiter Anlauf: nur die fehlenden Kacheln werden geholt. fehlerhaft.clear(); expect(knopfMit('Fehlende 2 Kacheln holen')).toBeDefined(); knopfMit('Fehlende 2 Kacheln holen').click(); const ergebnis = await versprechen; expect(ergebnis).not.toBeNull(); expect(eingesetzt).toHaveLength(4); const ersteKachel = plan.kacheln[0]?.url ?? ''; expect(abrufe.filter((u) => u === ersteKachel)).toHaveLength(1); // 2 gute + 2 Versuche der gestoerten + 2 fehlende im zweiten Anlauf. expect(abrufe).toHaveLength(6); }, 15000); it('nennt die Bodenaufloesung und verbietet Furten auf grobem Ausschnitt', async () => { const versprechen = luftbildDialog(); trageOrtEin(); waehleKantenlaenge(2000); const plan = planFuer(2000); expect(plan.kacheln).toHaveLength(25); const text = dialogText(); // 7000 Bildpunkte auf 2000 m sind 28,57 cm je Bildpunkt. expect(text).toContain('28,6 cm je Bildpunkt'); expect(text).toContain('25 Kacheln'); expect(text).toContain('Fußgängerfurten'); expect(text).toContain('nicht abgegriffen werden'); knopfMit('Abbrechen').click(); expect(await versprechen).toBeNull(); }); it('nennt die Bodenaufloesung auch beim engen Ausschnitt', async () => { const versprechen = luftbildDialog(); trageOrtEin(); const text = dialogText(); expect(text).toContain('10,0 cm je Bildpunkt'); // Bei 200 m entstehen 2 x 2 Kacheln - das dauert und muss vorher dastehen. expect(text).toContain('4 Kacheln'); knopfMit('Abbrechen').click(); expect(await versprechen).toBeNull(); }); });