195 lines
16 KiB
JavaScript
195 lines
16 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 { pathToFileURL } from 'node:url';
|
|
import { createHash } from 'node:crypto';
|
|
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
|
|
import { playwright } from './part-primitive-business-cases.mjs';
|
|
|
|
export const offsetId = 'Part:desktop:Part_Offset';
|
|
export async function runOffsetCases(ids) {
|
|
if (!ids.includes(offsetId)) return [];
|
|
const result = { capabilityId: offsetId, status: 'failed', cases: [], evidenceFiles: {} };
|
|
const inputs = ['tools/part-offset-business-cases.mjs', 'web/src/part-offset-task.tsx',
|
|
'web/src/part-desktop-adapter.ts', 'web/src/part-document-facade.ts', 'web/src/workbench-shell.tsx',
|
|
'freecad-source/src/WebAssembly/FreeCADCoreProbe/DocumentProbe.cpp',
|
|
...['js', 'wasm', 'worker.js'].map(extension => `web/public/kernel/FreeCADWebDocumentProbe.${extension}`)];
|
|
const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
for (const file of inputs) result.evidenceFiles[file] = digest(file);
|
|
const directory = path.resolve('docs/current/part-offset-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-offset-'));
|
|
const started = performance.now();
|
|
const stage = name => process.stderr.write(`Part Offset ${name}: ${Math.round(performance.now() - started)} ms\n`);
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
// Tessellation and edge traversal can change after recompute without changing the document.
|
|
const semanticObjects = state => JSON.parse(JSON.stringify(state.objects.map(({ mesh, lines, ...item }) => item),
|
|
(_key, value) => typeof value === 'number' ? Math.round(value * 1e7) / 1e7 : value));
|
|
const check = (state, value, volume, name = 'Offset') => {
|
|
const feature = state.objects.find(item => item.name === name);
|
|
assert.equal(feature?.type, 'Part::Offset'); assert.equal(feature.topology.valid, true);
|
|
assert.deepEqual(feature.offsetFeature, { source: 'Box', value });
|
|
assert.ok(feature.dependencies.includes('Box')); assert.ok(feature.mesh.triangleCount > 0);
|
|
if (volume !== undefined) assert.ok(Math.abs(feature.volume - volume) < 1e-5, `${feature.volume} != ${volume}`);
|
|
return feature;
|
|
};
|
|
let kernel, browser;
|
|
try {
|
|
stage('compile-adapter');
|
|
execFileSync('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 facade = await import(pathToFileURL(path.join(temporary, 'part-document-facade.js')));
|
|
const adapter = await import(pathToFileURL(path.join(temporary, 'part-desktop-adapter.js')));
|
|
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(temporary, 'facade-envelope.js')));
|
|
stage('load-enter');
|
|
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebDocumentProbe'));
|
|
stage('load-complete');
|
|
const queue = new FacadeSerialQueue(); let sequence = 0;
|
|
const document = 'OffsetBusiness';
|
|
const router = { request: (_module, command, payload) => {
|
|
assert.equal(command, facade.PART_DOCUMENT_COMMAND);
|
|
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`offset-${++sequence}`, payload), kernel);
|
|
} };
|
|
const call = async (action, extra = {}, name = document) => {
|
|
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, { schema: facade.PART_DOCUMENT_SCHEMA,
|
|
module: 'Part', document: name, action, builderInfo: true, includeMesh: true, ...extra });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const selection = [{ documentId: document, object: 'Box' }]; let applied;
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_Offset', { selection, ...args }, document,
|
|
(name, selected) => { applied = { name, selected }; });
|
|
const bounds = () => {
|
|
const source = `import FreeCAD as App, json\nf=App.getDocument('${document}').Offset\nb=f.Shape.BoundBox\nprint(json.dumps([b.XMin,b.YMin,b.ZMin,b.XMax,b.YMax,b.ZMax]))`;
|
|
const reply = JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.documentViewCommand', args: {
|
|
owner: 'DocumentProbe', document, command: 'Std_CommandLine', parameters: { operation: 'submit', source: `exec(${JSON.stringify(source)})` },
|
|
} })]));
|
|
assert.equal(reply.ok, true, JSON.stringify(reply)); assert.equal(reply.result.stderr, '', JSON.stringify(reply));
|
|
return JSON.parse(reply.result.stdout.trim());
|
|
};
|
|
const checkBounds = expected => bounds().forEach((n, i) => assert.ok(Math.abs(n - expected[i]) < 1e-5, `${bounds()} != ${expected}`));
|
|
await call('create'); await call('setBox', { object: 'Box', dimensions: [10, 12, 14] });
|
|
await call('create', {}, 'OffsetOther'); await call('setBox', { object: 'Box', dimensions: [2, 3, 4] }, 'OffsetOther');
|
|
const other = await call('readGeometry', {}, 'OffsetOther'); const before = await call('readGeometry');
|
|
stage('create-enter');
|
|
const reply = await execute({}); assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
assert.ok(check(reply.result, 1).volume > 1680); checkBounds([-1, -1, -1, 11, 13, 15]);
|
|
assert.equal(reply.result.undoCount, before.undoCount + 1);
|
|
assert.equal(reply.result.objects.find(item => item.name === 'Box').displayVisibility, false);
|
|
assert.deepEqual(applied, { name: document, selected: [{ documentId: document, object: 'Offset' }] });
|
|
assert.deepEqual(semanticObjects(await call('readGeometry', {}, 'OffsetOther')), semanticObjects(other));
|
|
record({ selection, dimensions: [10, 12, 14], defaultDistance: 1 }, { bounds: bounds(), volume: check(reply.result, 1).volume },
|
|
'Default Offset creates a native linked feature with expanded bounds, mesh, source visibility and selection in one isolated transaction');
|
|
await call('undo'); assert.deepEqual(semanticObjects(await call('readGeometry')), semanticObjects(before)); check(await call('redo'), 1);
|
|
const stable = await call('readGeometry');
|
|
for (const args of [{ selection: [] }, { selection: [...selection, ...selection] }, { selection: [{ documentId: 'OffsetOther', object: 'Box' }] },
|
|
{ selection: [{ documentId: document, object: 'Missing' }] }, { selection: [{ ...selection[0], subelements: ['Face1'] }] },
|
|
{ activeTask: true }, { shapes: ['fake'] }, { offsetValue: 0 }, { offsetValue: '1' }, { offsetValue: NaN },
|
|
{ object: 'Box' }, { action: 'edit', object: 'Box' }, { action: 'edit', object: 'Offset', offsetValue: -100 },
|
|
{ action: 'edit', object: 'Offset', selection: [{ documentId: document, object: 'Offset' }] }]) {
|
|
assert.equal((await execute(args)).kind, 'error', JSON.stringify(args));
|
|
const after = await call('readGeometry'); assert.deepEqual(semanticObjects(after), semanticObjects(stable)); assert.equal(after.undoCount, stable.undoCount);
|
|
}
|
|
const cancelled = await execute({ action: 'cancel' }); assert.equal(cancelled.kind, 'result');
|
|
assert.deepEqual(semanticObjects(cancelled.result), semanticObjects(stable)); assert.equal(cancelled.result.undoCount, stable.undoCount);
|
|
record({ invalid: ['selection', 'cross-document', 'subelement', 'missing', 'distance', 'name', 'cycle', 'collapsed geometry'], action: 'cancel' },
|
|
{ undoCount: stable.undoCount, objects: stable.objects.map(item => item.name) }, 'Invalid and cancelled operations preserve document objects and undo history, including native recompute failure rollback');
|
|
const edited = await execute({ action: 'edit', object: 'Offset', offsetValue: -1 });
|
|
assert.equal(edited.kind, 'result', JSON.stringify(edited)); check(edited.result, -1, 960); checkBounds([1, 1, 1, 9, 11, 13]);
|
|
check(await call('setBox', { object: 'Box', dimensions: [12, 12, 14] }), -1, 1200);
|
|
await call('save', { path: '/tmp/offset-business.FCStd' }); await call('close');
|
|
check(await call('open', { path: '/tmp/offset-business.FCStd' }), -1, 1200);
|
|
check(await call('setBox', { object: 'Box', dimensions: [14, 12, 14] }), -1, 1440);
|
|
const unique = await execute({ offsetValue: -1 }); assert.equal(unique.kind, 'result', JSON.stringify(unique));
|
|
assert.notEqual(unique.result.createdObject, 'Offset'); check(unique.result, -1, 1440, unique.result.createdObject);
|
|
record({ edits: [-1, [12, 12, 14], [14, 12, 14]], actions: ['undo', 'redo', 'save', 'close', 'open'] },
|
|
{ volumes: [960, 1200, 1440], unique: unique.result.createdObject }, 'Native distance and source edits recompute analytical inner volumes; undo, redo, unique naming and FCStd retain live source links');
|
|
kernel.PThread?.terminateAllThreads(); kernel = null; stage('native-cleanup-complete');
|
|
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: 900 } });
|
|
const errors = []; page.on('pageerror', error => errors.push(String(error)));
|
|
const uiDocument = 'OffsetUIBusiness';
|
|
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=part.document&document=${uiDocument}`);
|
|
const run = async payload => {
|
|
await page.locator('#payload-editor').fill(JSON.stringify(payload));
|
|
await page.evaluate(() => { if (window.__freecadWorkbenchState) window.__freecadWorkbenchState.response = null; });
|
|
await page.getByRole('button', { name: '\u6267\u884c\u9009\u4e2d\u547d\u4ee4', exact: true }).click();
|
|
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 payload = { schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: uiDocument };
|
|
await run({ ...payload, action: 'create' }); await run({ ...payload, action: 'setBox', object: 'Box', dimensions: [10, 12, 14] });
|
|
const task = async () => {
|
|
await page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Part_Offset$/ }) }).click();
|
|
await page.getByLabel('Offset document', { exact: true }).fill(uiDocument);
|
|
};
|
|
await task(); await page.getByRole('button', { name: 'Read offset document', exact: true }).click();
|
|
await page.getByLabel('Offset source', { exact: true }).selectOption('Box');
|
|
await page.getByLabel('Offset distance', { exact: true }).fill('-1');
|
|
await page.getByRole('button', { name: 'Create offset', exact: true }).click();
|
|
await page.locator('[data-offset-object="Offset"]').filter({ hasText: '960.000' }).waitFor();
|
|
check((await page.evaluate(() => window.__freecadWorkbenchState.response)).result, -1, 960);
|
|
await page.getByRole('button', { name: 'Offset undo', exact: true }).click();
|
|
await page.locator('[data-offset-object="Offset"]').waitFor({ state: 'detached' });
|
|
await page.getByRole('button', { name: 'Offset redo', exact: true }).click();
|
|
await page.locator('[data-offset-object="Offset"]').waitFor();
|
|
await page.getByLabel('Offset feature', { exact: true }).selectOption('Offset');
|
|
await page.getByLabel('Offset distance', { exact: true }).fill('-2');
|
|
await page.getByRole('button', { name: 'Apply offset', exact: true }).click();
|
|
await page.locator('[data-offset-object="Offset"]').filter({ hasText: '480.000' }).waitFor();
|
|
check((await page.evaluate(() => window.__freecadWorkbenchState.response)).result, -2, 480);
|
|
await page.getByRole('button', { name: 'Save offset document', exact: true }).click();
|
|
await page.getByRole('status').filter({ hasText: `Saved Offset-${uiDocument}.FCStd` }).waitFor();
|
|
const bytes = await page.evaluate(async name => {
|
|
const { readDocumentFile } = await import('/src/storage.ts');
|
|
return [...new Uint8Array(await readDocumentFile(`Offset-${name}.FCStd`))];
|
|
}, uiDocument); assert.ok(bytes.length > 100);
|
|
const capture = async name => {
|
|
const canvas = page.locator('[aria-label="Offset geometry"] canvas'); await canvas.waitFor();
|
|
const png = await canvas.screenshot({ path: path.join(directory, name) });
|
|
const colors = await page.evaluate(async base64 => {
|
|
const image = new Image(); image.src = `data:image/png;base64,${base64}`; await image.decode();
|
|
const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height;
|
|
const context = canvas.getContext('2d'); context.drawImage(image, 0, 0);
|
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const colors = new Set(); for (let i = 0; i < pixels.length; i += 4) colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
|
|
return colors.size;
|
|
}, png.toString('base64')); assert.ok(colors > 8, `Canvas colors: ${colors}`);
|
|
return createHash('sha256').update(png).digest('hex');
|
|
};
|
|
const first = await capture('desktop-geometry.png');
|
|
const box = await page.locator('[aria-label="Offset geometry"] canvas').boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2 + 30, { steps: 8 }); await page.mouse.up();
|
|
assert.notEqual(await capture('rotated-geometry.png'), first);
|
|
await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
|
|
await page.reload(); await task();
|
|
await page.getByLabel('Offset FCStd file', { exact: true }).setInputFiles({ name: 'Offset.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
|
|
await page.locator('[data-offset-object="Offset"]').filter({ hasText: '480.000' }).waitFor();
|
|
await page.getByLabel('Offset feature', { exact: true }).selectOption('Offset');
|
|
assert.equal(await page.getByLabel('Offset distance', { exact: true }).inputValue(), '-2');
|
|
assert.equal(await page.getByLabel('Offset source', { exact: true }).inputValue(), 'Box');
|
|
await page.setViewportSize({ width: 390, height: 844 }); await capture('mobile-geometry.png');
|
|
await page.screenshot({ path: path.join(directory, 'mobile.png'), fullPage: true });
|
|
const layout = await page.getByRole('region', { name: 'Offset task', exact: true }).evaluate(element => ({ width: element.clientWidth, scroll: element.scrollWidth }));
|
|
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout)); assert.deepEqual(errors, []);
|
|
record({ document: uiDocument, actions: ['create', 'undo', 'redo', 'edit', 'OPFS save', 'reload', 'open', 'rotate'] },
|
|
{ volumes: [960, 480], bytes: bytes.length, layout }, 'Mounted Offset controls dispatch native features and edits; OPFS restores editable FCStd and interactive nonblank geometry on desktop and mobile');
|
|
for (const name of ['desktop.png', 'desktop-geometry.png', 'rotated-geometry.png', 'mobile.png', 'mobile-geometry.png']) {
|
|
const file = path.join(directory, name); result.evidenceFiles[path.relative(process.cwd(), file)] = digest(file);
|
|
}
|
|
for (const file of inputs) assert.equal(digest(file), result.evidenceFiles[file], `Input changed during Offset execution: ${file}`);
|
|
result.status = 'passed'; stage('browser-complete');
|
|
} catch (error) {
|
|
result.error = `${error.stack ?? error}\n${error.stdout?.toString() ?? ''}\n${error.stderr?.toString() ?? ''}`; process.stderr.write(`${result.error}\n`);
|
|
} finally {
|
|
await browser?.close(); kernel?.PThread?.terminateAllThreads(); stage('cleanup-complete');
|
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2));
|
|
}
|
|
return [result];
|
|
}
|