Files
web-cad/frontend/tests/layoutBar.test.tsx
T

158 lines
6.2 KiB
TypeScript
Raw Normal View History

/**
* 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(<LayoutBar doc={null} bridge={null} />);
expect(screen.getByText(/Keine Layouts/)).toBeTruthy();
});
it('listet Layouts mit Name + Minimap-SVG', () => {
const doc = fakeDoc([L1]);
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
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(<LayoutBar doc={fakeDoc([L1]) as any} bridge={bridge as any} />);
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(<LayoutBar doc={doc as any} bridge={bridge as any} />);
fireEvent.click(screen.getByTitle('Layout hinzufügen'));
expect(doc.addLayout).toHaveBeenCalledTimes(1);
const added = (doc.addLayout as ReturnType<typeof vi.fn>).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(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
fireEvent.click(screen.getByTitle('Layout A4 Plan löschen'));
expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1');
});
it('ohne Bridge: Klick wirft nicht (no-op)', () => {
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={null} />);
expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow();
});
});