326 lines
20 KiB
JavaScript
326 lines
20 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 { primitiveCases, runPrimitiveCases } from "./part-primitive-business-cases.mjs";
|
|
import { booleanCases, runBooleanCases, booleanDialogId, runBooleanDialogCases } from "./part-boolean-business-cases.mjs";
|
|
import { writeBusinessReport } from "./business-report.mjs";
|
|
import { fixedBoxIds, runFixedBoxCases } from "./part-fixed-box-business-cases.mjs";
|
|
import { compoundId, runCompoundCases } from "./part-compound-business-cases.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const capabilityId = "Part:part.createRecompute";
|
|
const ioCapabilityId = "Part:part.importExport";
|
|
const saveCapabilityId = "Part:part.saveReopen";
|
|
const documentCapabilityId = "Part:part.document";
|
|
const formats = {
|
|
"business-format:Part:export:.brp": ["export", "BREP", "brp"],
|
|
"business-format:Part:export:.igs": ["export", "IGES", "igs"],
|
|
"business-format:Part:export:.obj": ["export", "OBJ", "obj"],
|
|
"business-format:Part:import:.brp": ["import", "BREP", "brp"],
|
|
"business-format:Part:import:.iges": ["import", "IGES", "iges"],
|
|
"business-format:Part:import:.igs": ["import", "IGES", "igs"],
|
|
"business-format:Part:import:.stp": ["import", "STEP", "stp"],
|
|
};
|
|
const formatCases = new Map();
|
|
const formatErrors = new Map();
|
|
const selectedIds = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId]));
|
|
// The dialog exercises all three operations, including their desktop routes.
|
|
// Share this run when the same batch also requests the individual commands.
|
|
const booleanResults = await runBooleanCases(selectedIds.includes(booleanDialogId)
|
|
? Object.keys(booleanCases) : selectedIds.filter(id => Object.hasOwn(booleanCases, id)));
|
|
const primitiveResults = [...await runPrimitiveCases(selectedIds.filter(id => Object.hasOwn(primitiveCases, id))),
|
|
...booleanResults.filter(result => selectedIds.includes(result.capabilityId)),
|
|
...await runBooleanDialogCases(selectedIds, booleanResults),
|
|
...await runFixedBoxCases(selectedIds.filter(id => fixedBoxIds.includes(id))),
|
|
...await runCompoundCases(selectedIds)];
|
|
const remainingIds = selectedIds.filter(id => id !== booleanDialogId && id !== compoundId && !Object.hasOwn(primitiveCases, id) && !Object.hasOwn(booleanCases, id) && !fixedBoxIds.includes(id));
|
|
const ids = remainingIds.filter(id => id === capabilityId || id === ioCapabilityId
|
|
|| id === saveCapabilityId || id === documentCapabilityId || Object.hasOwn(formats, id));
|
|
// Unsupported business routes must not initialize a kernel or exit successfully.
|
|
primitiveResults.push(...remainingIds.filter(id => !ids.includes(id))
|
|
.map(capabilityId => ({ capabilityId, status: "not_implemented", cases: [] })));
|
|
let exitCode = primitiveResults.some(result => result.status !== "passed") ? 1 : 0;
|
|
if (!ids.length) {
|
|
await writeBusinessReport({ schema: "freecad-web-business-cases/v1", results: primitiveResults });
|
|
process.exit(exitCode);
|
|
}
|
|
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-part-business-"));
|
|
const cases = [];
|
|
const creationCases = [];
|
|
const saveCases = [];
|
|
let saveError;
|
|
function describeError(error) {
|
|
return [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()]
|
|
.filter(Boolean).join("\n");
|
|
}
|
|
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/part-facade.ts"), path.join(root, "web/src/part-io-facade.ts"), path.join(root, "web/src/part-save-facade.ts"),
|
|
], { cwd: root, stdio: "pipe" });
|
|
const facade = await import(pathToFileURL(path.join(out, "part-facade.js")));
|
|
const ioFacade = await import(pathToFileURL(path.join(out, "part-io-facade.js")));
|
|
const saveFacade = await import(pathToFileURL(path.join(out, "part-save-facade.js")));
|
|
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
|
|
const needsGeometryKernel = ids.some(id => id !== saveCapabilityId && id !== documentCapabilityId);
|
|
const { kernel } = needsGeometryKernel ? await loadWasmProbe(process.env.FREECAD_PART_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebPartBoxProbe", {
|
|
printErr: process.env.FREECAD_PART_TRACE === "1" ? message => fs.writeSync(2, `${message}\n`) : undefined,
|
|
}) : { kernel: null };
|
|
if (kernel && process.env.FREECAD_PART_TRACE === "1") {
|
|
const ccall = kernel.ccall.bind(kernel);
|
|
kernel.ccall = (...args) => {
|
|
if (args[0] === "fc_web_dispatch_json") {
|
|
const request = JSON.parse(args[3][0]);
|
|
if (request.cmd === "part.importExport") request.args.trace = true;
|
|
args[3] = [JSON.stringify(request)];
|
|
}
|
|
const started = performance.now();
|
|
fs.writeSync(2, `Part native start: ${JSON.stringify(args[3])}\n`);
|
|
const result = ccall(...args);
|
|
fs.writeSync(2, `Part native completed: ${(performance.now() - started).toFixed(1)} ms\n`);
|
|
return result;
|
|
};
|
|
}
|
|
const queue = new FacadeSerialQueue();
|
|
let sequence = 0;
|
|
const payload = dimensions => ({ ...facade.PART_FIXTURE, dimensions });
|
|
const call = dimensions => facade.dispatchPart(queue, facade.createPartRequest(`part-${++sequence}`, payload(dimensions)), kernel);
|
|
const native = dimensions => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.PART_COMMAND, args: payload(dimensions) })]));
|
|
if (ids.includes(saveCapabilityId) || ids.includes(documentCapabilityId)) {
|
|
try {
|
|
const { kernel: documentKernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebDocumentProbe");
|
|
const documentCall = (document, args) => JSON.parse(documentKernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "part.document", args: { document, ...args } })]));
|
|
for (const [document, dimensions] of [["PartBusinessA", [5, 6, 7]], ["PartBusinessB", [0.5, 2, 3]]]) {
|
|
const savePath = `/tmp/${document}.FCStd`;
|
|
assert.equal(documentCall(document, { action: "create" }).ok, true);
|
|
const first = documentCall(document, { action: "setBox", object: "BoxA", dimensions, includeMesh: true });
|
|
assert.equal(first.ok, true, JSON.stringify(first));
|
|
assert.ok(first.result.objects[0].mesh.triangleCount > 0);
|
|
assert.deepEqual(first.result.objects[0].mesh.bounds, [0, 0, 0, ...dimensions]);
|
|
assert.equal(documentCall(document, { action: "setBox", object: "BoxB", dimensions: [2, 3, 4] }).ok, true);
|
|
let importedVolume = null;
|
|
if (document === "PartBusinessA") {
|
|
const path = `/tmp/${document}.step`;
|
|
documentKernel.FS.writeFile(path, fs.readFileSync(`${root}/web/public/samples/freecad-sample.step`));
|
|
const imported = documentCall(document, { action: "importStep", object: "ImportedStep", label: "Provided STEP", path });
|
|
assert.equal(imported.ok, true, JSON.stringify(imported));
|
|
importedVolume = imported.result.objects.find(object => object.name === "ImportedStep").volume;
|
|
assert.ok(importedVolume > 0);
|
|
documentKernel.FS.unlink(path);
|
|
}
|
|
const expectedObjects = importedVolume === null ? 2 : 3;
|
|
assert.equal(documentCall(document, { action: "setLabel", object: "BoxA", label: `${document} edited label` }).ok, true);
|
|
const input = { ...saveFacade.PART_SAVE_FIXTURE, document, path: savePath };
|
|
const response = await saveFacade.dispatchPartSave(queue, saveFacade.createPartSaveRequest(`save-${++sequence}`, input), documentKernel);
|
|
assert.equal(response.kind, "result", JSON.stringify(response));
|
|
assert.equal(response.result.objects.length, expectedObjects);
|
|
assert.equal(response.result.objects[0].label, `${document} edited label`);
|
|
assert.deepEqual(response.result.objects[0].dimensions, dimensions);
|
|
assert.deepEqual([...new Uint8Array(response.result.bytes).slice(0, 4)], [80, 75, 3, 4]);
|
|
assert.equal(documentCall(document, { action: "setBox", object: "BoxA", dimensions: [9, 8, 7] }).ok, true);
|
|
assert.equal(documentCall(document, { action: "close" }).ok, true);
|
|
const restored = documentCall(document, { action: "open", path: savePath });
|
|
assert.equal(restored.ok, true, JSON.stringify(restored));
|
|
assert.deepEqual(restored.result.objects[0].dimensions, dimensions);
|
|
if (importedVolume !== null) assert.ok(Math.abs(restored.result.objects.find(object => object.name === "ImportedStep").volume - importedVolume) < 1e-5);
|
|
assert.equal(documentCall(document, { action: "setBox", object: "BoxA", dimensions: [-1, 2, 3] }).ok, false);
|
|
const recovered = documentCall(document, { action: "snapshot" });
|
|
assert.deepEqual(recovered.result.objects, restored.result.objects);
|
|
const removed = documentCall(document, { action: "remove", object: "BoxB" });
|
|
assert.equal(removed.ok, true, JSON.stringify(removed));
|
|
assert.equal(removed.result.objectCount, expectedObjects - 1);
|
|
assert.equal(documentCall(document, { action: "close" }).ok, true);
|
|
const missing = documentCall(document, { action: "open", path: "/tmp/absent-document.FCStd" });
|
|
assert.equal(missing.ok, false);
|
|
assert.equal(documentCall(document, { action: "create" }).ok, true);
|
|
assert.equal(documentCall(document, { action: "close" }).ok, true);
|
|
saveCases.push({ input: { document, dimensions, secondBox: [2, 3, 4], path: savePath, label: `${document} edited label`, invalidDimensions: [-1, 2, 3] },
|
|
actual: { restored: restored.result, savedBytes: response.result.size, triangles: first.result.objects[0].mesh.triangleCount, importedVolume }, assertions: [
|
|
{ name: "current native multi-object document and edited labels survive FCStd round trip", passed: true },
|
|
{ name: "native mesh bounds match requested geometry", passed: true },
|
|
{ name: "saved file survives later edits and restores original dimensions", passed: true },
|
|
{ name: "invalid dimensions and missing files preserve recoverable document state", passed: true },
|
|
{ name: "native object removal and document close release their owners", passed: true },
|
|
] });
|
|
documentKernel.FS.unlink(savePath);
|
|
}
|
|
} catch (error) {
|
|
saveError = describeError(error);
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
for (const id of ids.filter(id => formats[id])) {
|
|
const [direction, format, extension] = formats[id];
|
|
const evidence = [];
|
|
formatCases.set(id, evidence);
|
|
const invoke = input => ioFacade.dispatchPartIo(queue, ioFacade.createPartIoRequest(`format-${++sequence}`, input), kernel);
|
|
try {
|
|
for (const dimensions of [[5, 6, 7], [0.5, 2, 3]]) {
|
|
const volume = dimensions.reduce((a, b) => a * b, 1);
|
|
const sourcePath = `/tmp/business-source-${sequence}.${direction === "import" ? extension : "brep"}`;
|
|
const seed = { ...ioFacade.PART_IO_FIXTURE, dimensions, format: direction === "import" ? format : "BREP", path: sourcePath };
|
|
const created = await invoke(seed);
|
|
assert.equal(created.kind, "result", JSON.stringify(created));
|
|
const input = { ...ioFacade.PART_IO_FIXTURE, dimensions: undefined, sourcePath,
|
|
sourceFormat: direction === "import" ? format : "BREP", format: direction === "import" ? "BREP" : format,
|
|
path: `/tmp/business-output-${sequence}.${direction === "import" ? "brep" : extension}` };
|
|
const response = await invoke(input);
|
|
if (response.kind !== "result") {
|
|
evidence.push({ input: { seed, ...input }, actual: response, assertions: [
|
|
{ name: "native file conversion succeeds", passed: false },
|
|
] });
|
|
kernel.FS_unlink(sourcePath);
|
|
}
|
|
assert.equal(response.kind, "result", JSON.stringify(response));
|
|
assert.ok(Math.abs(response.result.beforeVolume - volume) < 1e-6);
|
|
assert.ok(Math.abs(response.result.afterVolume - volume) < 1e-6);
|
|
assert.ok(response.result.bytes > 0);
|
|
if (format === "OBJ") {
|
|
assert.equal(response.result.roundTrip, false);
|
|
assert.equal(response.result.meshReopened, true);
|
|
assert.ok(response.result.meshFacets > 0);
|
|
}
|
|
evidence.push({ input: { seed, ...input }, actual: response.result, assertions: [
|
|
{ name: format === "OBJ" ? "native OBJ mesh preserves supplied box volume" : "native file reader and writer preserve supplied geometry volume", passed: true },
|
|
{ name: format === "OBJ" ? "native OBJ output reopens with facets without claiming BREP round trip" : "native output is nonempty and reopens successfully", passed: true },
|
|
] });
|
|
kernel.FS_unlink(sourcePath);
|
|
kernel.FS_unlink(input.path);
|
|
}
|
|
const input = { ...ioFacade.PART_IO_FIXTURE, dimensions: undefined,
|
|
sourcePath: `/tmp/absent.${direction === "import" ? extension : "brep"}`,
|
|
sourceFormat: direction === "import" ? format : "BREP" };
|
|
const rejected = await invoke(input);
|
|
assert.equal(rejected.kind, "error", JSON.stringify(rejected));
|
|
evidence.push({ input, actual: rejected, assertions: [{ name: "missing input file is rejected", passed: true }] });
|
|
} catch (error) {
|
|
formatErrors.set(id, describeError(error));
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
if (ids.includes(ioCapabilityId)) {
|
|
for (const [dimensions, volume] of [[[5, 6, 7], 210], [[0.5, 2, 3], 3]]) {
|
|
const sourcePath = `/tmp/part-source-${volume}.brep`;
|
|
const sourcePayload = { ...ioFacade.PART_IO_FIXTURE, dimensions, path: sourcePath };
|
|
const source = await ioFacade.dispatchPartIo(queue, ioFacade.createPartIoRequest(`source-${volume}`, sourcePayload), kernel);
|
|
assert.equal(source.kind, "result", JSON.stringify(source));
|
|
assert.ok(Math.abs(source.result.beforeVolume - volume) < 1e-8);
|
|
for (const [format, extension] of [["BREP", "brp"], ["STEP", "stp"]]) {
|
|
const input = { ...ioFacade.PART_IO_FIXTURE, dimensions: undefined, sourcePath, format, path: `/tmp/part-output-${volume}.${extension}` };
|
|
const response = await ioFacade.dispatchPartIo(queue, ioFacade.createPartIoRequest(`convert-${volume}-${format}`, input), kernel);
|
|
assert.equal(response.kind, "result", JSON.stringify(response));
|
|
assert.ok(Math.abs(response.result.beforeVolume - volume) < 1e-8);
|
|
assert.ok(Math.abs(response.result.afterVolume - volume) < 1e-8);
|
|
assert.ok(response.result.bytes > 0);
|
|
cases.push({ input, actual: response.result, assertions: [
|
|
{ name: `supplied BREP shape converts to ${format} with volume ${volume}`, passed: true },
|
|
{ name: "source reused for both formats and output reopened by native reader", passed: true },
|
|
] });
|
|
kernel.FS_unlink(input.path);
|
|
}
|
|
kernel.FS_unlink(sourcePath);
|
|
}
|
|
for (const invalid of [{ dimensions: [0, 2, 3] }, { dimensions: undefined }, { sourcePath: "/tmp/missing-part.brep" }]) {
|
|
const input = { ...ioFacade.PART_IO_FIXTURE, ...invalid, path: "/tmp/invalid-output.brep" };
|
|
const response = await ioFacade.dispatchPartIo(queue, ioFacade.createPartIoRequest(`invalid-${++sequence}`, input), kernel);
|
|
assert.equal(response.kind, "error", JSON.stringify(response));
|
|
cases.push({ input, actual: response, assertions: [{ name: "invalid source rejected", passed: true }] });
|
|
}
|
|
const ioPayload = { ...ioFacade.PART_IO_FIXTURE, path: "/tmp/PartAction02.brep" };
|
|
const ioResponse = await ioFacade.dispatchPartIo(queue, ioFacade.createPartIoRequest("part-io-1", ioPayload), kernel);
|
|
assert.equal(ioResponse.kind, "result", JSON.stringify(ioResponse));
|
|
assert.equal(ioResponse.result.roundTrip, true);
|
|
assert.ok(ioResponse.result.bytes > 0);
|
|
assert.ok(Math.abs(ioResponse.result.beforeVolume - ioResponse.result.afterVolume) < 1e-8);
|
|
cases.push({ input: ioPayload, actual: ioResponse.result, assertions: [
|
|
{ name: "native BREP export/import preserves volume", passed: true },
|
|
{ name: "round-trip artifact has non-zero size", passed: true },
|
|
] });
|
|
kernel.FS_unlink(ioPayload.path);
|
|
}
|
|
if (ids.includes(saveCapabilityId) && !saveError) {
|
|
try {
|
|
const browser = JSON.parse(execFileSync(process.execPath, [path.join(root, "tools/web-business-cases.mjs")], {
|
|
cwd: root, env: { ...process.env, FREECAD_BUSINESS_IDS: JSON.stringify([saveCapabilityId]) }, timeout: 150000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
|
}));
|
|
const result = browser.results?.find(result => result.capabilityId === saveCapabilityId);
|
|
assert.equal(result?.status, "passed", JSON.stringify(result));
|
|
saveCases.push(...result.cases);
|
|
} catch (error) {
|
|
saveError = describeError(error);
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
if (ids.includes(capabilityId)) {
|
|
for (const [dimensions, volume] of [[[2, 3, 4], 24], [[5, 6, 7], 210], [[0.5, 2, 3], 3]]) {
|
|
const response = await call(dimensions);
|
|
assert.equal(response.kind, "result", JSON.stringify(response));
|
|
assert.deepEqual(response.result.dimensions, dimensions);
|
|
assert.ok(Math.abs(response.result.volume - volume) < 1e-8);
|
|
assert.equal(response.result.resourcesReleased, true);
|
|
creationCases.push({ input: payload(dimensions), actual: response.result, assertions: [
|
|
{ name: `native recompute produces volume ${volume} for the requested dimensions`, passed: true },
|
|
{ name: "temporary native document resources released", passed: true },
|
|
] });
|
|
}
|
|
for (const dimensions of [[0, 6, 7], [-1, 6, 7], [NaN, 6, 7], [Infinity, 6, 7]]) {
|
|
assert.equal((await call(dimensions)).kind, "error");
|
|
const rejected = native(dimensions);
|
|
assert.equal(rejected.ok, false);
|
|
creationCases.push({ input: payload(dimensions), actual: rejected, assertions: [
|
|
{ name: "facade and native dispatcher reject invalid dimensions", passed: true },
|
|
] });
|
|
}
|
|
const recovered = await call([5, 6, 7]);
|
|
assert.equal(recovered.kind, "result", JSON.stringify(recovered));
|
|
assert.ok(Math.abs(recovered.result.volume - 210) < 1e-8);
|
|
creationCases.push({ input: payload([5, 6, 7]), actual: recovered.result,
|
|
assertions: [{ name: "valid recompute still works after rejected requests", passed: true }] });
|
|
}
|
|
for (const [id, diagnostic] of formatErrors) {
|
|
fs.writeSync(2, `${id}: ${diagnostic}\n`);
|
|
}
|
|
if (saveError) {
|
|
fs.writeSync(2, `${saveCapabilityId}: ${saveError}\n`);
|
|
}
|
|
await writeBusinessReport({ schema: "freecad-web-business-cases/v1", results: primitiveResults.concat(ids.map(id => formatCases.has(id)
|
|
? { capabilityId: id, status: formatErrors.has(id) ? "failed" : "passed", cases: formatCases.get(id),
|
|
...(formatErrors.has(id) ? { error: formatErrors.get(id) } : {}) }
|
|
: id === capabilityId
|
|
// Superseding business evidence for the corrected configure build root and Node loader.
|
|
// Emit these links only after the native creation, rejection and recovery assertions pass.
|
|
? { capabilityId: id, status: "passed", resolvedIssueIds: [
|
|
"runtime:74f64afb20f2bb816726",
|
|
"runtime:441b272e22a56e4c5c78",
|
|
"runtime:201e0521da22523235ef",
|
|
"runtime:2fdaf656f4e3dc6776d4",
|
|
"runtime:92ca06ea8e3bbf6987ab",
|
|
], resolvedFailureKeys: [
|
|
"34ab3de54cc19a928cb1",
|
|
"5536db4ee9041779201d",
|
|
"a3da0805549d006e7158",
|
|
"7f243b1e37f36c79804e",
|
|
], cases: creationCases }
|
|
: id === ioCapabilityId ? { capabilityId: id, status: "passed", resolvedIssueIds: ["business:part-import-export-roundtrip"], cases }
|
|
: id === saveCapabilityId || id === documentCapabilityId ? { capabilityId: id, status: saveError ? "failed" : "passed", cases: saveCases,
|
|
...(saveError ? { error: saveError } : {}) }
|
|
: { capabilityId: id, status: "not_implemented", cases: [] })) });
|
|
} catch (error) {
|
|
const diagnostic = describeError(error);
|
|
fs.writeSync(2, diagnostic + "\n");
|
|
await writeBusinessReport({ schema: "freecad-web-business-cases/v1", results: primitiveResults.concat(ids.map(id => ({
|
|
capabilityId: id,
|
|
status: id === capabilityId || id === ioCapabilityId || id === saveCapabilityId || id === documentCapabilityId || formats[id] ? "failed" : "not_implemented",
|
|
cases: formatCases.get(id) ?? (id === capabilityId ? creationCases : id === ioCapabilityId ? cases : id === saveCapabilityId || id === documentCapabilityId ? saveCases : []),
|
|
error: diagnostic,
|
|
}))) });
|
|
exitCode = 1;
|
|
} finally {
|
|
fs.rmSync(out, { recursive: true, force: true });
|
|
}
|
|
process.exit(exitCode);
|