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 { lageplanView } from '@/ui/views/lageplanView'; /** * Strittiger Befund S3 zur Fassung 5.9.0 (Lebensdauer der Ansicht). * * WAS DER ANWENDER SONST ERLEBT * * Er schlaegt die Ansicht "Lageplan" mit einem grossen eingebetteten * Hintergrundbild auf und wechselt, waehrend das Bild noch dekodiert wird, in * eine andere Ansicht. Der Ladevorgang laeuft weiter: `onload` zeichnet die * verlassene Ansicht zu Ende, und `onerror` schiebt ueber das globale `notify` * eine Fehlermeldung in die Ansicht, die inzwischen offensteht - Fehler laufen * nicht ab, sie bleibt also dort stehen. * * Die Aufraeumfunktion sagt genau das Gegenteil zu ("Alle Empfaenger abmelden - * sonst zeichnet die verlassene Ansicht weiter"), meldete den Bildlader aber * als einzigen nicht ab. Im selben Sachgebiet wird derselbe Fall bewusst * anders behandelt: `src/ui/lageplan/luftbild.ts` fuehrt fuer den Kachelabruf * ein Abbruchmerkmal, "damit ein noch laufender Abruf mitbekommt, dass niemand * mehr auf ihn wartet". * * Geprueft wird an der Zusage, nicht an der Wahrscheinlichkeit des * Zusammentreffens: Der Bildlader wird hier von Hand gesteuert, weil jsdom * keine Daten-URL abholt. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** * Bildelement, das erst meldet, wenn der Prueffall es sagt. * * Die uebrigen Ansichts-Tests ersetzen `Image` durch einen Lader, der beim * Setzen von `src` SOFORT meldet - damit umgehen sie den Wettlauf, um den es * hier gerade geht. */ class SchwebendesBild { static readonly angelegte: SchwebendesBild[] = []; onload: (() => void) | null = null; onerror: (() => void) | null = null; naturalWidth = 800; naturalHeight = 600; constructor() { SchwebendesBild.angelegte.push(this); } set src(_wert: string) { /* Nichts - der Ladevorgang schwebt, bis der Prueffall ihn beendet. */ } } /** Zeichenkontext, der nur zaehlt, wie oft gemalt wurde. */ class Zaehler { anweisungen = 0; strokeStyle = ''; fillStyle = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; imageSmoothingQuality = 'low'; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} arc(): void {} fill(): void {} fillText(): void {} stroke(): void {} setLineDash(): void {} measureText(): { width: number } { return { width: 24 }; } setTransform(): void { // `male()` beginnt jedes Zeichnen damit - genau einmal je Durchgang. this.anweisungen += 1; } } const stift = new Zaehler(); 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 = SchwebendesBild; vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => stift as unknown as CanvasRenderingContext2D, ); }); afterAll(() => { (globalThis as { Path2D?: unknown }).Path2D = urspruenglichesPath2D; (globalThis as { Image?: unknown }).Image = urspruenglichesImage; }); afterEach(() => { document.body.replaceChildren(); SchwebendesBild.angelegte.length = 0; stift.anweisungen = 0; }); function planMitBild(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 800, hoehe: 600, herkunft: 'Prüfbild', geladenAm: FESTES_DATUM, }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, haltlinien: [], linien: [], }; } /** Baut die Ansicht auf und gibt die Aufraeumfunktion zurueck. */ function zeichne(): () => void { const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); store.update((p) => actions.setLageplan(p, planMitBild()), { label: 'Pruefplan' }); const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return () => { schliessen(); wurzel.remove(); }; } function schwebenderLader(): SchwebendesBild { const bild = SchwebendesBild.angelegte[SchwebendesBild.angelegte.length - 1]; if (bild === undefined) throw new Error('Die Ansicht hat keinen Bildladevorgang begonnen'); return bild; } describe('Lageplan: der schwebende Bildladevorgang wird beim Verlassen der Ansicht entwertet', () => { it('meldet nach dem Ansichtswechsel keinen Bildfehler mehr in die neue Ansicht', () => { const aufraeumen = zeichne(); const lader = schwebenderLader(); // Der Anwender wechselt die Ansicht, bevor das Bild fertig ist. aufraeumen(); lader.onerror?.(); const meldungen = [...document.querySelectorAll('.meldungen .meldung')].map( (m) => m.textContent ?? '', ); expect( meldungen.filter((t) => t.includes('konnte nicht dargestellt werden')), 'die verlassene Ansicht meldet in die neue', ).toEqual([]); }); it('zeichnet nach dem Ansichtswechsel nicht weiter', () => { const aufraeumen = zeichne(); const lader = schwebenderLader(); aufraeumen(); const vorher = stift.anweisungen; lader.onload?.(); expect(stift.anweisungen, 'die verlassene Ansicht zeichnet weiter').toBe(vorher); }); it('laesst den Ladevorgang wirken, solange die Ansicht steht', () => { const aufraeumen = zeichne(); try { const lader = schwebenderLader(); const vorher = stift.anweisungen; lader.onload?.(); expect(stift.anweisungen, 'das geladene Bild wird nicht gezeichnet').toBeGreaterThan(vorher); } finally { aufraeumen(); } }); });