/** * Task G2 – LayoutBar UI + Viewport-Widget + ZoomPanController.setView. * * LayoutBar listet Layouts des aktiven Dokuments, zeigt je Layout ein * Minimap-Widget (Modellbereich + Viewport-Rechteck), Klick = zoom-to, * „+“ speichert aktuellen Viewport als neues Layout, Löschen entfernt. * ZoomPanController.setView(center, scale) setzt die Absolute Ansicht. */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import LayoutBar, { computeMinimapRects } from '../src/components/LayoutBar'; import { ZoomPanController } from '../src/canvas/ZoomPanController'; import type { LayoutDefinition } from '../src/types/cad.types'; function createMockCanvas(w = 800, h = 600): HTMLCanvasElement { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; return canvas; } function fakeDoc(layouts: LayoutDefinition[]) { return { getAllLayouts: () => [...layouts], addLayout: vi.fn((l: LayoutDefinition) => { layouts.push(l); }), deleteLayout: vi.fn((id: string) => { const i = layouts.findIndex((l) => l.id === id); if (i >= 0) layouts.splice(i, 1); }), }; } function fakeBridge(view = { center: { x: 10, y: 20 }, scale: 2 }) { return { zoomTo: vi.fn(), getView: vi.fn(() => view), }; } const L1: LayoutDefinition = { id: 'layout-1', name: 'A4 Plan', viewport: { center: { x: 100, y: 50 }, scale: 0.5 }, }; // ─── ZoomPanController.setView ───────────────────────────── describe('G2: ZoomPanController.setView', () => { it('setView(center, scale) zentriert Welt-Punkt exakt, Scale direkt', () => { const zpc = new ZoomPanController(createMockCanvas(800, 600)); zpc.setView({ x: 100, y: 50 }, 2); expect(zpc.getScale()).toBe(2); const vp = zpc.getViewport(); const cx = (vp.minX + vp.maxX) / 2; const cy = (vp.minY + vp.maxY) / 2; expect(cx).toBeCloseTo(100, 5); expect(cy).toBeCloseTo(50, 5); }); it('setView mit scale 1 bei Origin deckt (0,0) als Zentrum ab', () => { const zpc = new ZoomPanController(createMockCanvas(800, 600)); zpc.setView({ x: 0, y: 0 }, 1); const vp = zpc.getViewport(); expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5); expect((vp.minY + vp.maxY) / 2).toBeCloseTo(0, 5); }); it('zweiter setView-Aufruf überschreibt ersten komplett', () => { const zpc = new ZoomPanController(createMockCanvas(800, 600)); zpc.setView({ x: 500, y: 500 }, 4); zpc.setView({ x: 0, y: 0 }, 0.5); expect(zpc.getScale()).toBe(0.5); const vp = zpc.getViewport(); expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5); }); }); // ─── computeMinimapRects (Viewport-Widget-Geometrie) ─────── describe('G2: computeMinimapRects', () => { const elements = [ { x: 50, y: 50, width: 100, height: 100 }, // bbox 0..100 ]; it('Modellbbox füllt Widget (mit Padding), leer → Volles Widget', () => { const r = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80); expect(r.model.w).toBeGreaterThan(80); expect(r.model.h).toBeGreaterThan(60); const empty = computeMinimapRects([], { center: { x: 0, y: 0 }, scale: 1 }, 100, 80); expect(empty.model.w).toBe(100); expect(empty.model.h).toBe(80); }); it('Viewport-Zentrum liegt im View-Rechteck; höherer Scale = kleinerer Ausschnitt', () => { const r1 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80); const cx = r1.model.x + (r1.view.x + r1.view.w / 2 - r1.model.x) / r1.model.w * r1.model.w; // View-Zentrum in Widget-Koordinaten: const viewCX = r1.view.x + r1.view.w / 2; const modelCX = r1.model.x + r1.model.w / 2; expect(Math.abs(viewCX - modelCX)).toBeLessThan(1); // 50,50 = BBox-Zentrum const r2 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 4 }, 100, 80); expect(r2.view.w).toBeLessThan(r1.view.w); expect(r2.view.h).toBeLessThan(r1.view.h); }); }); // ─── LayoutBar UI ────────────────────────────────────────── describe('G2: LayoutBar', () => { it('ohne Doc: Empty-State ohne Crash', () => { render(); expect(screen.getByText(/Keine Layouts/)).toBeTruthy(); }); it('listet Layouts mit Name + Minimap-SVG', () => { const doc = fakeDoc([L1]); render(); expect(screen.getByText('A4 Plan')).toBeTruthy(); expect(screen.getByTestId('layout-minimap-layout-1')).toBeTruthy(); }); it('Klick auf Layout-Eintrag ruft bridge.zoomTo mit gespeichertem Viewport', () => { const bridge = fakeBridge(); render(); fireEvent.click(screen.getByTestId('layout-entry-layout-1')); expect(bridge.zoomTo).toHaveBeenCalledWith( { x: 100, y: 50 }, 0.5, ); }); it('+ Button speichert aktuellen Viewport als neues Layout', () => { const layouts: LayoutDefinition[] = []; const doc = fakeDoc(layouts); const bridge = fakeBridge(); render(); fireEvent.click(screen.getByTitle('Layout hinzufügen')); expect(doc.addLayout).toHaveBeenCalledTimes(1); const added = (doc.addLayout as ReturnType).mock.calls[0][0] as LayoutDefinition; expect(added.viewport.center.x).toBe(10); expect(added.viewport.center.y).toBe(20); expect(added.viewport.scale).toBe(2); expect(added.name).toMatch(/Layout \d/); expect(added.id).toBeTruthy(); }); it('Löschen-Button entfernt Layout über doc.deleteLayout', () => { const doc = fakeDoc([L1]); render(); fireEvent.click(screen.getByTitle('Layout A4 Plan löschen')); expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1'); }); it('ohne Bridge: Klick wirft nicht (no-op)', () => { render(); expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow(); }); });