lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui kachelSackgasse.test.ts
| 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 2 | import { leseKoordinate } from '@/domain/geometrie/projektion'; |
| 3 | import { baueKachelplan, dienstMit, type Kachelplan } from '@/services/kartendienste'; |
| 4 | import { luftbildDialog } from '@/ui/lageplan/luftbild'; |
| 5 | |
| 6 | /** |
| 7 | * Eine Kachel, die ankommt, aber kein Bild ergibt. |
| 8 | * |
| 9 | * DIE SACKGASSE |
| 10 | * |
| 11 | * Die Abrufschleife legt jede Antwort mit ok=true unmittelbar in den |
| 12 | * Zwischenspeicher. Ob daraus ein Bild wird, stellt sich erst beim |
| 13 | * Zusammensetzen heraus - also nach der Schleife. Scheitert das Dekodieren, |
| 14 | * blieb die untaugliche Kachel bisher liegen: `fehlendeKacheln` meldete null, |
| 15 | * der Knopf trug wieder "Ausschnitt holen", und jeder weitere Klick uebersprang |
| 16 | * saemtliche Kacheln und lief in genau denselben Fehler - ohne einen einzigen |
| 17 | * neuen Abruf. Herausgekommen ist der Anwender nur ueber einen Wechsel von |
| 18 | * Dienst, Groesse oder Koordinate; gesagt hat ihm das niemand. |
| 19 | * |
| 20 | * Damit war zugleich die Zusage gebrochen, die der Quelltext an derselben |
| 21 | * Stelle gibt: "Die bereits geholten Kacheln bleiben liegen: Der naechste |
| 22 | * Anlauf holt nur die fehlenden." |
| 23 | * |
| 24 | * DER AUSLOESER |
| 25 | * |
| 26 | * Der Hauptprozess weist einen leeren Koerper inzwischen ab. Die Anzeige darf |
| 27 | * sich darauf nicht verlassen: Sie bekommt ihre Daten ueber die Bruecke und |
| 28 | * muss auch mit einer Antwort zurechtkommen, die kein Bild ergibt. Hier ist das |
| 29 | * eine Daten-URL ohne Nutzlast. |
| 30 | * |
| 31 | * jsdom bringt weder Zeichenflaeche noch Bildlader mit; beides wird ersetzt. |
| 32 | */ |
| 33 | |
| 34 | const ORT = '50.941278 6.958281'; |
| 35 | const DIENST = 'nw-dop'; |
| 36 | |
| 37 | let abrufe: string[] = []; |
| 38 | let verdorben = new Set<string>(); |
| 39 | let eingesetzt: string[] = []; |
| 40 | |
| 41 | /** Daten-URL, an der eine Kachel wiederzuerkennen ist. Nur Base64-Zeichen. */ |
| 42 | function kachelDaten(nummer: number): string { |
| 43 | return `data:image/jpeg;base64,${'A'.repeat(nummer + 1)}`; |
| 44 | } |
| 45 | |
| 46 | function planFuer(kantenlaenge: number): Kachelplan { |
| 47 | const mitte = leseKoordinate(ORT); |
| 48 | if (mitte === null) throw new Error('Die Testkoordinate wird nicht gelesen.'); |
| 49 | return baueKachelplan({ dienst: dienstMit(DIENST), mitte, kantenlaenge }); |
| 50 | } |
| 51 | |
| 52 | beforeEach(() => { |
| 53 | abrufe = []; |
| 54 | verdorben = new Set(); |
| 55 | eingesetzt = []; |
| 56 | |
| 57 | const plan = planFuer(200); |
| 58 | |
| 59 | (window as unknown as { lsaPlaner: unknown }).lsaPlaner = { |
| 60 | platform: 'electron', |
| 61 | appVersion: 'test', |
| 62 | fetchMapImage: (anfrage: { url: string }) => { |
| 63 | abrufe.push(anfrage.url); |
| 64 | const nummer = plan.kacheln.findIndex((k) => k.url === anfrage.url); |
| 65 | if (nummer < 0) throw new Error(`Unerwartete Abrufadresse: ${anfrage.url}`); |
| 66 | if (verdorben.has(anfrage.url)) { |
| 67 | // Erfolg gemeldet, aber ohne Nutzlast: genau die Antwort, an der das |
| 68 | // Zusammensetzen scheitert. |
| 69 | return Promise.resolve({ ok: true, datenUrl: 'data:image/jpeg;base64,', fehler: null }); |
| 70 | } |
| 71 | return Promise.resolve({ ok: true, datenUrl: kachelDaten(nummer), fehler: null }); |
| 72 | }, |
| 73 | }; |
| 74 | |
| 75 | class Prueflader { |
| 76 | onload: (() => void) | null = null; |
| 77 | onerror: (() => void) | null = null; |
| 78 | width = 0; |
| 79 | height = 0; |
| 80 | private daten = ''; |
| 81 | |
| 82 | get src(): string { |
| 83 | return this.daten; |
| 84 | } |
| 85 | |
| 86 | set src(wert: string) { |
| 87 | this.daten = wert; |
| 88 | queueMicrotask(() => this.onload?.()); |
| 89 | } |
| 90 | } |
| 91 | (globalThis as unknown as { Image: unknown }).Image = Prueflader; |
| 92 | |
| 93 | const kontext = { |
| 94 | imageSmoothingQuality: 'low', |
| 95 | fillStyle: '', |
| 96 | fillRect(): void {}, |
| 97 | drawImage(bild: { src: string }): void { |
| 98 | eingesetzt.push(bild.src); |
| 99 | }, |
| 100 | }; |
| 101 | HTMLCanvasElement.prototype.getContext = (() => |
| 102 | kontext) as unknown as HTMLCanvasElement['getContext']; |
| 103 | HTMLCanvasElement.prototype.toDataURL = (typ?: string) => |
| 104 | typ === 'image/png' ? 'data:image/png;base64,PPPPPP' : 'data:image/jpeg;base64,JJJJ'; |
| 105 | }); |
| 106 | |
| 107 | afterEach(() => { |
| 108 | document.body.replaceChildren(); |
| 109 | }); |
| 110 | |
| 111 | function knopfMit(beschriftung: string): HTMLButtonElement { |
| 112 | const treffer = [...document.querySelectorAll('button')].find( |
| 113 | (k) => (k.textContent ?? '').trim() === beschriftung, |
| 114 | ); |
| 115 | if (treffer === undefined) { |
| 116 | throw new Error(`Keine Schaltflaeche "${beschriftung}" gefunden.`); |
| 117 | } |
| 118 | return treffer; |
| 119 | } |
| 120 | |
| 121 | function knopftexte(): string[] { |
| 122 | return [...document.querySelectorAll('button')].map((k) => (k.textContent ?? '').trim()); |
| 123 | } |
| 124 | |
| 125 | function trageOrtEin(): void { |
| 126 | const eingabe = document.querySelector<HTMLInputElement>('.dialog input'); |
| 127 | if (eingabe === null) throw new Error('Kein Eingabefeld im Dialog.'); |
| 128 | eingabe.value = ORT; |
| 129 | eingabe.dispatchEvent(new Event('input')); |
| 130 | } |
| 131 | |
| 132 | async function warteBis(bedingung: () => boolean, was: string): Promise<void> { |
| 133 | for (let versuch = 0; versuch < 500; versuch += 1) { |
| 134 | if (bedingung()) return; |
| 135 | await new Promise((erfuellen) => setTimeout(erfuellen, 10)); |
| 136 | } |
| 137 | throw new Error(`Bedingung nicht eingetreten: ${was}`); |
| 138 | } |
| 139 | |
| 140 | function fehlermeldung(): string { |
| 141 | return document.querySelector('.meldung-fehler')?.textContent ?? ''; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Erster Anlauf mit einer verdorbenen Kachel - bis zur Fehlermeldung. |
| 146 | * |
| 147 | * Zurueck kommt das Versprechen des Dialogs; er bleibt offen, damit der zweite |
| 148 | * Anlauf im selben Fenster stattfindet. |
| 149 | */ |
| 150 | async function ersterAnlaufMitVerdorbenerKachel(): Promise<{ |
| 151 | versprechen: Promise<unknown>; |
| 152 | schlechteUrl: string; |
| 153 | }> { |
| 154 | const plan = planFuer(200); |
| 155 | const schlecht = plan.kacheln[2]; |
| 156 | if (schlecht === undefined) throw new Error('Der Kachelplan hat keine dritte Kachel.'); |
| 157 | verdorben.add(schlecht.url); |
| 158 | |
| 159 | const versprechen = luftbildDialog(); |
| 160 | trageOrtEin(); |
| 161 | knopfMit('Ausschnitt holen').click(); |
| 162 | await warteBis(() => document.querySelectorAll('.meldung-fehler').length > 0, 'Fehlermeldung'); |
| 163 | |
| 164 | // Alle vier Kacheln sind mit ok=true angekommen; gescheitert ist erst das |
| 165 | // Zusammensetzen, und zwar an der dritten. |
| 166 | expect(abrufe).toHaveLength(4); |
| 167 | expect(eingesetzt).toHaveLength(2); |
| 168 | |
| 169 | return { versprechen, schlechteUrl: schlecht.url }; |
| 170 | } |
| 171 | |
| 172 | describe('Kachel ohne Bildinhalt', () => { |
| 173 | it('sagt an, welche Kachel unbrauchbar war, und dass genau sie fehlt', async () => { |
| 174 | const { versprechen } = await ersterAnlaufMitVerdorbenerKachel(); |
| 175 | |
| 176 | // Die Meldung nennt den Ort der Kachel - sonst steht der Anwender vor einem |
| 177 | // Satz ueber "empfangene Daten" ohne jeden Bezug. |
| 178 | expect(fehlermeldung()).toContain('Spalte 1, Zeile 2'); |
| 179 | |
| 180 | // Und der Knopf sagt an, dass eine Kachel fehlt. Stuende hier wieder |
| 181 | // "Ausschnitt holen", uebersprunge der naechste Klick saemtliche Kacheln. |
| 182 | expect(knopftexte()).toContain('Fehlende Kachel holen'); |
| 183 | |
| 184 | knopfMit('Abbrechen').click(); |
| 185 | expect(await versprechen).toBeNull(); |
| 186 | }, 15000); |
| 187 | |
| 188 | it('holt beim zweiten Anlauf genau die untaugliche Kachel neu', async () => { |
| 189 | const { versprechen, schlechteUrl } = await ersterAnlaufMitVerdorbenerKachel(); |
| 190 | |
| 191 | verdorben.clear(); |
| 192 | knopfMit('Fehlende Kachel holen').click(); |
| 193 | const ergebnis = await versprechen; |
| 194 | |
| 195 | // Ein einziger neuer Abruf, und zwar der richtige. Ohne das liefe der |
| 196 | // zweite Klick ohne jeden Abruf in denselben Fehler. |
| 197 | expect(abrufe).toHaveLength(5); |
| 198 | expect(abrufe[4]).toBe(schlechteUrl); |
| 199 | expect(ergebnis).not.toBeNull(); |
| 200 | // Zwei Kacheln aus dem ersten Anlauf, vier aus dem zweiten. |
| 201 | expect(eingesetzt).toHaveLength(6); |
| 202 | }, 15000); |
| 203 | |
| 204 | /* |
| 205 | * WAS DIE STELLE UEBER DIE NACHFOLGENDEN KACHELN WEISS - UND WAS NICHT. |
| 206 | * |
| 207 | * Das Zusammensetzen bricht bei der ERSTEN untauglichen Kachel ab. Die davor |
| 208 | * sind dekodiert und gezeichnet, die dahinter hat die Schleife nie erreicht: |
| 209 | * Sie bleiben als "geholt" liegen, obwohl niemand sie geprueft hat. Der |
| 210 | * Kommentar in `setzeMosaikZusammen` sagt genau das - und dieser Fall belegt |
| 211 | * die Folge daraus: Sind zwei Kacheln verdorben, braucht es zwei weitere |
| 212 | * Anlaeufe, und jeder nimmt genau die naechste heraus. |
| 213 | */ |
| 214 | it('nimmt bei zwei verdorbenen Kacheln mit jedem Anlauf die naechste heraus', async () => { |
| 215 | const plan = planFuer(200); |
| 216 | const dritte = plan.kacheln[2]; |
| 217 | const vierte = plan.kacheln[3]; |
| 218 | if (dritte === undefined || vierte === undefined) { |
| 219 | throw new Error('Der Kachelplan hat keine vier Kacheln.'); |
| 220 | } |
| 221 | verdorben.add(dritte.url); |
| 222 | verdorben.add(vierte.url); |
| 223 | |
| 224 | const fehlermeldungen = (): string[] => |
| 225 | [...document.querySelectorAll('.meldung-fehler')].map((k) => k.textContent ?? ''); |
| 226 | |
| 227 | const versprechen = luftbildDialog(); |
| 228 | trageOrtEin(); |
| 229 | knopfMit('Ausschnitt holen').click(); |
| 230 | await warteBis(() => fehlermeldungen().length > 0, 'erste Fehlermeldung'); |
| 231 | |
| 232 | // Erster Anlauf: Alle vier Kacheln sind geholt, gescheitert ist die dritte. |
| 233 | // Nur sie wird genannt und herausgenommen; ueber die vierte ist an dieser |
| 234 | // Stelle nichts bekannt - sie wurde nie dekodiert. |
| 235 | expect(abrufe).toHaveLength(4); |
| 236 | expect(eingesetzt).toHaveLength(2); |
| 237 | expect(fehlermeldung()).toContain('Spalte 1, Zeile 2'); |
| 238 | expect(knopftexte()).toContain('Fehlende Kachel holen'); |
| 239 | |
| 240 | verdorben.delete(dritte.url); |
| 241 | knopfMit('Fehlende Kachel holen').click(); |
| 242 | await warteBis( |
| 243 | () => fehlermeldungen().some((text) => text.includes('Spalte 2, Zeile 2')), |
| 244 | 'zweite Fehlermeldung', |
| 245 | ); |
| 246 | |
| 247 | // Zweiter Anlauf: ein einziger neuer Abruf - die dritte Kachel. Jetzt kommt |
| 248 | // die Schleife bis zur vierten, und nun faellt sie auf und wird ihrerseits |
| 249 | // herausgenommen. |
| 250 | expect(abrufe).toHaveLength(5); |
| 251 | expect(abrufe[4]).toBe(dritte.url); |
| 252 | expect(knopftexte()).toContain('Fehlende Kachel holen'); |
| 253 | |
| 254 | verdorben.delete(vierte.url); |
| 255 | knopfMit('Fehlende Kachel holen').click(); |
| 256 | const ergebnis = await versprechen; |
| 257 | |
| 258 | // Dritter Anlauf: wieder ein einziger Abruf, und das Bild entsteht. |
| 259 | expect(abrufe).toHaveLength(6); |
| 260 | expect(abrufe[5]).toBe(vierte.url); |
| 261 | expect(ergebnis).not.toBeNull(); |
| 262 | }, 15000); |
| 263 | }); |