import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { leseKoordinate } from '@/domain/geometrie/projektion'; import { baueKachelplan, dienstMit, type Kachelplan } from '@/services/kartendienste'; import { luftbildDialog } from '@/ui/lageplan/luftbild'; /** * Eine Kachel, die ankommt, aber kein Bild ergibt. * * DIE SACKGASSE * * Die Abrufschleife legt jede Antwort mit ok=true unmittelbar in den * Zwischenspeicher. Ob daraus ein Bild wird, stellt sich erst beim * Zusammensetzen heraus - also nach der Schleife. Scheitert das Dekodieren, * blieb die untaugliche Kachel bisher liegen: `fehlendeKacheln` meldete null, * der Knopf trug wieder "Ausschnitt holen", und jeder weitere Klick uebersprang * saemtliche Kacheln und lief in genau denselben Fehler - ohne einen einzigen * neuen Abruf. Herausgekommen ist der Anwender nur ueber einen Wechsel von * Dienst, Groesse oder Koordinate; gesagt hat ihm das niemand. * * Damit war zugleich die Zusage gebrochen, die der Quelltext an derselben * Stelle gibt: "Die bereits geholten Kacheln bleiben liegen: Der naechste * Anlauf holt nur die fehlenden." * * DER AUSLOESER * * Der Hauptprozess weist einen leeren Koerper inzwischen ab. Die Anzeige darf * sich darauf nicht verlassen: Sie bekommt ihre Daten ueber die Bruecke und * muss auch mit einer Antwort zurechtkommen, die kein Bild ergibt. Hier ist das * eine Daten-URL ohne Nutzlast. * * jsdom bringt weder Zeichenflaeche noch Bildlader mit; beides wird ersetzt. */ const ORT = '50.941278 6.958281'; const DIENST = 'nw-dop'; let abrufe: string[] = []; let verdorben = new Set(); let eingesetzt: string[] = []; /** 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 = []; verdorben = new Set(); eingesetzt = []; const plan = planFuer(200); (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { platform: 'electron', appVersion: 'test', fetchMapImage: (anfrage: { url: string }) => { abrufe.push(anfrage.url); const nummer = plan.kacheln.findIndex((k) => k.url === anfrage.url); if (nummer < 0) throw new Error(`Unerwartete Abrufadresse: ${anfrage.url}`); if (verdorben.has(anfrage.url)) { // Erfolg gemeldet, aber ohne Nutzlast: genau die Antwort, an der das // Zusammensetzen scheitert. return Promise.resolve({ ok: true, datenUrl: 'data:image/jpeg;base64,', fehler: null }); } return Promise.resolve({ ok: true, datenUrl: kachelDaten(nummer), fehler: null }); }, }; class Prueflader { onload: (() => void) | null = null; onerror: (() => void) | null = null; width = 0; height = 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', fillStyle: '', fillRect(): void {}, drawImage(bild: { src: string }): void { eingesetzt.push(bild.src); }, }; 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 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; } function knopftexte(): string[] { return [...document.querySelectorAll('button')].map((k) => (k.textContent ?? '').trim()); } 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')); } 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 fehlermeldung(): string { return document.querySelector('.meldung-fehler')?.textContent ?? ''; } /** * Erster Anlauf mit einer verdorbenen Kachel - bis zur Fehlermeldung. * * Zurueck kommt das Versprechen des Dialogs; er bleibt offen, damit der zweite * Anlauf im selben Fenster stattfindet. */ async function ersterAnlaufMitVerdorbenerKachel(): Promise<{ versprechen: Promise; schlechteUrl: string; }> { const plan = planFuer(200); const schlecht = plan.kacheln[2]; if (schlecht === undefined) throw new Error('Der Kachelplan hat keine dritte Kachel.'); verdorben.add(schlecht.url); const versprechen = luftbildDialog(); trageOrtEin(); knopfMit('Ausschnitt holen').click(); await warteBis(() => document.querySelectorAll('.meldung-fehler').length > 0, 'Fehlermeldung'); // Alle vier Kacheln sind mit ok=true angekommen; gescheitert ist erst das // Zusammensetzen, und zwar an der dritten. expect(abrufe).toHaveLength(4); expect(eingesetzt).toHaveLength(2); return { versprechen, schlechteUrl: schlecht.url }; } describe('Kachel ohne Bildinhalt', () => { it('sagt an, welche Kachel unbrauchbar war, und dass genau sie fehlt', async () => { const { versprechen } = await ersterAnlaufMitVerdorbenerKachel(); // Die Meldung nennt den Ort der Kachel - sonst steht der Anwender vor einem // Satz ueber "empfangene Daten" ohne jeden Bezug. expect(fehlermeldung()).toContain('Spalte 1, Zeile 2'); // Und der Knopf sagt an, dass eine Kachel fehlt. Stuende hier wieder // "Ausschnitt holen", uebersprunge der naechste Klick saemtliche Kacheln. expect(knopftexte()).toContain('Fehlende Kachel holen'); knopfMit('Abbrechen').click(); expect(await versprechen).toBeNull(); }, 15000); it('holt beim zweiten Anlauf genau die untaugliche Kachel neu', async () => { const { versprechen, schlechteUrl } = await ersterAnlaufMitVerdorbenerKachel(); verdorben.clear(); knopfMit('Fehlende Kachel holen').click(); const ergebnis = await versprechen; // Ein einziger neuer Abruf, und zwar der richtige. Ohne das liefe der // zweite Klick ohne jeden Abruf in denselben Fehler. expect(abrufe).toHaveLength(5); expect(abrufe[4]).toBe(schlechteUrl); expect(ergebnis).not.toBeNull(); // Zwei Kacheln aus dem ersten Anlauf, vier aus dem zweiten. expect(eingesetzt).toHaveLength(6); }, 15000); /* * WAS DIE STELLE UEBER DIE NACHFOLGENDEN KACHELN WEISS - UND WAS NICHT. * * Das Zusammensetzen bricht bei der ERSTEN untauglichen Kachel ab. Die davor * sind dekodiert und gezeichnet, die dahinter hat die Schleife nie erreicht: * Sie bleiben als "geholt" liegen, obwohl niemand sie geprueft hat. Der * Kommentar in `setzeMosaikZusammen` sagt genau das - und dieser Fall belegt * die Folge daraus: Sind zwei Kacheln verdorben, braucht es zwei weitere * Anlaeufe, und jeder nimmt genau die naechste heraus. */ it('nimmt bei zwei verdorbenen Kacheln mit jedem Anlauf die naechste heraus', async () => { const plan = planFuer(200); const dritte = plan.kacheln[2]; const vierte = plan.kacheln[3]; if (dritte === undefined || vierte === undefined) { throw new Error('Der Kachelplan hat keine vier Kacheln.'); } verdorben.add(dritte.url); verdorben.add(vierte.url); const fehlermeldungen = (): string[] => [...document.querySelectorAll('.meldung-fehler')].map((k) => k.textContent ?? ''); const versprechen = luftbildDialog(); trageOrtEin(); knopfMit('Ausschnitt holen').click(); await warteBis(() => fehlermeldungen().length > 0, 'erste Fehlermeldung'); // Erster Anlauf: Alle vier Kacheln sind geholt, gescheitert ist die dritte. // Nur sie wird genannt und herausgenommen; ueber die vierte ist an dieser // Stelle nichts bekannt - sie wurde nie dekodiert. expect(abrufe).toHaveLength(4); expect(eingesetzt).toHaveLength(2); expect(fehlermeldung()).toContain('Spalte 1, Zeile 2'); expect(knopftexte()).toContain('Fehlende Kachel holen'); verdorben.delete(dritte.url); knopfMit('Fehlende Kachel holen').click(); await warteBis( () => fehlermeldungen().some((text) => text.includes('Spalte 2, Zeile 2')), 'zweite Fehlermeldung', ); // Zweiter Anlauf: ein einziger neuer Abruf - die dritte Kachel. Jetzt kommt // die Schleife bis zur vierten, und nun faellt sie auf und wird ihrerseits // herausgenommen. expect(abrufe).toHaveLength(5); expect(abrufe[4]).toBe(dritte.url); expect(knopftexte()).toContain('Fehlende Kachel holen'); verdorben.delete(vierte.url); knopfMit('Fehlende Kachel holen').click(); const ergebnis = await versprechen; // Dritter Anlauf: wieder ein einziger Abruf, und das Bild entsteht. expect(abrufe).toHaveLength(6); expect(abrufe[5]).toBe(vierte.url); expect(ergebnis).not.toBeNull(); }, 15000); });