220 lines
16 KiB
JavaScript
220 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-draft-business-"));
|
|
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? '["Draft:draft.formatBatch"]');
|
|
if (ids.length === 1 && ids[0] === 'Draft:draft.action') {
|
|
const {runDraftRestoreCases}=await import('./draft-restore-business-cases.mjs');
|
|
const result=await runDraftRestoreCases();
|
|
fs.rmSync(out,{recursive:true,force:true});
|
|
await new Promise(resolve=>process.stdout.write(JSON.stringify({schema:'freecad-web-business-cases/v1',results:[result]})+'\n',resolve));
|
|
process.exit(result.status==='failed'?1:0);
|
|
}
|
|
const svgIds = new Set(["business-format:Draft:import:.svg", "business-format:Draft:export:.svg"]);
|
|
const dxfId = "business-format:Draft:import:.dxf";
|
|
const started = performance.now();
|
|
const timings = { operations: [] };
|
|
let exitCode = 0;
|
|
try {
|
|
let stageStarted = performance.now();
|
|
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), [
|
|
"--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler", "--strict", "--skipLibCheck",
|
|
"--outDir", out, path.join(root, "web/src/facade-envelope.ts"), path.join(root, "web/src/draft-python-family-facade.ts"),
|
|
...(ids.includes("Draft:draft.action") ? [path.join(root, "web/src/draft-facade.ts")] : []),
|
|
], { cwd: root, stdio: "pipe" });
|
|
timings.transpileSeconds = (performance.now() - stageStarted) / 1000;
|
|
const facade = await import(pathToFileURL(path.join(out, "draft-python-family-facade.js")));
|
|
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
|
|
stageStarted = performance.now();
|
|
const { kernel } = await loadWasmProbe(process.env.FREECAD_DRAFT_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebDraftRuntimeProbe");
|
|
timings.nativeLoadSeconds = (performance.now() - stageStarted) / 1000;
|
|
const queue = new FacadeSerialQueue(); let n = 0;
|
|
const actionCases = [];
|
|
if (ids.includes("Draft:draft.action")) {
|
|
const actions = await import(pathToFileURL(path.join(out, "draft-facade.js")));
|
|
const act = async (action, input = {}) => {
|
|
const began = performance.now();
|
|
const response = await actions.dispatchDraft(queue, actions.createDraftRequest(`draft-action-${++n}`, action, { ...actions.DRAFT_FIXTURE, action, ...input }), kernel);
|
|
timings.operations.push({ action, seconds: (performance.now() - began) / 1000, result: response.kind });
|
|
return response;
|
|
};
|
|
const success = response => { assert.ok(response.result, JSON.stringify(response)); return response.result; };
|
|
for (const [documentId, length, height] of [["DraftActionA", 20, 10], ["DraftActionB", 7, 3]]) {
|
|
const created = success(await act("create/recompute", { documentId, length, height }));
|
|
assert.equal(created.documentId, documentId);
|
|
assert.equal(created.objectType, "Part::Part2DObjectPython");
|
|
assert.ok(Math.abs(created.area - length * height) < 1e-7, `Created area ${created.area} differs from ${length * height}`);
|
|
assert.equal(created.documentObjectCount, 1);
|
|
assert.match(created.stateSha256, /^[a-f0-9]{64}$/);
|
|
assert.ok(created.geometry.some(item => item.object === created.object && item.triangles.length > 0));
|
|
assert.equal(Buffer.from(created.file.base64, "base64").subarray(0, 2).toString(), "PK");
|
|
const input = { documentId, object: created.object, length: length + 4, height: height + 2 };
|
|
const edited = success(await act("edit/recompute", input));
|
|
assert.equal(edited.object, created.object);
|
|
assert.ok(Math.abs(edited.area - input.length * input.height) < 1e-7, `Edited area ${edited.area} differs from ${input.length * input.height}`);
|
|
assert.equal(edited.documentObjectCount, 1);
|
|
assert.notEqual(edited.stateSha256, created.stateSha256);
|
|
const cancelled = success(await act("cancel", { ...input, length: 100, height: 100 }));
|
|
assert.equal(cancelled.stateSha256, edited.stateSha256);
|
|
for (const length of [0, -1, Infinity, NaN, "12"]) {
|
|
const rejected = await act("edit/recompute", { ...input, length });
|
|
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
|
|
}
|
|
const missing = await act("edit/recompute", { ...input, object: "MissingRectangle" });
|
|
assert.equal(missing.kind, "error", JSON.stringify(missing));
|
|
const inspected = success(await act("inspect/document", input));
|
|
assert.equal(inspected.stateSha256, edited.stateSha256);
|
|
const appended = success(await act("create/recompute", { documentId, length: 2, height: 3 }));
|
|
assert.notEqual(appended.object, created.object);
|
|
assert.equal(appended.documentObjectCount, 2);
|
|
assert.equal(appended.objects.find(item => item.name === created.object).area, edited.area);
|
|
actionCases.push({ input: { documentId, create: { length, height }, edit: input }, actual: { created, edited, cancelled, inspected, appended }, assertions: [
|
|
{ name: "native rectangle creates parameter-driven face geometry in the requested persistent document", passed: true },
|
|
{ name: "editing recomputes the same object with changed dimensions and area", passed: true },
|
|
{ name: "cancel and rejected edits preserve the document geometry and state", passed: true },
|
|
{ name: "subsequent creation preserves the edited rectangle and appends a distinct object", passed: true },
|
|
{ name: "mutations return native FCStd bytes and geometry for display", passed: true },
|
|
] });
|
|
}
|
|
for (const request of [null, undefined, {}, { requestId: 17 }]) {
|
|
const rejected = await actions.dispatchDraft(queue, request, kernel);
|
|
assert.equal(rejected.kind, "error");
|
|
assert.equal(rejected.requestId, "invalid");
|
|
}
|
|
if (ids.every(id => id === "Draft:draft.action")) {
|
|
timings.totalSeconds = (performance.now() - started) / 1000;
|
|
// Partial native proof does not claim the primary browser workflow or FCStd reopening.
|
|
const report = { schema: "freecad-web-business-cases/v1", timings, results: [{ capabilityId: "Draft:draft.action", status: "not_implemented", scope: "persistent-native-create-edit-recompute", cases: actionCases, remaining: ["primary-workspace browser integration", "FCStd save/reopen through OPFS", "browser executable acceptance cases"] }] };
|
|
await new Promise((resolve, reject) => process.stdout.write(JSON.stringify(report) + "\n", error => error ? reject(error) : resolve()));
|
|
fs.rmSync(out, { recursive: true, force: true });
|
|
process.exit(0);
|
|
}
|
|
}
|
|
const call = async records => {
|
|
const began = performance.now();
|
|
const result = await facade.dispatchDraftFormat(queue, facade.createDraftFormatRequest(`draft-${++n}`, records), kernel);
|
|
timings.operations.push({ format: records[0].format, direction: records[0].direction, seconds: (performance.now() - began) / 1000, result: result.kind });
|
|
return result;
|
|
};
|
|
const cases = [];
|
|
for (const request of [null, undefined, {}, { requestId: 17 }]) {
|
|
const rejected = await facade.dispatchDraftFormat(queue, request, kernel);
|
|
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
|
|
assert.equal(rejected.requestId, "invalid");
|
|
cases.push({ input: { malformedRequest: request ?? null }, actual: rejected,
|
|
assertions: [{ name: "malformed format envelope returns a structured error", passed: true }] });
|
|
}
|
|
const lengths = [];
|
|
for (const [documentId, width, height] of [["DraftCaseA", 5, 3], ["DraftCaseB", 8, 4]]) {
|
|
const payload = `<svg xmlns="http://www.w3.org/2000/svg"><rect width="${width}" height="${height}"/></svg>`;
|
|
const imported = await call([{ format: "svg", direction: "import", documentId, payload }]);
|
|
assert.equal(imported.kind, "result", JSON.stringify(imported));
|
|
const first = imported.result.records[0];
|
|
assert.equal(first.documentId, documentId);
|
|
assert.equal(first.objectCount > 0, true);
|
|
assert.equal(first.objects.every(object => object.isNull === false && object.length > 0), true);
|
|
lengths.push(first.objects.reduce((sum, object) => sum + object.length, 0));
|
|
const appended = await call([{ format: "svg", direction: "import", documentId, payload }]);
|
|
assert.equal(appended.kind, "result", JSON.stringify(appended));
|
|
const second = appended.result.records[0];
|
|
assert.equal(second.documentId, documentId);
|
|
assert.equal(second.objectCount, first.objectCount);
|
|
assert.equal(second.objects.every(object => !first.objects.some(prior => prior.objectHandle === object.objectHandle)), true);
|
|
const exported = await call([{ format: "svg", direction: "export", documentId }]);
|
|
assert.equal(exported.kind, "result", JSON.stringify(exported));
|
|
assert.match(exported.result.records[0].payload, /<svg/i);
|
|
const exportedNames = new Set(exported.result.records[0].objects.map(object => object.objectHandle));
|
|
const retainsImports = [...first.objects, ...second.objects].every(object => exportedNames.has(object.objectHandle));
|
|
assert.equal(retainsImports, true);
|
|
const roundTrip = await call([{ format: "svg", direction: "import", documentId: `${documentId}RoundTrip`, payload: exported.result.records[0].payload }]);
|
|
assert.equal(roundTrip.kind, "result", JSON.stringify(roundTrip));
|
|
const restored = roundTrip.result.records[0];
|
|
const exportedLength = exported.result.records[0].objects.reduce((sum, object) => sum + object.length, 0);
|
|
const restoredLength = restored.objects.reduce((sum, object) => sum + object.length, 0);
|
|
assert.ok(Math.abs(restoredLength - exportedLength) < 1e-6, `SVG round trip changed native perimeter: ${exportedLength} -> ${restoredLength}`);
|
|
cases.push({ actual: { firstImport: first, repeatedImport: second, export: exported.result.records[0], roundTrip: restored }, input: { records: [
|
|
{ format: "svg", direction: "import", documentId, payload },
|
|
{ format: "svg", direction: "import", documentId, payload },
|
|
{ format: "svg", direction: "export", documentId },
|
|
] }, assertions: [
|
|
{ name: "native SVG import creates geometry in the requested new document", passed: first.documentId === documentId && first.objects.every(object => object.isNull === false && object.length > 0) },
|
|
{ name: "repeated import appends distinct objects to the existing document", passed: second.objects.every(object => !first.objects.some(prior => prior.objectHandle === object.objectHandle)) },
|
|
{ name: "export retains geometry from both imports", passed: retainsImports },
|
|
{ name: "exported SVG restores the native perimeter in another document", passed: Math.abs(restoredLength - exportedLength) < 1e-6 },
|
|
] });
|
|
}
|
|
assert.ok(Math.abs(lengths[1] / lengths[0] - 1.5) < 1e-8, `native perimeters must scale with 5x3 and 8x4 rectangles: ${lengths}`);
|
|
const observedInput = { format: "svg", direction: "import", documentId: "draft-user", payload: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="5" height="3"/></svg>' };
|
|
const observed = await call([observedInput]);
|
|
assert.equal(observed.kind, "result", JSON.stringify(observed));
|
|
assert.equal(observed.result.records[0].objects.every(object => object.isNull === false && object.length > 0), true);
|
|
cases.push({ input: observedInput, actual: observed.result.records[0], assertions: [
|
|
{ name: "original runtime issue input creates real native SVG geometry", passed: true },
|
|
{ name: "different SVG dimensions produce proportional native perimeters", passed: true },
|
|
] });
|
|
const missing = await call([{ format: "svg", direction: "export", documentId: "NoSuchDraftDocument" }]);
|
|
assert.equal(missing.kind, "error");
|
|
cases.push({ input: { format: "svg", direction: "export", documentId: "NoSuchDraftDocument" }, assertions: [{ name: "missing document is rejected", passed: missing.kind === "error" }] });
|
|
const dxfCases = [];
|
|
let dxfError = null;
|
|
if (ids.includes(dxfId) || ids.includes("Draft:draft.formatBatch")) {
|
|
try {
|
|
const length = record => record.objects.reduce((sum, object) => sum + (object.length ?? 0), 0);
|
|
for (const [documentId, x, y] of [["DXFBusinessCheck", 3, 4], ["DXFBusinessOther", 6, 8]]) {
|
|
const payload = ["0", "SECTION", "2", "ENTITIES", "0", "LINE", "8", "0", "10", "0", "20", "0", "30", "0",
|
|
"11", String(x), "21", String(y), "31", "0", "0", "ENDSEC", "0", "EOF", ""].join("\n");
|
|
const input = { format: "dxf", direction: "import", documentId, payload };
|
|
const imported = await call([input]);
|
|
assert.equal(imported.kind, "result", JSON.stringify(imported));
|
|
const first = imported.result.records[0];
|
|
assert.equal(first.documentId, documentId);
|
|
assert.ok(Math.abs(length(first) - Math.hypot(x, y)) < 1e-6, JSON.stringify(first));
|
|
const exported = await call([{ format: "dxf", direction: "export", documentId }]);
|
|
assert.equal(exported.kind, "result", JSON.stringify(exported));
|
|
const saved = exported.result.records[0];
|
|
assert.match(saved.payload, /ENTITIES/);
|
|
const roundTrip = await call([{ ...input, documentId: `${documentId}Restored`, payload: saved.payload }]);
|
|
assert.equal(roundTrip.kind, "result", JSON.stringify(roundTrip));
|
|
assert.ok(Math.abs(length(roundTrip.result.records[0]) - Math.hypot(x, y)) < 1e-6);
|
|
const invalid = await call([{ ...input, payload: "not a DXF document" }]);
|
|
assert.equal(invalid.kind, "error", JSON.stringify(invalid));
|
|
const recovered = await call([{ format: "dxf", direction: "export", documentId }]);
|
|
assert.equal(recovered.kind, "result", JSON.stringify(recovered));
|
|
assert.ok(Math.abs(length(recovered.result.records[0]) - Math.hypot(x, y)) < 1e-6);
|
|
dxfCases.push({ input: { import: input, export: { format: "dxf", direction: "export", documentId },
|
|
roundTripDocument: `${documentId}Restored`, rejectedPayload: "not a DXF document" },
|
|
actual: { imported: first, exported: saved, restored: roundTrip.result.records[0], recovered: recovered.result.records[0] },
|
|
assertions: [
|
|
{ name: "supplied DXF coordinates produce the expected native line length", passed: true },
|
|
{ name: "native DXF export and reimport preserve geometry in another document", passed: true },
|
|
{ name: "invalid import is rejected and preserves existing document geometry", passed: true },
|
|
] });
|
|
}
|
|
} catch (error) { dxfError = String(error.stack ?? error); }
|
|
}
|
|
timings.totalSeconds = (performance.now() - started) / 1000;
|
|
const report = { schema: "freecad-web-business-cases/v1", timings, results: ids.map(capabilityId => {
|
|
if (capabilityId === "Draft:draft.action") return { capabilityId, status: "not_implemented", scope: "persistent-native-create-edit-recompute", cases: actionCases, remaining: ["primary-workspace browser integration", "FCStd save/reopen through OPFS", "browser executable acceptance cases"] };
|
|
if (svgIds.has(capabilityId)) return { capabilityId, status: "passed", scope: "native-svg-format-workflow", cases };
|
|
if (capabilityId === dxfId || capabilityId === "Draft:draft.formatBatch") return {
|
|
capabilityId, status: dxfError ? "failed" : "passed", scope: "native-svg-dxf-document-workflow", error: dxfError,
|
|
resolvedIssueIds: capabilityId === "Draft:draft.formatBatch" && !dxfError ? ["runtime:8fe11605fccfd0b61b22", "runtime:be933a588c91c63f48da"] : [],
|
|
cases: capabilityId === dxfId ? dxfCases : [...cases, ...dxfCases],
|
|
};
|
|
return { capabilityId, status: "not_implemented", cases: [] };
|
|
}) };
|
|
await new Promise((resolve, reject) => process.stdout.write(JSON.stringify(report) + "\n", error => error ? reject(error) : resolve()));
|
|
} catch (error) {
|
|
fs.writeSync(2, String(error.stack ?? error) + "\n");
|
|
if (error.stdout?.length) fs.writeSync(2, error.stdout);
|
|
if (error.stderr?.length) fs.writeSync(2, error.stderr);
|
|
exitCode = 1;
|
|
} finally { fs.rmSync(out, { recursive: true, force: true }); }
|
|
process.exit(exitCode);
|