Files
FreeCADWasmDev/tools/import-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

195 lines
14 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 { createHash } from "node:crypto";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-import-business-"));
const id = "Import:import.saveReopen";
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([id]));
const stepFormatIds = ["business-format:Import:import:.step", "business-format:Import:import:.stp"];
const cases = [];
let exitCode = 0;
try {
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/import-facade.ts")], { cwd: root });
const facade = await import(pathToFileURL(path.join(out, "import-facade.js")));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
const { kernel } = await loadWasmProbe(process.env.FREECAD_IMPORT_BUSINESS_BUILD ?? path.join(root,"freecad-source/build-web/configure/bin"), "FreeCADWebImportRuntimeProbe");
let partKernel;
const getPartKernel = async () => partKernel ??= (await loadWasmProbe(path.join(root, "web/public/kernel"), "FreeCADWebPartBoxProbe")).kernel;
const fixture = path.join(root, "web/public/samples/freecad-sample.step");
try { kernel.FS_createPath("/", "fixtures", true, true); } catch {}
try { kernel.FS_unlink("/fixtures/freecad-sample.step"); } catch {}
try { kernel.FS_createDataFile("/fixtures", "freecad-sample.step", fs.readFileSync(fixture), true, true); } catch (error) {
if (error?.code !== "EEXIST") throw error;
}
if (ids.includes("business-format:Import:import:.igs")) {
const part = await getPartKernel();
const queue = new FacadeSerialQueue();
for (const [name, bytes, code] of [["missing.igs", null, "file_not_found"], ["invalid.iges", new TextEncoder().encode("invalid IGES input"), "import_failed"]]) {
const payload = { ...facade.IMPORT_FIXTURE, fixturePath: `/tmp/${name}` };
if (bytes) kernel.FS_createDataFile("/tmp", name, bytes, true, true);
const response = await facade.dispatchImportStep(queue, facade.createImportStepRequest(name, payload), kernel);
assert.equal(response.kind, "error", JSON.stringify(response));
assert.equal(response.error.code, code);
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId: "business-format:Import:import:.igs", input: payload, actual: response, assertions: [{ name: "invalid or missing IGES rejected without live handles", passed: response.error.code === code }] });
if (bytes) kernel.FS_unlink(payload.fixturePath);
}
for (const [extension, dimensions] of [["igs", [2, 3, 4]], ["IGES", [5, 6, 7]]]) {
const igesPath = `/tmp/import-business.${extension}`;
const generated = JSON.parse(part.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "part.importExport", args: { format: "IGES", path: igesPath, dimensions } })]));
assert.equal(generated.ok, true, JSON.stringify(generated));
const bytes = part.FS_readFile(igesPath); kernel.FS_createDataFile("/tmp", path.basename(igesPath), bytes, true, true);
const payload = { ...facade.IMPORT_FIXTURE, fixturePath: igesPath, fixtureSha256: createHash("sha256").update(bytes).digest("hex") };
const response = await facade.dispatchImportStep(queue, facade.createImportStepRequest(extension, payload), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
assert.ok(["ocaf", "shape-fallback"].includes(response.result.importMode), "IGES reader mode must be disclosed");
const matches = response.result.summary.parts > 0 && Math.abs(response.result.summary.volume - dimensions.reduce((a, b) => a * b, 1)) < 1e-6;
assert.ok(matches, JSON.stringify(response));
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId: "business-format:Import:import:.igs", input: { ...payload, dimensions }, actual: response.result, assertions: [{ name: "IGES geometry matches input volume after failed input", passed: matches }] });
part.FS_unlink(igesPath);
kernel.FS_unlink(igesPath);
}
}
for (const capabilityId of ["Import:import.step", "Import:import.createRecompute"]) if (ids.includes(capabilityId)) {
const request = capabilityId.endsWith("step") ? facade.createImportStepRequest("step", facade.IMPORT_FIXTURE) : facade.createImportCreateRecomputeRequest("recompute", facade.IMPORT_CREATE_RECOMPUTE_FIXTURE);
const response = capabilityId.endsWith("step") ? await facade.dispatchImportStep(new FacadeSerialQueue(), request, kernel) : await facade.dispatchImportCreateRecompute(new FacadeSerialQueue(), request, kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const result = response.result;
if (capabilityId === "Import:import.step") {
assert.ok(result.summary.solids > 0 && result.summary.volume > 0);
assert.equal(result.released, true);
assert.equal(result.activeAfterRelease, 0);
} else {
assert.ok(result.object.solids > 0 && result.volume > 0);
assert.equal(result.object.type, "Part::Feature");
assert.equal(result.recompute.hasError, 0);
assert.equal(result.document.activeAfterClose, 0);
}
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId, input: request.payload, actual: result, assertions: [{ name: "STEP import produces native geometry and releases the document", passed: true }] });
}
const inputs = [{ payload: { ...facade.IMPORT_SAVE_REOPEN_FIXTURE } }];
if (ids.some(value => [id, "Import:import.step", "Import:import.createRecompute", ...stepFormatIds].includes(value))) {
const part = await getPartKernel();
for (const dimensions of [[5, 6, 7], [0.5, 2, 3]]) {
const fixturePath = `/tmp/import-${dimensions.join("-")}.step`;
const generated = JSON.parse(part.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({
cmd: "part.importExport", args: { format: "STEP", path: fixturePath, dimensions },
})]));
assert.equal(generated.ok, true, JSON.stringify(generated));
const bytes = part.FS_readFile(fixturePath);
kernel.FS_createDataFile("/tmp", path.basename(fixturePath), bytes, true, true);
part.FS_unlink(fixturePath);
inputs.push({ bytes, dimensions, volume: dimensions.reduce((a, b) => a * b, 1), payload: {
...facade.IMPORT_SAVE_REOPEN_FIXTURE, fixturePath,
fixtureSha256: createHash("sha256").update(bytes).digest("hex"),
} });
}
for (const capabilityId of stepFormatIds.filter(value => ids.includes(value))) {
const extension = capabilityId.split(":").at(-1);
const queue = new FacadeSerialQueue();
for (const [name, bytes, code] of [
[`missing-format${extension}`, null, "file_not_found"],
[`invalid-format${extension}`, new TextEncoder().encode("invalid STEP input"), "import_failed"],
]) {
const payload = { ...facade.IMPORT_FIXTURE, fixturePath: `/tmp/${name}` };
if (bytes) kernel.FS_createDataFile("/tmp", name, bytes, true, true);
try {
const response = await facade.dispatchImportStep(queue, facade.createImportStepRequest(name, payload), kernel);
const activeHandles = kernel.ccall("fc_web_import_active_handles", "number");
const assertions = [{ name: "missing or corrupt STEP rejected without live handles", passed: response.kind === "error" && response.error.code === code && activeHandles === 0 }];
cases.push({ capabilityId, input: { ...payload, contents: bytes ? new TextDecoder().decode(bytes) : null }, actual: { response, activeHandles }, assertions });
assert.ok(assertions.every(a => a.passed), JSON.stringify(response));
} finally {
if (bytes) kernel.FS_unlink(payload.fixturePath);
}
}
for (const { payload: input, bytes, volume, dimensions } of inputs.slice(1)) {
const fixturePath = `/tmp/format-${dimensions.join("-")}${extension}`;
kernel.FS_createDataFile("/tmp", path.basename(fixturePath), bytes, true, true);
try {
const payload = { ...facade.IMPORT_FIXTURE, fixturePath, fixtureSha256: input.fixtureSha256 };
const response = await facade.dispatchImportStep(queue, facade.createImportStepRequest(fixturePath, payload), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const result = response.result;
const activeHandles = kernel.ccall("fc_web_import_active_handles", "number");
const assertions = [
{ name: "imported solid volume matches supplied dimensions after rejected input", passed: result.summary.parts > 0 && result.summary.solids === 1 && Math.abs(result.summary.volume - volume) < 1e-6 },
{ name: "import document handle released", passed: result.released === true && result.activeAfterRelease === 0 && activeHandles === 0 },
];
cases.push({ capabilityId, input: { ...payload, dimensions }, actual: { ...result, activeHandles }, assertions });
assert.ok(assertions.every(a => a.passed), JSON.stringify(result));
} finally {
kernel.FS_unlink(fixturePath);
}
}
}
for (const capabilityId of ["Import:import.step", "Import:import.createRecompute"]) if (ids.includes(capabilityId)) {
const step = capabilityId === "Import:import.step";
const create = step ? facade.createImportStepRequest : facade.createImportCreateRecomputeRequest;
const dispatch = step ? facade.dispatchImportStep : facade.dispatchImportCreateRecompute;
const base = step ? facade.IMPORT_FIXTURE : facade.IMPORT_CREATE_RECOMPUTE_FIXTURE;
const queue = new FacadeSerialQueue();
const missing = { ...base, fixturePath: "/tmp/missing-import.step" };
const rejected = await dispatch(queue, create("missing", missing), kernel);
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
assert.equal(rejected.error.code, "file_not_found");
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId, input: missing, actual: rejected, assertions: [{ name: "missing STEP rejected without a live handle", passed: true }] });
for (const { payload: input, volume, dimensions } of inputs.slice(1)) {
const payload = { ...base, fixturePath: input.fixturePath, fixtureSha256: input.fixtureSha256 };
const request = create(`varying-${dimensions.join("-")}`, payload);
assert.equal(step ? facade.isImportStepRequest(request) : facade.isImportCreateRecomputeRequest(request), true);
const response = await dispatch(queue, request, kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const result = response.result;
assert.ok(Math.abs((step ? result.summary.volume : result.volume) - volume) < 1e-6, JSON.stringify(result));
assert.equal(step ? result.summary.solids : result.object.solids, 1);
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId, input: { ...payload, dimensions }, actual: result, assertions: [{ name: "varying native STEP geometry recomputes and releases after failed input", passed: true }] });
}
}
}
if (ids.includes(id)) {
const missing = { ...facade.IMPORT_SAVE_REOPEN_FIXTURE, fixturePath: "/tmp/missing-import.step" };
const rejected = await facade.dispatchImportSaveReopen(new FacadeSerialQueue(), facade.createImportSaveReopenRequest("missing", missing), kernel);
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
assert.equal(rejected.error.code, "file_not_found");
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId: id, input: missing, actual: rejected, assertions: [{ name: "missing STEP is rejected without a live document handle", passed: true }] });
}
if (ids.includes(id)) for (const { payload, volume, dimensions } of inputs) {
assert.equal(facade.isImportSaveReopenRequest(facade.createImportSaveReopenRequest("route", payload)), true);
const response = await facade.dispatchImportSaveReopen(new FacadeSerialQueue(), facade.createImportSaveReopenRequest(`import-${Date.now()}`, payload), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const r = response.result;
assert.ok(r.before.solids > 0 && r.before.volume > 0);
assert.equal(r.before.parts, r.reopened.parts);
assert.ok(Math.abs(r.before.volume - r.reopened.volume) < 1e-6);
if (volume !== undefined) assert.ok(Math.abs(r.reopened.volume - volume) < 1e-6, JSON.stringify(r));
assert.equal(kernel.ccall("fc_web_import_active_handles", "number"), 0);
cases.push({ capabilityId: id, input: { ...payload, dimensions }, actual: r, assertions: [{ name: "native FCStd save and restored geometry match supplied STEP, with handles released", passed: r.persisted.size > 0 && r.before.objects === r.reopened.objects && r.before.solids === r.reopened.solids && r.error.code === "" }] });
assert.ok(cases.at(-1).assertions[0].passed, JSON.stringify(r));
assert.notEqual(r.persisted.path, payload.fixturePath, "persisted document must not overwrite the STEP input");
kernel.FS_unlink(r.persisted.path);
}
fs.writeSync(1, JSON.stringify({ schema:"freecad-web-business-cases/v1", results: ids.map(capabilityId => {
const selectedCases = cases.filter(c => c.capabilityId === capabilityId);
return { capabilityId, status: selectedCases.length > 0 && selectedCases.every(c => c.assertions.every(a => a.passed)) ? "passed" : "not_implemented", cases: selectedCases };
}) }) + "\n");
} catch (error) {
fs.writeSync(2, String(error.stack ?? error) + "\n");
exitCode = 1;
} finally { fs.rmSync(out, { recursive:true, force:true }); }
// The Emscripten runtime may retain pthread workers after the business result is
// emitted; this is a one-shot acceptance probe, so terminate once evidence is
// fully written and temporary files are cleaned up.
process.exit(exitCode);