From d93ee5308cba854d1cf0a988eb5f2cd81be23bb1 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 20:51:43 +0200 Subject: [PATCH] task(B4): cache background image (fixes BUG-4) --- frontend/src/canvas/RenderEngine.ts | 31 ++++++++++-- frontend/tests/bugs.regress.test.ts | 73 ++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/frontend/src/canvas/RenderEngine.ts b/frontend/src/canvas/RenderEngine.ts index 05d907c..70d6d4c 100644 --- a/frontend/src/canvas/RenderEngine.ts +++ b/frontend/src/canvas/RenderEngine.ts @@ -50,6 +50,10 @@ export class RenderEngine { private snapPoints: SnapPoint[] = []; private activeSnapPoint: SnapPoint | null = null; private previewElement: CADElement | null = null; + /** Gecachtes Hintergrundbild (BUG-4-Fix): nur 1× pro backgroundSrc laden. */ + private bgImage: HTMLImageElement | null = null; + /** src des zuletzt erfolgreich geladenen Hintergrundbilds. */ + private bgSrcLoaded = ''; private dpr = 1; constructor( @@ -379,13 +383,30 @@ export class RenderEngine { } private drawBackground(): void { - // Background image rendering with transform - const img = new Image(); - img.src = this.options.backgroundSrc!; - if (!img.complete) { - img.onload = () => this.render(); + // Background image rendering with transform. + // BUG-4-Fix: Das Bild wird nur EINMAL pro backgroundSrc geladen und gecacht; + // vorher wurde pro Frame ein neues Image erzeugt (Flackern/Rerender-Schleife). + const src = this.options.backgroundSrc!; + + // Quellwechsel: Cache invalidieren und neu laden + if (!this.bgImage || this.bgSrcLoaded !== src) { + const img = new Image(); + img.onload = () => { + this.bgImage = img; + this.bgSrcLoaded = src; + this.render(); // einmalig nach erfolgreichem Laden neu zeichnen + }; + img.src = src; + if (img.complete && img.naturalWidth > 0) { + // Bereits aus Browser-Cache verfügbar (z.B. dataURL) + this.bgImage = img; + this.bgSrcLoaded = src; + } return; } + + // Gecachtes Bild zeichnen + const img = this.bgImage; this.ctx.save(); this.ctx.globalAlpha = this.options.backgroundOpacity; const s = this.zoomPan.getScale(); diff --git a/frontend/tests/bugs.regress.test.ts b/frontend/tests/bugs.regress.test.ts index 684eca3..bae6e66 100644 --- a/frontend/tests/bugs.regress.test.ts +++ b/frontend/tests/bugs.regress.test.ts @@ -3,10 +3,14 @@ * Diese Tests sind VOR dem jeweiligen Fix ROT (dokumentiertes Fehlverhalten) * und müssen danach GRÜN sein. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SeatingService } from '../src/services/seatingService'; import { trimElement, offsetElement } from '../src/tools/modification/geometry'; import type { CADElement } from '../src/types/cad.types'; +import { RenderEngine } from '../src/canvas/RenderEngine'; +import { ZoomPanController } from '../src/canvas/ZoomPanController'; +import { SpatialIndex } from '../src/canvas/SpatialIndex'; +import { LayerManager } from '../src/canvas/LayerManager'; // ── Helfer (Muster aus tests/geometry.test.ts) ───────────── function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { @@ -100,3 +104,70 @@ describe('BUG-3: offset respects click side', () => { expect(offsetElement(circle, 10, 1).properties.radius).toBeCloseTo(60); }); }); + +// ── BUG-4: Hintergrundbild wurde pro Frame neu geladen ────── +describe('BUG-4: background image cached', () => { + let imageCount: number; + let OriginalImage: typeof Image; + + beforeEach(() => { + imageCount = 0; + OriginalImage = global.Image; + // Bild-Stub: zaehlt Konstruktur-Aufrufe; simuliert Browser-Cache-Bild + // (complete=true sofort verfuegbar, onload als Mikrotask wie async decode) + class FakeImage { + complete = true; + naturalWidth = 800; + width = 800; + height = 600; + onload: (() => void) | null = null; + private internalSrc = ''; + get src(): string { + return this.internalSrc; + } + set src(value: string) { + this.internalSrc = value; + queueMicrotask(() => this.onload?.()); + } + constructor() { + imageCount += 1; + } + } + (global as { Image: unknown }).Image = FakeImage as unknown as typeof Image; + }); + + afterEach(() => { + (global as { Image: unknown }).Image = OriginalImage; + }); + + function createEngine(): RenderEngine { + const canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + const zoomPan = new ZoomPanController(canvas); + const spatialIndex = new SpatialIndex(); + const layerManager = new LayerManager(); + return new RenderEngine(canvas, zoomPan, spatialIndex, layerManager); + } + + it('lädt das Bild pro Quellwechsel nur EINMAL, weitere Frames nutzen den Cache', async () => { + const engine = createEngine(); + const DATA_URL = 'data:image/png;base64,iVBORw0KGgo='; + engine.setOptions({ backgroundSrc: DATA_URL, backgroundOpacity: 0.5 }); + + // Frame 1: legt genau ein Image an und cachet es (complete=true) + engine.render(); + expect(imageCount).toBe(1); + + // Weitere Frames mit identischer src: dürfen KEIN neues Image erzeugen + engine.render(); + engine.render(); + engine.render(); + expect(imageCount).toBe(1); + + // Ausstehende Mikrotasks (simuliertes onload) abwarten, dann erneut prüfen: + await Promise.resolve(); + engine.render(); + expect(imageCount).toBe(1); // vorher (Bug): jedes render() erzeugte ein neues Image + }); +});