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

158 lines
10 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 supported = ["Spreadsheet:spreadsheet.importExportCsv", "business-format:Spreadsheet:import:.csv", "Spreadsheet:spreadsheet.createRecompute", "Spreadsheet:spreadsheet.saveReopen"];
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify(supported));
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-spreadsheet-business-"));
const cases = [];
const timing = [];
let stage = "compile-facade";
let started = performance.now();
let error;
const next = name => {
timing.push({ stage, seconds: Number(((performance.now() - started) / 1000).toFixed(4)) });
stage = name;
started = performance.now();
};
const parseCsv = (text, delimiter) => JSON.parse(execFileSync("python3", ["-c",
"import csv,io,json,sys; v=json.load(sys.stdin); print(json.dumps(list(csv.reader(io.StringIO(v['text']),delimiter=v['delimiter'],escapechar='\\\\'))))"],
{ input: JSON.stringify({ text, delimiter }), encoding: "utf8", timeout: 5000 }));
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", directory, path.join(root, "web/src/spreadsheet-facade.ts"), path.join(root, "web/src/spreadsheet-save-facade.ts")], { cwd: root, stdio: "pipe", timeout: 30000 });
const facade = await import(pathToFileURL(path.join(directory, "spreadsheet-facade.js")));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(directory, "facade-envelope.js")));
next("load-existing-wasm");
const { kernel } = await loadWasmProbe(process.env.FREECAD_SPREADSHEET_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebSpreadsheetRuntimeProbe");
next("native-csv-roundtrips");
const queue = new FacadeSerialQueue();
let sequence = 0;
if (ids.includes("Spreadsheet:spreadsheet.saveReopen")) {
next("native-save-reopen");
const save = await import(pathToFileURL(path.join(directory, "spreadsheet-save-facade.js")));
const persist = input => save.dispatchSpreadsheetSave(queue, save.createSpreadsheetSaveRequest(`save-${++sequence}`, input), kernel);
for (const sample of [
{ a1: "13", b1: "=A1*3" },
{ a1: "7.5", b1: "=A1+2" },
{ a1: "'Panel A", b1: "'left right" },
]) {
const input = { ...save.SPREADSHEET_SAVE_FIXTURE, path: `/tmp/SpreadsheetBusiness${sequence}.FCStd`, ...sample };
const response = await persist(input);
assert.equal(response.kind, "result", JSON.stringify(response));
const { bytes, ...actual } = response.result;
assert.equal(actual.cellA1, input.a1);
assert.equal(input.b1.startsWith("=") ? actual.cellB1.replace(/\s/g, "") : actual.cellB1, input.b1);
assert.equal(actual.path, input.path);
assert.equal(bytes.byteLength, actual.size);
assert.deepEqual([...new Uint8Array(bytes).slice(0, 4)], [80, 75, 3, 4]);
const artifact = path.join(directory, path.basename(input.path));
fs.writeFileSync(artifact, new Uint8Array(bytes));
const archive = JSON.parse(execFileSync("python3", ["-c", "import json,sys,zipfile,xml.etree.ElementTree as E; z=zipfile.ZipFile(sys.argv[1]); r=E.fromstring(z.read('Document.xml')); print(json.dumps([e.attrib for e in r.iter('Cell')]))", artifact], { encoding: "utf8", timeout: 5000 }));
assert.ok(archive.some(cell => cell.address === "A1" && cell.content === input.a1), JSON.stringify(archive));
kernel.FS.unlink(input.path);
assert.equal(kernel.FS.analyzePath(input.path).exists, false);
cases.push({ input, actual, assertions: [{ name: "variable cells and whitespace survive native FCStd restore and exported ZIP contains supplied A1", passed: true }] });
}
kernel.FS.writeFile("/tmp/SpreadsheetBlockedParent", "not a directory");
for (const invalid of [{ path: "/tmp/SpreadsheetBlockedParent/book.FCStd" }, { path: "/tmp/../bad.FCStd" }, { a1: "" }, { b1: "=A1+" }]) {
const input = { ...save.SPREADSHEET_SAVE_FIXTURE, a1: "9", b1: "=A1*4", ...invalid };
const response = await persist(input);
assert.equal(response.kind, "error", JSON.stringify(response));
const native = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: save.SPREADSHEET_SAVE_COMMAND, args: input })]));
assert.equal(native.ok, false, JSON.stringify(native));
assert.equal(native.resourcesReleased, true);
cases.push({ input, actual: { response, native }, assertions: [{ name: "invalid path or cells fail and release temporary documents", passed: true }] });
}
kernel.FS.unlink("/tmp/SpreadsheetBlockedParent");
const recoveryInput = { ...save.SPREADSHEET_SAVE_FIXTURE, path: "/tmp/SpreadsheetRecovered.FCStd", a1: "19", b1: "=A1*5" };
const recovery = await persist(recoveryInput);
assert.equal(recovery.kind, "result", JSON.stringify(recovery));
assert.equal(recovery.result.cellA1, "19");
assert.equal(recovery.result.cellB1.replace(/\s/g, ""), "=A1*5");
kernel.FS.unlink(recoveryInput.path);
const { bytes, ...actual } = recovery.result;
cases.push({ input: recoveryInput, actual, assertions: [{ name: "same native instance saves and restores new input after failure", passed: true }] });
}
if (ids.includes("Spreadsheet:spreadsheet.createRecompute")) {
next("native-create-recompute");
const create = await queue.dispatch(facade.createSpreadsheetCreateRequest(`create-${++sequence}`, { ...facade.SPREADSHEET_CREATE_FIXTURE, a1: "21", b1: "=A1*2" }),
(payload, signal) => facade.handleSpreadsheetCreate(payload, signal, kernel));
assert.equal(create.kind, "result", JSON.stringify(create));
assert.equal(create.result.objectType, "Spreadsheet::Sheet");
assert.equal(create.result.objectCount, 1);
assert.equal(create.result.recomputeCount, 1);
assert.equal(create.result.cellA1, "21");
assert.equal(create.result.cellB1, "=A1*2");
assert.equal(create.result.recomputed, true);
assert.equal(create.result.resourcesReleased, true);
cases.push({ input: facade.SPREADSHEET_CREATE_FIXTURE, actual: create.result,
assertions: [{ name: "native Sheet creation and recompute honor supplied cell values", passed: true }] });
}
if (ids.some(id => id.includes("importExportCsv") || id === "business-format:Spreadsheet:import:.csv")) {
next("native-csv-roundtrips");
const call = input => queue.dispatch(facade.createSpreadsheetCsvRequest(`csv-${++sequence}`, input),
(payload, signal) => facade.handleSpreadsheetCsv(payload, signal, kernel));
const samples = [
{ csv: "Name,Quantity,Price\nBracket,2,12.5\nPlate,7,3.25\n", delimiter: ",", usedRange: "A1:C3" },
{ csv: 'Name;Description\nPlate;"left;right"\nAngle;trimmed\n', delimiter: ";", usedRange: "A1:B3" },
{ csv: "Part\tQuantity\nPanel\t4\n", delimiter: "\t", usedRange: "A1:B2" },
];
for (const sample of samples) {
const input = { ...facade.SPREADSHEET_CSV_FIXTURE, csv: sample.csv, delimiter: sample.delimiter };
const response = await call(input);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.objectType, "Spreadsheet::Sheet");
assert.equal(actual.usedRange, sample.usedRange);
assert.equal(actual.importBytes, Buffer.byteLength(input.csv));
assert.equal(actual.exportBytes, Buffer.byteLength(actual.csv));
assert.equal(actual.recomputed, true);
assert.equal(actual.resourcesReleased, true);
assert.deepEqual(parseCsv(actual.csv, sample.delimiter), parseCsv(sample.csv, sample.delimiter));
const repeated = await call({ ...input, csv: actual.csv });
assert.equal(repeated.kind, "result", JSON.stringify(repeated));
assert.deepEqual(parseCsv(repeated.result.csv, sample.delimiter), parseCsv(sample.csv, sample.delimiter));
cases.push({ input, actual, assertions: [
{ name: "provided CSV cells survive native Sheet import, recompute, export and reimport", passed: true },
{ name: "native used range and artifact byte lengths match the supplied rows", passed: true },
{ name: "temporary documents and CSV files are released", passed: !kernel.FS.analyzePath("/tmp/FreeCADWebSpreadsheetImport.csv").exists
&& !kernel.FS.analyzePath("/tmp/FreeCADWebSpreadsheetExport.csv").exists },
] });
}
next("invalid-input-and-recovery");
for (const invalid of [{ csv: "" }, { delimiter: "::" }, { quote: "" }, { escape: "" }]) {
const input = { ...facade.SPREADSHEET_CSV_FIXTURE, ...invalid };
const response = await call(input);
assert.equal(response.kind, "error", JSON.stringify(response));
const native = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.SPREADSHEET_CSV_COMMAND, args: input })]));
assert.equal(native.ok, false, JSON.stringify(native));
assert.equal(native.resourcesReleased, true);
cases.push({ input, actual: { response, native }, assertions: [{ name: "Facade and native CSV entry reject invalid parameters without leaked documents", passed: true }] });
}
const recovered = await call({ ...facade.SPREADSHEET_CSV_FIXTURE, csv: "Status,Count\nRecovered,9\n" });
assert.equal(recovered.kind, "result", JSON.stringify(recovered));
assert.deepEqual(parseCsv(recovered.result.csv, ","), [["Status", "Count"], ["Recovered", "9"]]);
cases.push({ input: { csv: "Status,Count\nRecovered,9\n" }, actual: recovered.result,
assertions: [{ name: "valid input works after rejected requests in the same native instance", passed: true }] });
}
assert.ok(cases.length && cases.every(entry => entry.assertions.every(check => check.passed)));
} catch (failure) {
error = `${stage}: ${failure.stack ?? failure}${failure.stderr ? `\n${failure.stderr}` : ""}`;
} finally {
next("cleanup");
fs.rmSync(directory, { recursive: true, force: true });
next("complete");
}
await writeBusinessReport({ schema: "freecad-web-business-cases/v1", timing, results: ids.map(capabilityId => ({
capabilityId, status: error ? "failed" : "passed", cases, ...(error ? { error } : {}),
})) });
process.exit(error ? 1 : 0);