158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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();
|
|||
|
|
});
|
|||
|
|
});
|