Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.
Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
Prioritäts-Verschneidung (section_boolean.rs), einstellbare
Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken,
|
||||
// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte,
|
||||
// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
Layout,
|
||||
LayoutFolder,
|
||||
MasterLayout,
|
||||
Project,
|
||||
ViewSnapshot,
|
||||
} from "../model/types";
|
||||
import {
|
||||
addAnnotation,
|
||||
addViewport,
|
||||
buildLayoutTree,
|
||||
buildMasterTree,
|
||||
collectFolderLayouts,
|
||||
createAnnotation,
|
||||
createLayout,
|
||||
createLayoutFolder,
|
||||
createMasterLayout,
|
||||
createViewport,
|
||||
deleteFolder,
|
||||
deleteMasterFolder,
|
||||
effectiveSheetSizeMm,
|
||||
findMaster,
|
||||
findSnapshot,
|
||||
folderPdfPages,
|
||||
isDescendant,
|
||||
moveFolderToFolder,
|
||||
moveLayoutToFolder,
|
||||
PAPER_FORMATS,
|
||||
patchAnnotation,
|
||||
patchViewport,
|
||||
removeAnnotation,
|
||||
removeViewport,
|
||||
resolveTitleBlock,
|
||||
resolveViewportScale,
|
||||
sheetSizeMm,
|
||||
visibleCodesFromSnapshot,
|
||||
} from "./layoutModel";
|
||||
|
||||
/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */
|
||||
function snap(id: string, patch?: Partial<ViewSnapshot>): ViewSnapshot {
|
||||
return {
|
||||
id,
|
||||
name: `Snap ${id}`,
|
||||
viewType: "grundriss",
|
||||
view3d: "top",
|
||||
scaleDenominator: 100,
|
||||
detail: "mittel",
|
||||
activeLevelId: "floor-0",
|
||||
layerVisibility: { A: true, B: false, C: true },
|
||||
drawingVisibility: {},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */
|
||||
function project(patch?: Partial<Project>): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Villa Test",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [],
|
||||
layers: [],
|
||||
walls: [],
|
||||
doors: [],
|
||||
drawings2d: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sheetSizeMm", () => {
|
||||
it("liefert A4/A3 in Hoch- und Querformat", () => {
|
||||
expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 });
|
||||
expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 });
|
||||
expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 });
|
||||
expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fabriken", () => {
|
||||
it("createLayout: leer, ohne Master", () => {
|
||||
const l = createLayout("l1", "Blatt 1", "a3", "landscape");
|
||||
expect(l).toEqual({
|
||||
id: "l1",
|
||||
name: "Blatt 1",
|
||||
paper: "a3",
|
||||
orientation: "landscape",
|
||||
viewports: [],
|
||||
});
|
||||
expect(l.masterId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("createMasterLayout: leerer Titelblock + Rahmen an", () => {
|
||||
const m = createMasterLayout("m1", "Master", "a4", "portrait");
|
||||
expect(m.titleBlock).toEqual({});
|
||||
expect(m.border).toBe(true);
|
||||
});
|
||||
|
||||
it("createViewport: Defaults + optionaler Massstab", () => {
|
||||
const v = createViewport("v1", "s1");
|
||||
expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 });
|
||||
expect(v.scaleDenominator).toBeUndefined();
|
||||
const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 });
|
||||
expect(v2.xMm).toBe(5);
|
||||
expect(v2.scaleDenominator).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Viewport-CRUD (immutabel)", () => {
|
||||
const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1"));
|
||||
|
||||
it("addViewport verändert das Original nicht", () => {
|
||||
const before = createLayout("l1", "L", "a4", "portrait");
|
||||
const after = addViewport(before, createViewport("v1", "s1"));
|
||||
expect(before.viewports).toHaveLength(0);
|
||||
expect(after.viewports).toHaveLength(1);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it("removeViewport entfernt per Id", () => {
|
||||
const two = addViewport(base, createViewport("v2", "s2"));
|
||||
const one = removeViewport(two, "v1");
|
||||
expect(one.viewports.map((v) => v.id)).toEqual(["v2"]);
|
||||
});
|
||||
|
||||
it("patchViewport ändert nur das Ziel, immutabel", () => {
|
||||
const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 });
|
||||
expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 });
|
||||
expect(base.viewports[0].xMm).toBe(20);
|
||||
// Unbekannte Id → No-op (gleiche Werte, neue Liste).
|
||||
const noop = patchViewport(base, "nope", { xMm: 1 });
|
||||
expect(noop.viewports[0].xMm).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Annotationen (Linie/Rechteck/Text) ───────────────────────────────────────
|
||||
|
||||
describe("createAnnotation — Defaults je Art", () => {
|
||||
it("line: Default-Geometrie ohne Farbe/Strichstärke", () => {
|
||||
const a = createAnnotation("a1", "line");
|
||||
expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 });
|
||||
});
|
||||
it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => {
|
||||
const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 });
|
||||
expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 });
|
||||
});
|
||||
it("rect: Default-Grösse", () => {
|
||||
const a = createAnnotation("a2", "rect");
|
||||
expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 });
|
||||
});
|
||||
it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => {
|
||||
const a = createAnnotation("a3", "text");
|
||||
expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 });
|
||||
});
|
||||
it("text: init setzt Inhalt + Höhe", () => {
|
||||
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
|
||||
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Annotations-CRUD (immutabel)", () => {
|
||||
const base = createLayout("l1", "L", "a4", "portrait");
|
||||
|
||||
it("addAnnotation verändert das Original nicht", () => {
|
||||
const after = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
expect(base.annotations).toBeUndefined();
|
||||
expect(after.annotations).toHaveLength(1);
|
||||
expect(after).not.toBe(base);
|
||||
});
|
||||
|
||||
it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => {
|
||||
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
|
||||
const after = addAnnotation(legacy, createAnnotation("a1", "rect"));
|
||||
expect(after.annotations).toHaveLength(1);
|
||||
expect(legacy.annotations).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeAnnotation entfernt per Id", () => {
|
||||
let l = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
l = addAnnotation(l, createAnnotation("a2", "rect"));
|
||||
const removed = removeAnnotation(l, "a1");
|
||||
expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]);
|
||||
});
|
||||
|
||||
it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => {
|
||||
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
|
||||
const r = removeAnnotation(legacy, "nope");
|
||||
expect(r.annotations).toEqual([]);
|
||||
});
|
||||
|
||||
it("patchAnnotation ändert nur das Ziel, immutabel", () => {
|
||||
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
|
||||
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
|
||||
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
|
||||
expect(l.annotations![0].color).toBe("#111");
|
||||
});
|
||||
|
||||
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
|
||||
const l = addAnnotation(base, createAnnotation("a1", "line"));
|
||||
const noop = patchAnnotation(l, "nope", { x1Mm: 1 });
|
||||
expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20);
|
||||
});
|
||||
|
||||
it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => {
|
||||
const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" }));
|
||||
const patched = patchAnnotation(l, "t1", { text: "1. OG" });
|
||||
expect(patched.annotations![0]).toMatchObject({ text: "1. OG" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveViewportScale", () => {
|
||||
const s = snap("s1", { scaleDenominator: 200 });
|
||||
it("Viewport-Override gewinnt", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50);
|
||||
});
|
||||
it("sonst Ausschnitt-Massstab", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200);
|
||||
});
|
||||
it("Fallback ohne beides", () => {
|
||||
expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSnapshot / findMaster", () => {
|
||||
const p = project({
|
||||
viewSnapshots: [snap("s1"), snap("s2")],
|
||||
masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")],
|
||||
});
|
||||
it("findSnapshot", () => {
|
||||
expect(findSnapshot(p, "s2")?.id).toBe("s2");
|
||||
expect(findSnapshot(p, "x")).toBeUndefined();
|
||||
});
|
||||
it("findMaster nur bei gesetzter masterId", () => {
|
||||
const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" };
|
||||
expect(findMaster(p, bound)?.id).toBe("m1");
|
||||
expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined();
|
||||
// Dangling masterId → undefined.
|
||||
expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTitleBlock — Master-Vererbung + Defaults", () => {
|
||||
const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] });
|
||||
|
||||
it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => {
|
||||
let l = createLayout("l1", "EG Grundriss", "a4", "portrait");
|
||||
l = addViewport(l, createViewport("v1", "s1"));
|
||||
const tb = resolveTitleBlock(p, l, undefined, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Villa Test");
|
||||
expect(tb.sheetName).toBe("EG Grundriss");
|
||||
expect(tb.scale).toBe("1:50");
|
||||
expect(tb.date).toBe("2026-07-07");
|
||||
expect(tb.author).toBe("");
|
||||
});
|
||||
|
||||
it("Master-Werte übersteuern die Defaults", () => {
|
||||
const master: MasterLayout = {
|
||||
id: "m1",
|
||||
name: "M",
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" },
|
||||
};
|
||||
const l = createLayout("l1", "EG", "a4", "portrait");
|
||||
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Kundenprojekt");
|
||||
expect(tb.author).toBe("GV");
|
||||
expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default
|
||||
expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name
|
||||
});
|
||||
|
||||
it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => {
|
||||
const master: MasterLayout = {
|
||||
id: "m1",
|
||||
name: "M",
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: { projectName: " ", sheetName: "" },
|
||||
};
|
||||
const l = createLayout("l1", "OG", "a4", "portrait");
|
||||
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
|
||||
expect(tb.projectName).toBe("Villa Test");
|
||||
expect(tb.sheetName).toBe("OG");
|
||||
expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleCodesFromSnapshot", () => {
|
||||
it("nur sichtbare Codes landen im Set", () => {
|
||||
const codes = visibleCodesFromSnapshot(snap("s1"));
|
||||
expect([...codes].sort()).toEqual(["A", "C"]);
|
||||
expect(codes.has("B")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Freie Grösse (custom übersteuert Papier) ──────────────────────────────────
|
||||
|
||||
describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => {
|
||||
it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => {
|
||||
expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 });
|
||||
});
|
||||
it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => {
|
||||
expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 });
|
||||
expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 });
|
||||
});
|
||||
it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => {
|
||||
expect(
|
||||
effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }),
|
||||
).toEqual({ w: 210, h: 297 });
|
||||
expect(
|
||||
effectiveSheetSizeMm({
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
customWidthMm: 250,
|
||||
customHeightMm: 250,
|
||||
}),
|
||||
).toEqual({ w: 250, h: 250 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMasterLayout — freie Grösse", () => {
|
||||
it("übernimmt custom Breite/Höhe, wenn übergeben", () => {
|
||||
const m = createMasterLayout("m", "M", "a4", "portrait", {
|
||||
widthMm: 400,
|
||||
heightMm: 250,
|
||||
});
|
||||
expect(m.customWidthMm).toBe(400);
|
||||
expect(m.customHeightMm).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ordner-Baum (CRUD + Aufbau) ───────────────────────────────────────────────
|
||||
|
||||
function lay(id: string, folderId?: string): Layout {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
viewports: [],
|
||||
...(folderId ? { folderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createLayoutFolder", () => {
|
||||
it("Wurzelordner ohne parentId", () => {
|
||||
expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" });
|
||||
});
|
||||
it("Unterordner mit parentId", () => {
|
||||
expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({
|
||||
id: "f2",
|
||||
name: "Sub",
|
||||
parentId: "f1",
|
||||
});
|
||||
});
|
||||
it("Master-Ordner trägt kind:\"master\"", () => {
|
||||
expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({
|
||||
id: "m1",
|
||||
name: "Master-Ordner",
|
||||
kind: "master",
|
||||
});
|
||||
});
|
||||
it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => {
|
||||
expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({
|
||||
id: "l1",
|
||||
name: "Layout-Ordner",
|
||||
});
|
||||
expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({
|
||||
id: "l2",
|
||||
name: "Layout-Ordner",
|
||||
parentId: "p",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLayoutTree", () => {
|
||||
it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")];
|
||||
const tree = buildLayoutTree(folders, layouts);
|
||||
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]);
|
||||
expect(tree.rootFolders).toHaveLength(1);
|
||||
const a = tree.rootFolders[0];
|
||||
expect(a.folder.id).toBe("A");
|
||||
expect(a.layouts.map((l) => l.id)).toEqual(["inA"]);
|
||||
expect(a.subfolders).toHaveLength(1);
|
||||
expect(a.subfolders[0].folder.id).toBe("B");
|
||||
expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]);
|
||||
});
|
||||
|
||||
it("verwaiste Referenzen landen auf der Wurzel", () => {
|
||||
const tree = buildLayoutTree(
|
||||
[createLayoutFolder("orphan", "O", "missing")],
|
||||
[lay("ghost", "gone")],
|
||||
);
|
||||
// Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel.
|
||||
expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]);
|
||||
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Master-Baum + Layout/Master-Trennung (kind) ──────────────────────────────
|
||||
|
||||
function mas(id: string, folderId?: string): MasterLayout {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
paper: "a4",
|
||||
orientation: "portrait",
|
||||
titleBlock: {},
|
||||
...(folderId ? { folderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMasterTree", () => {
|
||||
it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A", undefined, "master"),
|
||||
createLayoutFolder("B", "B", "A", "master"),
|
||||
];
|
||||
const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")];
|
||||
const tree = buildMasterTree(folders, masters);
|
||||
expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]);
|
||||
expect(tree.rootFolders).toHaveLength(1);
|
||||
const a = tree.rootFolders[0];
|
||||
expect(a.folder.id).toBe("A");
|
||||
expect(a.masters.map((m) => m.id)).toEqual(["inA"]);
|
||||
expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => {
|
||||
it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout
|
||||
createLayoutFolder("M", "Master-Ordner", undefined, "master"),
|
||||
];
|
||||
const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner
|
||||
const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner
|
||||
|
||||
// Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master".
|
||||
const layoutTree = buildLayoutTree(
|
||||
folders.filter((f) => f.kind !== "master"),
|
||||
layouts,
|
||||
);
|
||||
const masterTree = buildMasterTree(
|
||||
folders.filter((f) => f.kind === "master"),
|
||||
masters,
|
||||
);
|
||||
|
||||
// Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel.
|
||||
expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]);
|
||||
expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]);
|
||||
// Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel.
|
||||
expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]);
|
||||
expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]);
|
||||
expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMasterFolder", () => {
|
||||
it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A", undefined, "master"),
|
||||
createLayoutFolder("B", "B", "A", "master"),
|
||||
];
|
||||
const masters = [mas("a1", "A"), mas("b1", "B")];
|
||||
const r = deleteMasterFolder("A", folders, masters);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
|
||||
// B auf Wurzel hochgezogen, aber weiterhin Master-Ordner.
|
||||
expect(r.folders[0].parentId).toBeUndefined();
|
||||
expect(r.folders[0].kind).toBe("master");
|
||||
expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined();
|
||||
expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFolderLayouts", () => {
|
||||
it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")];
|
||||
expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
"b1",
|
||||
]);
|
||||
expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([
|
||||
"b1",
|
||||
]);
|
||||
});
|
||||
it("unbekannter Ordner → leer", () => {
|
||||
expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFolder", () => {
|
||||
it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("a1", "A"), lay("b1", "B")];
|
||||
// A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel.
|
||||
const r = deleteFolder("A", folders, layouts);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
|
||||
expect(r.folders[0].parentId).toBeUndefined();
|
||||
expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined();
|
||||
expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B");
|
||||
});
|
||||
it("Unterordner löschen zieht Kinder zum Grosselter", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
];
|
||||
const layouts = [lay("b1", "B")];
|
||||
const r = deleteFolder("B", folders, layouts);
|
||||
expect(r.folders.map((f) => f.id)).toEqual(["A"]);
|
||||
expect(r.layouts[0].folderId).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderPdfPages", () => {
|
||||
it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => {
|
||||
const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape");
|
||||
const layouts: Layout[] = [
|
||||
{ ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master
|
||||
{ ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse
|
||||
lay("p3", "F"), // A4 hoch (Default)
|
||||
];
|
||||
const p = project({
|
||||
layoutFolders: [createLayoutFolder("F", "F")],
|
||||
layouts,
|
||||
masterLayouts: [master],
|
||||
});
|
||||
const pages = folderPdfPages(p, "F");
|
||||
expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]);
|
||||
expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer
|
||||
expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom
|
||||
expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch
|
||||
});
|
||||
it("leerer/unbekannter Ordner → keine Seiten", () => {
|
||||
expect(folderPdfPages(project(), "nope")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PAGE_MM / Standard-Formate A0–A6 + B0–B6", () => {
|
||||
it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => {
|
||||
expect(PAPER_FORMATS).toEqual([
|
||||
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
|
||||
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
|
||||
]);
|
||||
});
|
||||
it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => {
|
||||
expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 });
|
||||
expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 });
|
||||
expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 });
|
||||
expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 });
|
||||
expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 });
|
||||
expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 });
|
||||
expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 });
|
||||
expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 });
|
||||
});
|
||||
it("Querformat dreht die Basis (Beispiel A0)", () => {
|
||||
expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 });
|
||||
});
|
||||
it("jedes Format hat eine definierte Grösse", () => {
|
||||
for (const f of PAPER_FORMATS) {
|
||||
const s = sheetSizeMm(f, "portrait");
|
||||
expect(s.w).toBeGreaterThan(0);
|
||||
expect(s.h).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDescendant — Zyklus-Schutz", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
createLayoutFolder("C", "C", "B"),
|
||||
createLayoutFolder("X", "X"),
|
||||
];
|
||||
it("ein Ordner ist sein eigener Nachfahre (self)", () => {
|
||||
expect(isDescendant(folders, "A", "A")).toBe(true);
|
||||
});
|
||||
it("erkennt direkte + tiefe Nachfahren", () => {
|
||||
expect(isDescendant(folders, "B", "A")).toBe(true);
|
||||
expect(isDescendant(folders, "C", "A")).toBe(true);
|
||||
});
|
||||
it("nicht-verwandte Ordner sind keine Nachfahren", () => {
|
||||
expect(isDescendant(folders, "X", "A")).toBe(false);
|
||||
expect(isDescendant(folders, "A", "B")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveLayoutToFolder", () => {
|
||||
const layouts = [lay("l1", "A"), lay("l2")];
|
||||
it("setzt folderId auf den Zielordner", () => {
|
||||
const r = moveLayoutToFolder(layouts, "l2", "A");
|
||||
expect(r.find((l) => l.id === "l2")!.folderId).toBe("A");
|
||||
});
|
||||
it("null → zurück auf Wurzel (folderId entfernt)", () => {
|
||||
const r = moveLayoutToFolder(layouts, "l1", null);
|
||||
expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined();
|
||||
expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false);
|
||||
});
|
||||
it("unbekannte Id → unverändert", () => {
|
||||
expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFolderToFolder", () => {
|
||||
const folders: LayoutFolder[] = [
|
||||
createLayoutFolder("A", "A"),
|
||||
createLayoutFolder("B", "B", "A"),
|
||||
createLayoutFolder("X", "X"),
|
||||
];
|
||||
it("setzt parentId auf den Zielordner", () => {
|
||||
const r = moveFolderToFolder(folders, "X", "A");
|
||||
expect(r.find((f) => f.id === "X")!.parentId).toBe("A");
|
||||
});
|
||||
it("null → zurück auf Wurzel (parentId entfernt)", () => {
|
||||
const r = moveFolderToFolder(folders, "B", null);
|
||||
expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined();
|
||||
});
|
||||
it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => {
|
||||
const withMaster: LayoutFolder[] = [
|
||||
createLayoutFolder("P", "P", undefined, "master"),
|
||||
createLayoutFolder("Q", "Q", "P", "master"),
|
||||
];
|
||||
const r = moveFolderToFolder(withMaster, "Q", null);
|
||||
const q = r.find((f) => f.id === "Q")!;
|
||||
expect(q.parentId).toBeUndefined();
|
||||
expect(q.kind).toBe("master");
|
||||
});
|
||||
it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => {
|
||||
expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders);
|
||||
});
|
||||
it("in sich selbst → No-op", () => {
|
||||
expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user