Files
FreeCADWasmDev/tools/points-business-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

365 lines
22 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";
import { writeBusinessReport } from "./business-report.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const createId = "Points:points.createRecompute";
const batchId = "Points:points.desktopBatch";
const mergeId = "Points:desktop:Points_Merge";
const structureId = "Points:desktop:Points_Structure";
const saveId = "Points:points.saveReopen";
const objExportId = "business-format:Points:export:.obj";
const plyImportId = "business-format:Points:import:.ply";
const supported = [createId, batchId, mergeId, structureId, saveId, objExportId, plyImportId];
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify(supported));
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-points-business-"));
const results = [];
const timing = [];
let sequence = 0;
let stage = "compile-facade";
let stageStarted = performance.now();
let failure;
const next = name => {
timing.push({ stage, seconds: Number(((performance.now() - stageStarted) / 1000).toFixed(4)) });
stage = name;
stageStarted = performance.now();
};
try {
assert.ok(Array.isArray(ids) && ids.length && new Set(ids).size === ids.length && ids.every(id => supported.includes(id)));
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), ["--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler",
"--strict", "--skipLibCheck", "--outDir", out, path.join(root, "web/src/points-facade.ts"), path.join(root, "web/src/points-python-family-facade.ts"), path.join(root, "web/src/points-persistence-facade.ts")], { stdio: "pipe", timeout: 30000 });
const facade = await import(pathToFileURL(path.join(out, "points-facade.js")));
const desktop = await import(pathToFileURL(path.join(out, "points-python-family-facade.js")));
const persistence = await import(pathToFileURL(path.join(out, "points-persistence-facade.js")));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
next("load-existing-wasm");
const { kernel } = await loadWasmProbe(process.env.FREECAD_POINTS_BUSINESS_BUILD ?? path.join(root, "freecad-source/build-web/configure"), "FreeCADWebPointsRuntimeProbe");
const queue = new FacadeSerialQueue();
const payload = (document, object, points) => ({ ...facade.POINTS_FIXTURE, document, object, points, includePoints: true });
const invoke = input => facade.dispatchPoints(queue, facade.createPointsRequest(`points-${++sequence}`, input), kernel);
const checkPoints = (actual, points) => {
assert.equal(actual.pointCount, points.length);
assert.deepEqual(actual.points, points);
assert.deepEqual(actual.min, [0, 1, 2].map(axis => Math.min(...points.map(point => point[axis]))));
assert.deepEqual(actual.max, [0, 1, 2].map(axis => Math.max(...points.map(point => point[axis]))));
assert.equal(actual.documentRetained, true);
};
const create = async input => {
const response = await invoke(input);
assert.equal(response.kind, "result", JSON.stringify(response));
checkPoints(response.result, input.points);
assert.equal(response.result.document, input.document);
assert.equal(response.result.objectName, input.object);
return response.result;
};
for (const id of ids) {
const cases = [];
const result = { capabilityId: id, status: "failed", cases };
results.push(result);
try {
next(id);
if (id === createId) {
for (const [document, object, points, count] of [
["PointsBusinessA", "First", [[-2, 3, 1], [4, -1, 7]], 1],
["PointsBusinessA", "Second", [[10, 8, 4], [0, 1, 2], [-4, 5, 3], [2, 6, 9]], 2],
["PointsBusinessA", "First", [[-0.5, 8, 2], [2, 6, 9], [9, 4, 3]], 2],
["PointsBusinessB", "First", [[8, -4, 0.5]], 1],
]) {
const input = payload(document, object, points);
const actual = await create(input);
assert.equal(actual.objectCount, count);
cases.push({ input, actual, assertions: [{ name: "native stored coordinates, bounds and ownership follow varying points without duplicate updates", passed: true }] });
}
for (const change of [{ points: [] }, { points: [[1, 2]] }, { points: [["bad", 2, 3]] }, { document: "bad/name" }, { object: "" }]) {
const input = { ...payload("PointsBusinessA", "First", [[1, 2, 3]]), ...change };
const response = await invoke(input);
assert.equal(response.kind, "error");
const actual = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.POINTS_COMMAND, args: input })]));
assert.equal(actual.ok, false, JSON.stringify(actual));
cases.push({ input, actual: { response, native: actual }, assertions: [{ name: "invalid point data and document identifiers are rejected by facade and native entry", passed: true }] });
}
const input = payload("PointsBusinessA", "First", [[-0.5, 8, 2], [2, 6, 9], [9, 4, 3]]);
const actual = await create(input);
assert.equal(actual.objectCount, 2);
cases.push({ input, actual, assertions: [{ name: "current document recovers after invalid requests without extra objects", passed: true }] });
} else if (id === plyImportId) {
const files = new Set();
const importPoints = async args => {
const record = { apiId: "Points:desktop:Points_Import", name: "Points_Import", args };
const response = await desktop.dispatchPointsDesktop(queue,
desktop.createPointsDesktopRequest(`ply-${++sequence}`, [record]), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
assert.equal(response.result.records.length, 1);
return response.result.records[0];
};
const snapshot = async (document, expected, target) => {
files.add(target);
const input = { schema: persistence.POINTS_PERSISTENCE_SCHEMA, module: "Points", action: "save/reopen",
document, path: target };
const response = await persistence.dispatchPointsPersistence(queue,
persistence.createPointsPersistenceRequest(`ply-snapshot-${++sequence}`, input), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.sourceDocument, document);
assert.equal(actual.objectCount, expected.length);
assert.equal(actual.objects.length, expected.length);
const remaining = [...actual.objects];
for (const points of expected) {
const index = remaining.findIndex(object => JSON.stringify(object.points) === JSON.stringify(points));
assert.ok(index >= 0, JSON.stringify({ expected: points, actual }));
const [object] = remaining.splice(index, 1);
assert.equal(object.objectType, "Points::Feature");
checkPoints({ ...object, documentRetained: actual.documentRetained }, points);
}
return actual;
};
let importFailure;
try {
assert.equal(typeof kernel.FS?.writeFile, "function", "WASM filesystem is required to stage user PLY bytes");
for (const offset of [0, 17]) {
let document = `PointsPly${++sequence}`;
const anchor = [[offset + 101, -77, 55]];
await create(payload(document, "Existing", anchor));
const expected = [anchor];
const snapshotPath = `/tmp/${document}.FCStd`;
for (const points of [
[[offset - 2, 3, 0.5], [offset + 4, -1, 7]],
[[offset + 10, 8, -4], [offset, 1, 2], [offset - 4, 5, 3]],
]) {
const target = `/tmp/PointsPlyInput${++sequence}.ply`;
const content = ["ply", "format ascii 1.0", `element vertex ${points.length}`,
"property float x", "property float y", "property float z", "end_header",
...points.map(point => point.join(" ")), ""].join("\n");
files.add(target);
kernel.FS.writeFile(target, content);
const input = { path: target };
const imported = await importPoints(input);
assert.equal(imported.status, "exposed", JSON.stringify(imported));
expected.push(points);
const actual = await snapshot(document, expected, snapshotPath);
cases.push({ input: { ...input, content, document, expectedObjects: expected.slice() },
actual: { imported, restored: actual }, assertions: [{ name: "native PLY import appends supplied coordinates to the active document and preserves existing objects through FCStd reopen", passed: true }] });
document = actual.document;
}
for (const [input, code] of [[{}, "missing_path"],
[{ path: `/tmp/PointsPlyMissing${++sequence}.ply` }, "points_import_failed"]]) {
const rejected = await importPoints(input);
assert.equal(rejected.status, "error", JSON.stringify(rejected));
assert.equal(rejected.code, code);
const actual = await snapshot(document, expected, snapshotPath);
cases.push({ input: { ...input, document }, actual: { rejected, restored: actual },
assertions: [{ name: "missing PLY path or file returns a typed error and leaves document point objects unchanged", passed: true }] });
document = actual.document;
}
const recoveryPoints = [[offset + 0.25, -8, 2], [offset + 9, 6, -3]];
const edited = await create(payload(document, "Existing", recoveryPoints));
assert.equal(edited.objectCount, expected.length);
expected[0] = recoveryPoints;
const actual = await snapshot(document, expected, snapshotPath);
cases.push({ input: { document, object: "Existing", points: recoveryPoints }, actual,
assertions: [{ name: "restored current document remains editable after import errors while retaining imported clouds", passed: true }] });
}
} catch (error) {
importFailure = error;
throw error;
} finally {
const errors = [];
for (const target of files) {
try {
const errno = kernel.ccall("fc_web_points_remove_export", "number", ["string"], [target]);
assert.ok(errno === 0 || errno === 44, `PLY cleanup failed for ${target}: errno ${errno}`);
} catch (error) { errors.push(error); }
}
if (errors.length) throw new AggregateError(importFailure ? [importFailure, ...errors] : errors, "PLY case cleanup failed");
}
} else if (id === objExportId) {
const exportedFiles = new Set();
const readExport = target => {
const result = JSON.parse(kernel.ccall("fc_web_points_read_export", "string", ["string"], [target]));
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(typeof result.content, "string");
return result.content;
};
const exportPoints = async args => {
const record = { apiId: "Points:desktop:Points_Export", name: "Points_Export", args };
const response = await desktop.dispatchPointsDesktop(queue,
desktop.createPointsDesktopRequest(`export-${++sequence}`, [record]), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
assert.equal(response.result.records.length, 1);
return response.result.records[0];
};
const verify = async (document, objectNames, expected, target) => {
const input = { document, objectNames, path: target };
exportedFiles.add(target);
const actual = await exportPoints(input);
assert.equal(actual.status, "exposed", JSON.stringify(actual));
assert.equal(actual.document, document);
assert.equal(actual.path, target);
assert.deepEqual(actual.objectNames, objectNames);
const content = readExport(target);
assert.equal(actual.content, content);
const vertices = content.split(/\r?\n/).map(line => line.trim().split(/\s+/))
.filter(fields => fields[0] === "v").map(fields => {
assert.equal(fields.length, 4);
const point = fields.slice(1).map(Number);
assert.ok(point.every(Number.isFinite));
return point;
});
assert.deepEqual(vertices, expected);
cases.push({ input: { ...input, expectedPoints: expected }, actual: { ...actual, content, vertices },
assertions: [{ name: "WASM OBJ file contains exactly the selected current document coordinates", passed: true }] });
};
let exportFailure;
try {
for (const offset of [0, 17]) {
const document = `PointsObj${++sequence}`;
const first = [[offset - 2, 3, 0.5], [offset + 4, -1, 7]];
const second = [[offset + 10, 8, -4], [offset, 1, 2], [offset - 4, 5, 3]];
await create(payload(document, "First", first));
await create(payload(document, "Second", second));
const target = `/tmp/${document}.obj`;
await verify(document, ["First", "Second"], [...first, ...second], target);
await verify(document, ["Second"], second, target);
const updated = [[offset + 0.25, -8, 2], [offset + 9, 6, -3], [offset - 5, 2, 1]];
const edited = await create(payload(document, "First", updated));
assert.equal(edited.objectCount, 2);
await verify(document, ["First"], updated, target);
for (const [args, code] of [
[{ objectNames: ["Missing"], path: target }, "unknown_object_handle"],
[{ objectNames: [], path: target }, "missing_object_handles"],
[{ objectNames: ["First"] }, "missing_path"],
]) {
const before = readExport(target);
const actual = await exportPoints({ document, ...args });
assert.equal(actual.status, "error", JSON.stringify(actual));
assert.equal(actual.code, code);
assert.equal(readExport(target), before);
cases.push({ input: { document, ...args }, actual,
assertions: [{ name: "invalid selection or missing path is rejected without changing the existing OBJ", passed: true }] });
}
await verify(document, ["First", "Second"], [...updated, ...second], target);
const invalidPathInput = { document, objectNames: ["First"], path: `/tmp/${document}-missing/output.obj` };
const invalidPathResult = await exportPoints(invalidPathInput);
assert.equal(invalidPathResult.status, "error", JSON.stringify(invalidPathResult));
assert.equal(invalidPathResult.code, "points_export_failed");
cases.push({ input: invalidPathInput, actual: invalidPathResult,
assertions: [{ name: "an unwritable destination returns a native export error", passed: true }] });
await create(payload(`${document}Other`, "First", [[99, -77, 55]]));
await verify(document, ["First", "Second"], [...updated, ...second], target);
}
} catch (error) {
exportFailure = error;
throw error;
} finally {
const cleanupErrors = [];
for (const target of exportedFiles) {
try {
const errno = kernel.ccall("fc_web_points_remove_export", "number", ["string"], [target]);
// Emscripten ENOENT is 44; a failed export may not create its target.
assert.ok(errno === 0 || errno === 44, `OBJ cleanup failed for ${target}: errno ${errno}`);
} catch (error) {
cleanupErrors.push(error);
}
}
if (cleanupErrors.length) {
throw new AggregateError(exportFailure === undefined ? cleanupErrors : [exportFailure, ...cleanupErrors],
exportFailure === undefined ? "OBJ cleanup failed" : `OBJ export failed: ${String(exportFailure)}; cleanup also failed`);
}
}
} else if (id === saveId) {
const save = input => persistence.dispatchPointsPersistence(queue,
persistence.createPointsPersistenceRequest(`save-${++sequence}`, input), kernel);
const verify = async (input, sourceDocument, expected) => {
const response = await save(input);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.sourceDocument, sourceDocument);
assert.notEqual(actual.document, sourceDocument);
assert.equal(actual.objectCount, Object.keys(expected).length);
assert.deepEqual(actual.objects.map(object => object.objectName).sort(), Object.keys(expected).sort());
for (const object of actual.objects) {
assert.equal(object.objectType, "Points::Feature");
checkPoints({ ...object, documentRetained: actual.documentRetained }, expected[object.objectName]);
}
assert.equal(actual.resourcesReleased, true);
assert.ok(actual.size > 0);
cases.push({ input: { ...input, expectedObjects: expected }, actual,
assertions: [{ name: "FCStd restore preserves every named point object, coordinates and bounds in a new retained document", passed: true }] });
return actual;
};
for (const offset of [0, 17]) {
const document = `PointsSave${++sequence}`;
const expected = {
First: [[offset - 2, 3, 0.5], [offset + 4, -1, 7]],
Second: [[offset + 10, 8, -4], [offset, 1, 2], [offset - 4, 5, 3]],
};
for (const [object, points] of Object.entries(expected)) await create(payload(document, object, points));
const input = { schema: persistence.POINTS_PERSISTENCE_SCHEMA, module: "Points", action: "save/reopen",
document, path: `/tmp/${document}.FCStd` };
for (const [change, code] of [[{ path: "/tmp/invalid.txt" }, "invalid_path"],
[{ document: "MissingPointsSaveDocument" }, "missing_document"]]) {
const invalid = { ...input, ...change };
const actual = await save(invalid);
assert.equal(actual.kind, "error", JSON.stringify(actual));
assert.equal(actual.error.code, code);
cases.push({ input: invalid, actual, assertions: [{ name: "invalid persistence input returns the expected native error", passed: true }] });
}
const restored = await verify(input, document, expected);
const closed = await save(input);
assert.equal(closed.kind, "error", JSON.stringify(closed));
assert.equal(closed.error.code, "missing_document");
cases.push({ input, actual: closed, assertions: [{ name: "successful reopen closes the original source document", passed: true }] });
const updated = { ...expected, First: [[offset + 0.25, -8, 2], [offset + 9, 6, -3], [offset - 5, 2, 1]] };
const edited = await create(payload(restored.document, "First", updated.First));
assert.equal(edited.objectCount, 2);
const activeInput = { ...input };
delete activeInput.document;
await verify(activeInput, restored.document, updated);
}
} else {
for (const offset of [0, 11]) {
const document = `PointsBatch${++sequence}`;
const first = [[offset + 3, 4, 1], [offset - 2, 0, 5]];
const second = [[offset + 1, -3, 2], [offset + 7, 2, -1]];
await create(payload(document, "First", first));
await create(payload(document, "Second", second));
const commands = id === mergeId ? ["Points_Merge"] : id === structureId ? ["Points_Structure"] : ["Points_Merge", "Points_Structure"];
for (const name of commands) {
const record = { apiId: `Points:desktop:${name}`, name, args: { objectNames: name === "Points_Merge" ? ["First", "Second"] : ["First"], includePoints: true } };
const request = desktop.createPointsDesktopRequest(`desktop-${++sequence}`, [record]);
const response = await desktop.dispatchPointsDesktop(queue, request, kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result.records[0];
assert.equal(actual.status, "exposed", JSON.stringify(actual));
assert.equal(actual.document, document);
const expected = name === "Points_Merge" ? [...first, ...second] : [...first].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
checkPoints(actual, expected);
assert.equal(actual.computedPointCount, expected.length);
cases.push({ input: { document, first, second, record }, actual, assertions: [{ name: "native command transforms the selected current point objects and returns their actual stored coordinates", passed: true }] });
const invalid = { ...record, args: { objectNames: ["Missing"], includePoints: true } };
const rejected = await desktop.dispatchPointsDesktop(queue, desktop.createPointsDesktopRequest(`desktop-${++sequence}`, [invalid]), kernel);
assert.equal(rejected.kind, "result", JSON.stringify(rejected));
assert.equal(rejected.result.records[0].status, "error");
assert.equal(rejected.result.records[0].code, "unknown_object_handle");
cases.push({ input: invalid, actual: rejected.result, assertions: [{ name: "missing selected object is a typed native error", passed: true }] });
}
}
}
result.status = "passed";
} catch (error) { result.error = `${stage}: ${error.stack ?? error}`; failure = result.error; }
}
} catch (error) { failure = `${stage}: ${error.stack ?? error}${error.stderr ? `\n${error.stderr}` : ""}`; }
finally {
next("cleanup");
fs.rmSync(out, { recursive: true, force: true });
next("complete");
}
await writeBusinessReport({ schema: "freecad-web-business-cases/v1", timing,
results: ids.map(capabilityId => results.find(result => result.capabilityId === capabilityId) ?? { capabilityId, status: "failed", cases: [], error: failure }) });
process.exit(failure ? 1 : 0);