import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { booleanShapePathData, booleanShapePathResult } from "../src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts"; import { booleanShapeGroupToPathNode, createBooleanShapeGroupNode, createBooleanShapeResultNode, parseBooleanShapeOptions, patchShapeStyleNode, updateBooleanShapeOperation } from "../src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts"; const first = shapeNode("first", 0, 0, 100, 100); const second = shapeNode("second", 50, 0, 100, 100); const operands = [first, second].map((node) => ({ id: node.id, title: node.title, x: node.x, y: node.y, width: node.width, height: node.height, content: node.content, fillColor: node.fillColor, strokeColor: node.strokeColor, strokeWidth: node.strokeWidth, strokeStyle: node.strokeStyle, opacity: node.opacity })); test("creates real result paths for every boolean operation", () => { const union = booleanShapePathData(operands, "union", 150, 100); const subtract = booleanShapePathData(operands, "subtract", 150, 100); const intersect = booleanShapePathData(operands, "intersect", 150, 100); const exclude = booleanShapePathData(operands, "exclude", 150, 100); assert.deepEqual(pathBounds(union), { left: 0, top: 0, right: 1, bottom: 1 }); assertBounds(pathBounds(subtract), { left: 0, top: 0, right: 1 / 3, bottom: 1 }); assertBounds(pathBounds(intersect), { left: 1 / 3, top: 0, right: 2 / 3, bottom: 1 }); assert.deepEqual(pathBounds(exclude), { left: 0, top: 0, right: 1, bottom: 1 }); assert.equal(commandCount(union, 1), 1); assert.equal(commandCount(subtract, 1), 1); assert.equal(commandCount(intersect, 1), 1); assert.equal(commandCount(exclude, 1), 2); }); test("stores the computed result path on a boolean group", () => { const group = createBooleanShapeGroupNode({ id: "boolean-1", title: "UNION", operation: "union", nodes: [first, second] }); const options = parseBooleanShapeOptions(group.content); assert.ok(Array.isArray(options.pathData)); assert.equal(options.pathData[0], 1); assert.equal(options.width, 150); assert.equal(options.height, 100); assert.equal(group.fillColor, first.fillColor); assert.equal(group.strokeColor, first.strokeColor); }); test("creates boolean operations as ordinary editable path nodes", () => { const result = createBooleanShapeResultNode({ id: "result-1", title: "UNION", operation: "union", nodes: [first, second] }); const options = JSON.parse(result.content); assert.equal(result.layerRole, "shape"); assert.equal(options.kind, "path"); assert.ok(Array.isArray(options.pathData)); assert.equal(result.fillColor, first.fillColor); assert.equal(result.strokeColor, first.strokeColor); }); test("keeps each operand stroke style on the boundary it contributes", () => { const black = { ...first, strokeColor: "#111111", strokeWidth: 3, strokeStyle: "solid" }; const purple = { ...second, strokeColor: "#7C3AED", strokeWidth: 5, strokeStyle: "dashed" }; const styledOperands = [black, purple].map((node) => ({ id: node.id, title: node.title, x: node.x, y: node.y, width: node.width, height: node.height, content: node.content, fillColor: node.fillColor, strokeColor: node.strokeColor, strokeWidth: node.strokeWidth, strokeStyle: node.strokeStyle, opacity: node.opacity })); const computed = booleanShapePathResult(styledOperands, "union", 150, 100); const result = createBooleanShapeResultNode({ id: "result-styled", title: "UNION", operation: "union", nodes: [black, purple] }); const options = JSON.parse(result.content); assert.deepEqual(new Set(computed.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#111111", "#7C3AED"])); assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#111111", "#7C3AED"])); assert.ok(options.strokeSegments.some((segment) => segment.strokeColor === "#111111" && segment.strokeWidth === 3 && segment.strokeStyle === "solid")); assert.ok(options.strokeSegments.some((segment) => segment.strokeColor === "#7C3AED" && segment.strokeWidth === 5 && segment.strokeStyle === "dashed")); }); test("lets the single-shape toolbar intentionally update all boolean result strokes", () => { const black = { ...first, strokeColor: "#111111", strokeWidth: 3 }; const purple = { ...second, strokeColor: "#7C3AED", strokeWidth: 5 }; const result = createBooleanShapeResultNode({ id: "result-patched", title: "UNION", operation: "union", nodes: [black, purple] }); const patched = patchShapeStyleNode(result, { strokeColor: "#22C55E", strokeWidth: 8 }); const options = JSON.parse(patched.content); assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#22C55E"])); assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeWidth)), new Set([8])); }); test("keeps curved operands as curved result paths", () => { const ellipse = { ...operands[0], content: JSON.stringify({ kind: "ellipse" }) }; const rectangle = { ...operands[1], x: 40, y: 20, width: 40, height: 60 }; const result = booleanShapePathData([ellipse, rectangle], "subtract", 100, 100); assert.ok(result.includes(5)); assert.equal(result.at(-1), 11); }); test("recomputes the result path when the boolean operation changes", () => { const union = createBooleanShapeGroupNode({ id: "boolean-2", title: "UNION", operation: "union", nodes: [first, second] }); const subtract = parseBooleanShapeOptions(updateBooleanShapeOperation(union, "subtract").content); assertBounds(pathBounds(subtract.pathData), { left: 0, top: 0, right: 1 / 3, bottom: 1 }); }); test("flattens legacy boolean groups into ordinary path nodes", () => { const group = createBooleanShapeGroupNode({ id: "boolean-3", title: "UNION", operation: "union", nodes: [first, second] }); const result = booleanShapeGroupToPathNode(group); assert.equal(result.layerRole, "shape"); assert.equal(JSON.parse(result.content).kind, "path"); }); test("renders and exports only the computed boolean result path", async () => { const previewSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx", import.meta.url), "utf8"); const exportSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts", import.meta.url), "utf8"); const booleanPreview = previewSource.slice(previewSource.indexOf("function BooleanShapePreview"), previewSource.indexOf("export function shapeLinePathFromNode")); assert.match(booleanPreview, /normalizedPathDataToSvg\(pathData, width, height\)/); assert.match(booleanPreview, /fillRule="evenodd"/); assert.match(previewSource, /options\.strokeSegments/); assert.doesNotMatch(booleanPreview, / { const workspaceSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8"); assert.match(workspaceSource, /createBooleanShapeResultNode\(/); assert.match(workspaceSource, /selectOnlyNode\(resultNode\.id\)/); assert.match(workspaceSource, /selectedShapeNode && selectedShapeBounds[\s\S]*? { if (name === command) count += 1; }); return count; } function assertBounds(actual, expected) { for (const key of ["left", "top", "right", "bottom"]) assert.ok(Math.abs(actual[key] - expected[key]) < 0.00001, `${key}: expected ${expected[key]}, received ${actual[key]}`); } function pathBounds(pathData) { const points = []; walkPathData(pathData, (_name, coordinates) => { for (let index = 0; index < coordinates.length; index += 2) points.push({ x: coordinates[index], y: coordinates[index + 1] }); }); return { left: Math.min(...points.map((point) => point.x)), top: Math.min(...points.map((point) => point.y)), right: Math.max(...points.map((point) => point.x)), bottom: Math.max(...points.map((point) => point.y)) }; } function walkPathData(pathData, visit) { for (let index = 0; index < pathData.length; ) { const name = pathData[index++]; const coordinateCount = name === 1 || name === 2 ? 2 : name === 5 ? 6 : name === 7 ? 4 : name === 11 ? 0 : -1; assert.notEqual(coordinateCount, -1); const coordinates = pathData.slice(index, index + coordinateCount); visit(name, coordinates); index += coordinateCount; } }