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

226 lines
11 KiB
JavaScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const capabilityId = "Assembly:assembly.createRecompute";
const persistenceId = "Assembly:assembly.saveReopen";
let cases = [];
let activeId = capabilityId;
const results = [];
const nativeDiagnostics = [];
let kernel;
let exitCode = 0;
function runCase(args, check, cmd = "assembly.createRecompute") {
const input = { cmd, args };
const entry = { input, assertions: [] };
cases.push(entry);
try {
entry.actual = JSON.parse(String(kernel.ccall(
"fc_web_dispatch_json", "string", ["string"], [JSON.stringify(input)],
)));
entry.assertions = check(entry.actual).map(([name, passed]) => ({ name, passed: Boolean(passed) }));
} catch (error) {
entry.error = String(error.stack ?? error);
entry.assertions.push({ name: "native request returns valid JSON", passed: false });
}
return entry.actual;
}
function successful(args, assemblyCount) {
// Assembly inherits App::Part's Origin tree. countObjects() includes those
// native support objects: the current empty assembly occupies nine objects.
const objectCount = assemblyCount * 9;
runCase(args, actual => [
["native Assembly create/recompute succeeds", actual.ok === true
&& actual.source === "freecad-source" && actual.module === "Assembly"
&& actual.action === "create/recompute"
&& actual.schema === "freecad-web-assembly/create-recompute/v1"],
["requested document retains the expected object count", actual.document?.name === args.documentName
&& actual.document?.objectCount === objectCount],
["requested object is a native empty assembly", actual.object?.name === (args.objectName ?? "Assembly")
&& actual.object?.type === "Assembly::AssemblyObject" && actual.object?.componentCount === 0],
["native document recompute completes without error", Number.isInteger(actual.recompute?.count)
&& actual.recompute.count >= 0 && actual.recompute.hasError === 0
&& Number.isInteger(actual.recompute.solverStatus)],
]);
}
try {
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId, persistenceId]));
if (!Array.isArray(ids) || !ids.every(id => typeof id === "string")) {
throw new Error("FREECAD_BUSINESS_IDS must be a JSON array of capability IDs");
}
for (const id of new Set(ids)) {
if (![capabilityId, persistenceId].includes(id)) {
results.push({ capabilityId: id, status: "not_implemented", cases: [] });
exitCode = 1;
}
}
if (ids.some(id => [capabilityId, persistenceId].includes(id))) {
activeId = ids.includes(capabilityId) ? capabilityId : persistenceId;
({ kernel } = await loadWasmProbe(
path.join(root, "web/public/kernel"), "FreeCADWebAssemblyProbe",
{ printErr: text => nativeDiagnostics.push(String(text)) },
));
}
if (ids.includes(capabilityId)) {
const result = { capabilityId, status: "failed", cases };
results.push(result);
const first = {
schema: "freecad-web-assembly/create-recompute/v1",
documentName: "AssemblyBusinessPrimary", objectName: "DriveAssembly",
objectType: "Assembly::AssemblyObject", label: "Drive assembly",
};
successful(first, 1);
successful({ ...first, label: "Drive assembly revised" }, 1);
successful({ ...first, objectName: "SupportAssembly", label: "Support assembly" }, 2);
const second = { ...first, documentName: "AssemblyBusinessSecondary", label: "Independent drive" };
successful(second, 1);
successful(first, 2);
for (const args of [
{ ...first, objectName: "RejectedType", objectType: "App::FeaturePython" },
{ ...first, objectName: "RejectedLabel", label: 42 },
{ ...first, objectName: "" },
{ ...first, documentName: "" },
{ ...first, objectName: 42 },
{ ...first, objectName: null },
{ ...first, objectName: " " },
{ ...first, documentName: " " },
{ ...first, objectName: "DriveAssembly\u0000Suffix" },
{ ...first, documentName: "AssemblyBusinessPrimary\u0000Suffix" },
]) {
runCase(args, actual => [
["native input validation rejects invalid creation", actual.ok === false
&& actual.code === "assembly_create_failed" && typeof actual.error === "string" && actual.error.length > 0],
]);
successful(first, 2);
}
successful(second, 1);
const defaultName = { ...first, documentName: "AssemblyBusinessDefault", label: "Default object name" };
delete defaultName.objectName;
successful(defaultName, 1);
successful(defaultName, 1);
const passed = cases.length > 0 && cases.every(entry => entry.assertions.length > 0
&& entry.assertions.every(assertion => assertion.passed));
result.status = passed ? "passed" : "failed";
if (passed) result.resolvedFailureKeys = ["161f02f2235e99467738"];
if (!passed) exitCode = 1;
}
if (ids.includes(persistenceId)) {
activeId = persistenceId;
cases = [];
const result = { capabilityId: persistenceId, status: "failed", cases };
results.push(result);
const primary = {
schema: "freecad-web-assembly/create-recompute/v1",
documentName: "AssemblySavePrimary", objectName: "Drive",
objectType: "Assembly::AssemblyObject", label: "Drive before revision",
};
const support = { ...primary, objectName: "Support", label: "Support frame" };
const secondary = { ...primary, documentName: "AssemblySaveSecondary", label: "Independent drive" };
const save = (documentName, filename, expected) => runCase({
schema: "freecad-web-assembly/save-reopen/v1", module: "Assembly", documentName, path: filename,
}, actual => {
const assemblies = actual.objects?.filter(item => item.type === "Assembly::AssemblyObject");
return [
["native current-document save and restore succeeds", actual.ok === true
&& actual.schema === "freecad-web-assembly/save-reopen/v1"
&& actual.source === "freecad-source" && actual.module === "Assembly"
&& actual.format === ".fcstd" && actual.roundTrip === true],
["requested document and output file are used", actual.documentName === documentName
&& actual.path === filename && Number.isInteger(actual.size) && actual.size > 0],
["restore creates a separate document", typeof actual.reopenedDocumentName === "string"
&& actual.reopenedDocumentName.length > 0 && actual.reopenedDocumentName !== documentName],
["restored tree contains all current assemblies and support objects", actual.objectCount === expected.length * 9
&& actual.objects?.length === actual.objectCount && assemblies?.length === expected.length
&& expected.every(item => assemblies?.some(restored => restored.name === item.objectName
&& restored.label === item.label && typeof restored.typeProperty === "string"))],
["restored document recomputes", Number.isInteger(actual.recomputeCount) && actual.recomputeCount >= 0],
];
}, "assembly.saveReopen");
successful(primary, 1);
successful(support, 2);
successful(secondary, 1);
const initial = save(primary.documentName, "/tmp/AssemblyBusinessPrimary.FCStd", [primary, support]);
save(secondary.documentName, "/tmp/AssemblyBusinessSecondary.fcstd", [secondary]);
const revised = { ...primary, label: "Drive revised for save" };
successful(revised, 2);
save(primary.documentName, "/tmp/AssemblyBusinessPrimary.FCStd", [revised, support]);
// Saving the restored document checks that it retains its original state
// independently of subsequent changes to the source document.
if (initial?.ok === true && typeof initial.reopenedDocumentName === "string") {
save(initial.reopenedDocumentName, "/tmp/AssemblyBusinessRestored.FCStd", [primary, support]);
}
for (const args of [
{ documentName: primary.documentName, path: "/tmp/AssemblyBusinessInvalid.txt" },
{ documentName: primary.documentName, path: "/outside.FCStd" },
{ documentName: primary.documentName, path: "/tmp/../outside.FCStd" },
{ documentName: primary.documentName, path: "/tmp/bad\u0000.FCStd" },
{ documentName: "AssemblySaveMissing", path: "/tmp/AssemblyBusinessMissing.FCStd" },
]) {
runCase(args, actual => [
["invalid persistence request is rejected", actual.ok === false
&& actual.code === "assembly_persistence_failed"
&& typeof actual.error === "string" && actual.error.length > 0],
], "assembly.saveReopen");
}
save(primary.documentName, "/tmp/AssemblyBusinessRecovered.FCStd", [revised, support]);
save(secondary.documentName, "/tmp/AssemblyBusinessIndependent.FCStd", [secondary]);
const passed = cases.length > 0 && cases.every(entry => entry.assertions.length > 0
&& entry.assertions.every(assertion => assertion.passed));
result.status = passed ? "passed" : "failed";
if (!passed) exitCode = 1;
}
} catch (error) {
exitCode = 1;
let result = results.find(item => item.capabilityId === activeId);
if (!result) {
result = { capabilityId: activeId, status: "failed", cases };
results.push(result);
}
result.status = "failed";
result.error = String(error.stack ?? error);
} finally {
try {
kernel?.PThread?.terminateAllThreads?.();
} catch (error) {
exitCode = 1;
for (const result of results) {
result.status = "failed";
delete result.resolvedFailureKeys;
result.cleanupError = String(error);
}
}
for (const result of results) {
if (result.status === "failed") {
result.nativeDiagnostics = nativeDiagnostics;
const firstFailure = result.cases.find(entry => entry.error
|| entry.assertions.some(assertion => !assertion.passed));
if (firstFailure) result.firstFailure = {
input: firstFailure.input,
actual: firstFailure.actual,
error: firstFailure.error,
assertions: firstFailure.assertions.filter(assertion => !assertion.passed),
};
// The outer runner retains stderr when a business command exits unsuccessfully.
await new Promise((resolve, reject) => process.stderr.write(
JSON.stringify({
capabilityId: result.capabilityId,
error: result.error,
cleanupError: result.cleanupError,
nativeDiagnostics: nativeDiagnostics.slice(-5),
firstFailure: result.firstFailure,
}) + "\n",
error => error ? reject(error) : resolve(),
));
}
}
await new Promise((resolve, reject) => process.stdout.write(
JSON.stringify({ schema: "freecad-web-business-cases/v1", results }) + "\n",
error => error ? reject(error) : resolve(),
));
}
process.exit(exitCode);