Files
FreeCADWasmDev/tools/part-new-document-business-cases.mjs

163 lines
13 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 { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const newDocumentId = 'Part:desktop:Part_NewDoc';
export async function runNewDocumentCases(ids) {
if (!ids.includes(newDocumentId)) return [];
const result = { capabilityId: newDocumentId, status: 'failed', cases: [], timing: [], evidenceFiles: {} };
const directory = path.resolve('docs/current/part-new-document-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-new-document-'));
const stage = name => { result.timing.push({ name, at: new Date().toISOString() }); process.stderr.write(`Part NewDoc: ${name}\n`); };
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const volume = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) <= 1e-9 * Math.max(1, Math.abs(expected)),
`Native volume ${actual} differs from expected ${expected}`);
let kernel, browser;
try {
execFileSync(path.resolve('web/node_modules/.bin/tsc'), ['--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler',
'--strict', '--skipLibCheck', '--outDir', temporary, 'web/node_modules/vite/client.d.ts', 'web/src/part-desktop-adapter.ts'], { stdio: 'pipe' });
const { executePartFixedBoxDesktop } = await import(pathToFileURL(path.join(temporary, 'part-desktop-adapter.js')));
stage('native-load-enter');
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? 'web/public/kernel', 'FreeCADWebDocumentProbe'));
stage('native-load-complete');
const raw = (cmd, args) => JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd, args })]));
const doc = (document, action, args = {}) => {
const reply = raw('part.document', { document, action, ...args });
assert.equal(reply.ok, true, JSON.stringify(reply)); return reply.result;
};
const router = { request: async (module, cmd, args) => {
assert.equal(module, 'Part'); assert.ok(['part.document', 'part.documentViewCommand'].includes(cmd));
const reply = raw(cmd, args);
return reply.ok ? { kind: 'result', requestId: 'new-doc', result: reply.result }
: { kind: 'error', requestId: 'new-doc', error: { code: reply.code, message: reply.error } };
} };
doc('RetainedPart', 'create'); doc('RetainedPart', 'setBox', { object: 'Existing', dimensions: [2, 3, 4] });
const before = doc('RetainedPart', 'snapshot'); let selection;
const execute = (args = {}, current = 'RetainedPart') => executePartFixedBoxDesktop(router, 'Part_NewDoc', args, current,
(documentId, objects) => { selection = { documentId, objects }; });
const createdNames = [];
for (const args of [{ name: 'PartNew' }, { name: 'PartNew' }, {}, {}]) {
const reply = await execute(args); assert.equal(reply.kind, 'result', JSON.stringify(reply));
const state = reply.result;
assert.equal(state.objectCount, 0); assert.deepEqual(state.objects, []);
assert.equal(state.undoCount, 0); assert.equal(state.redoCount, 0);
assert.deepEqual(selection, { documentId: state.document, objects: [] });
assert.ok(!createdNames.includes(state.document)); createdNames.push(state.document);
assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
const active = raw('part.documentViewCommand', { owner: 'DocumentProbe', document: state.document,
command: 'Std_CommandLine', parameters: { operation: 'submit', source: 'print(App.ActiveDocument.Name)' } });
assert.equal(active.ok, true, JSON.stringify(active)); assert.equal(active.result.stderr, '');
assert.equal(active.result.stdout.trim(), state.document);
record(args, { document: state.document, selection, active: active.result.stdout.trim() },
'NewDoc creates a distinct empty native active document, clears selection and preserves existing document and history');
}
const current = createdNames[0];
for (const args of [{ name: '' }, { name: '../invalid' }, { name: 12 }, { activeTask: true }, { taskActive: true },
{ action: 'cancel' }, { document: 'Other' }, { documentId: 'Other' }, { runtimeOwner: 'PartBox' }]) {
let calls = 0;
const reply = await executePartFixedBoxDesktop({ request: () => { calls++; throw Error('invalid request reached native'); } },
'Part_NewDoc', args, current, () => { throw Error('invalid request changed selection'); });
assert.equal(reply.kind, 'error'); assert.equal(calls, 0); assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
record(args, reply.error, 'Invalid and cancelled requests do not dispatch mutations or change selection');
}
let state = doc(current, 'setBox', { object: 'NewBox', dimensions: [4, 5, 6] });
volume(state.objects[0].volume, 120); assert.equal(state.undoCount, 1);
assert.equal(doc(current, 'undo').objectCount, 0); volume(doc(current, 'redo').objects[0].volume, 120);
const filename = '/tmp/PartNew.FCStd'; state = doc(current, 'save', { path: filename }); assert.ok(state.size > 0);
const savedBytes = kernel.FS.readFile(filename); assert.deepEqual([...savedBytes.slice(0, 2)], [80, 75]);
doc(current, 'close'); state = doc(current, 'open', { path: filename }); volume(state.objects[0].volume, 120);
volume(doc(current, 'setBox', { object: 'NewBox', dimensions: [5, 6, 7] }).objects[0].volume, 210);
volume(doc(current, 'undo').objects[0].volume, 120); state = doc(current, 'redo'); volume(state.objects[0].volume, 210);
assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
record({ document: current, operations: ['create-box', 'undo', 'redo', 'save', 'close', 'open', 'edit', 'undo', 'redo'] },
{ bytes: savedBytes.length, volume: state.objects[0].volume }, 'New native documents support parametric edits, history and editable FCStd restoration');
stage('native-workflow-complete'); kernel.PThread?.terminateAllThreads(); kernel = undefined;
stage('browser-enter');
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? '/usr/bin/google-chrome', headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000); const errors = []; page.on('pageerror', error => errors.push(String(error)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=Part_NewDoc`);
const selectDesktop = async command => page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: new RegExp(`^${command}$`) }) }).click();
const waitResult = async () => {
await page.waitForFunction(() => !!window.__freecadWorkbenchState?.response && !document.querySelector('button.run')?.disabled);
const reply = await page.evaluate(() => window.__freecadWorkbenchState.response);
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
const clear = () => page.evaluate(() => { window.__freecadWorkbenchState.response = null; });
const run = async payload => {
await page.locator('#payload-editor').fill(JSON.stringify(payload)); await clear();
await page.getByRole('button', { name: '执行选中命令', exact: true }).click(); return waitResult();
};
await selectDesktop('Part_NewDoc'); await clear();
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
const first = await waitResult(); assert.equal(first.objectCount, 0);
assert.equal(JSON.parse(await page.locator('#payload-editor').inputValue()).documentId, first.document);
await selectDesktop('Part_Box'); const box = await run({ document: first.document }); volume(box.objects[0].volume, 1000);
await selectDesktop('Part_NewDoc'); await clear();
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
const second = await waitResult(); assert.notEqual(second.document, first.document); assert.equal(second.objectCount, 0);
assert.equal(JSON.parse(await page.locator('#payload-editor').inputValue()).documentId, second.document);
const api = async command => page.locator('aside:nth-child(2) > button.command').filter({ has: page.getByText(command, { exact: true }) }).click();
await api('part.document');
const payload = (document, action, args = {}) => ({ schema: 'freecad-web-part/document/v1', module: 'Part', document, action, ...args });
volume((await run(payload(first.document, 'snapshot'))).objects[0].volume, 1000);
volume((await run(payload(second.document, 'setBox', { object: 'Editable', dimensions: [3, 4, 5] }))).objects[0].volume, 60);
assert.equal((await run(payload(second.document, 'undo'))).objectCount, 0);
volume((await run(payload(second.document, 'redo'))).objects[0].volume, 60);
await run(payload(second.document, 'save', { path: '/tmp/NewDocBrowser.FCStd' }));
const bytes = await page.evaluate(async () => {
const bytes = window.__freecadWorkbenchState.response.result.bytes;
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) throw Error('Native FCStd bytes missing');
const storage = await import('/src/storage.ts'); await storage.writeDocumentFile('NewDocBrowser.FCStd', bytes);
return [...new Uint8Array(await storage.readDocumentFile('NewDocBrowser.FCStd'))];
});
assert.deepEqual(bytes.slice(0, 2), [80, 75]);
await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
await page.reload(); await api('part.document');
// ArrayBuffer cannot be expressed in the JSON editor; reopen through the public router.
const restored = await page.evaluate(async () => {
const { WebModuleRouter } = await import('/src/module-router.ts');
const { readDocumentFile } = await import('/src/storage.ts');
const router = new WebModuleRouter();
try {
const request = (action, extra = {}) => router.request('Part', 'part.document', {
schema: 'freecad-web-part/document/v1', module: 'Part', document: 'NewDocRestored', action, ...extra,
}, 'NewDocRestored');
const opened = await request('open', { path: '/tmp/Restored.FCStd', bytes: await readDocumentFile('NewDocBrowser.FCStd') });
const edited = await request('setBox', { object: 'Editable', dimensions: [4, 5, 6] });
const undo = await request('undo'); const redo = await request('redo'); return { opened, edited, undo, redo };
} finally { router.dispose(); }
});
for (const reply of Object.values(restored)) assert.equal(reply.kind, 'result', JSON.stringify(reply));
volume(restored.opened.result.objects[0].volume, 60); volume(restored.edited.result.objects[0].volume, 120);
volume(restored.undo.result.objects[0].volume, 60); volume(restored.redo.result.objects[0].volume, 120);
await selectDesktop('Part_NewDoc'); await page.setViewportSize({ width: 390, height: 844 }); await clear();
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
const mobile = await waitResult(); assert.equal(mobile.objectCount, 0);
const layout = await page.getByRole('region', { name: 'New Part document', exact: true }).evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth }));
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout));
await page.screenshot({ path: path.join(directory, 'mobile.png'), fullPage: true }); assert.deepEqual(errors, []);
for (const name of ['desktop.png', 'mobile.png']) {
const file = path.join(directory, name); result.evidenceFiles[path.relative(process.cwd(), file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
record({ operations: ['mounted-new', 'desktop-box', 'mounted-new-again', 'edit', 'undo', 'redo', 'OPFS-save', 'reload', 'open', 'edit', 'mobile-new'] },
{ documents: [first.document, second.document, mobile.document], bytes: bytes.length, restored, layout },
'Mounted desktop and mobile NewDoc controls activate independent editable documents; OPFS FCStd bytes survive reload and retain native history after reopening');
stage('browser-complete'); result.status = 'passed';
} catch (error) { result.error = [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n'); }
finally {
await browser?.close(); kernel?.PThread?.terminateAllThreads(); fs.rmSync(temporary, { recursive: true, force: true });
stage('cleanup-complete'); fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2));
}
return [result];
}