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:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+654
View File
@@ -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 A0A6 + B0B6", () => {
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);
});
});