237 lines
19 KiB
JavaScript
237 lines
19 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 offset2dIds = ['Part:desktop:Part_Offset2D', 'Part:desktop:Part_CompOffset'];
|
|
export async function runOffset2DCases(ids) {
|
|
const results = [];
|
|
for (const capabilityId of offset2dIds.filter(id => ids.includes(id))) {
|
|
const command = capabilityId.split(':').at(-1);
|
|
const composite = command === 'Part_CompOffset';
|
|
const result = { capabilityId, status: 'failed', cases: [], evidenceFiles: {} };
|
|
const inputs = ['tools/part-offset2d-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(ext => `web/public/kernel/FreeCADWebDocumentProbe.${ext}`)];
|
|
const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
for (const file of inputs) result.evidenceFiles[file] = digest(file);
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-offset2d-'));
|
|
const directory = path.resolve('docs/current/part-offset2d-business', `${command}-${Date.now()}`);
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const started = performance.now();
|
|
const stage = name => process.stderr.write(`${command} ${name}: ${Math.round(performance.now() - started)} ms\n`);
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
const semantic = 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, source, value, area, name = 'Offset2D') => {
|
|
const feature = state.objects.find(item => item.name === name);
|
|
assert.equal(feature?.type, 'Part::Offset2D'); assert.equal(feature.topology.valid, true);
|
|
assert.equal(feature.topology.Face, 1); assert.equal(feature.topology.Solid, 0);
|
|
assert.deepEqual(feature.offsetFeature, { source, value }); assert.ok(feature.dependencies.includes(source));
|
|
assert.ok(Math.abs(feature.topology.area - area) < 1e-5, `${feature.topology.area} != ${area}`);
|
|
assert.ok(feature.mesh.triangleCount > 0); 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 = 'Offset2DBusiness';
|
|
const router = { request: (_module, endpoint, payload) => {
|
|
assert.equal(endpoint, facade.PART_DOCUMENT_COMMAND);
|
|
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`offset2d-${++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 faceArgs = name => ({ builderMode: 'face-vertices', planar: true,
|
|
selection: [{ documentId: name, object: 'Box', subelements: ['Vertex1', 'Vertex2', 'Vertex4', 'Vertex3'] }] });
|
|
await call('create'); await call('setBox', { object: 'Box', dimensions: [10, 10, 10] });
|
|
const source = (await call('createBuilder', faceArgs(document))).createdObject;
|
|
await call('create', {}, 'Offset2DOther');
|
|
const other = await call('snapshot', {}, 'Offset2DOther');
|
|
const before = await call('readGeometry'); let applied;
|
|
const selection = [{ documentId: document, object: source }];
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, command,
|
|
{ selection, ...(composite ? { mode: 'offset2d', actionIndex: 1 } : {}), ...args }, document,
|
|
(_document, selected) => { applied = selected; });
|
|
stage('native-create-enter');
|
|
const created = await execute({}); assert.equal(created.kind, 'result', JSON.stringify(created));
|
|
const expanded = check(created.result, source, 1, 140 + Math.PI);
|
|
assert.equal(created.result.undoCount, before.undoCount + 1);
|
|
assert.equal(created.result.objects.find(item => item.name === source).displayVisibility, false);
|
|
assert.equal(expanded.displayVisibility, true); assert.deepEqual(applied, [{ documentId: document, object: 'Offset2D' }]);
|
|
assert.deepEqual(semantic(await call('snapshot', {}, 'Offset2DOther')), semantic(other));
|
|
await call('undo'); assert.deepEqual(semantic(await call('readGeometry')), semantic(before));
|
|
check(await call('redo'), source, 1, 140 + Math.PI);
|
|
record({ command, selection, defaultDistance: 1 }, { area: expanded.topology.area, type: expanded.type },
|
|
'Native linked Offset2D expands a square with arc corners, hides its source, selects its result and supports isolated undo/redo');
|
|
const stable = await call('readGeometry');
|
|
for (const args of [{ selection: [] }, { selection: [...selection, ...selection] }, { activeTask: true },
|
|
{ selection: [{ documentId: 'Offset2DOther', object: source }] }, { selection: [{ documentId: document, object: 'Missing' }] },
|
|
{ selection: [{ ...selection[0], subelements: ['Edge1'] }] }, { offsetValue: 0 }, { offsetValue: '1' }, { offsetValue: NaN },
|
|
{ actionIndex: 8 }, { actionIndex: -1 }, { actionIndex: 2 }, { actionIndex: 0.5 }, { actionIndex: '1' },
|
|
{ iMsg: 0, actionIndex: 1 }, { mode: 'invalid' }, { object: 'Box' }, { action: 'edit', object: 'Box' },
|
|
{ action: 'edit', object: 'Offset2D', offsetValue: -100 },
|
|
{ action: 'edit', object: 'Offset2D', selection: [{ documentId: document, object: 'Offset2D' }] }]) {
|
|
assert.equal((await execute(args)).kind, 'error', JSON.stringify(args));
|
|
const after = await call('readGeometry'); assert.deepEqual(semantic(after), semantic(stable)); assert.equal(after.undoCount, stable.undoCount);
|
|
}
|
|
const cancelled = await execute({ action: 'cancel' }); assert.equal(cancelled.kind, 'result');
|
|
assert.deepEqual(semantic(cancelled.result), semantic(stable)); assert.equal(cancelled.result.undoCount, stable.undoCount);
|
|
const edited = await execute({ action: 'edit', object: 'Offset2D', offsetValue: -1 });
|
|
assert.equal(edited.kind, 'result', JSON.stringify(edited)); check(edited.result, source, -1, 64);
|
|
check(await call('setPosition', { object: source, position: [5, 6, 7] }), source, -1, 64);
|
|
const inspect = () => {
|
|
const code = `import FreeCAD as App, json\nf=App.getDocument('${document}').Offset2D\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(code)})` } } })]));
|
|
assert.equal(reply.ok, true, JSON.stringify(reply)); assert.equal(reply.result.stderr, ''); return JSON.parse(reply.result.stdout.trim());
|
|
};
|
|
const bounds = inspect(); bounds.forEach((value, i) => assert.ok(Math.abs(value - [5, 7, 8, 5, 15, 16][i]) < 1e-5, JSON.stringify(bounds)));
|
|
await call('save', { path: '/tmp/offset2d.FCStd' }); await call('close');
|
|
check(await call('open', { path: '/tmp/offset2d.FCStd' }), source, -1, 64);
|
|
const unique = await execute({ offsetValue: -2 }); assert.equal(unique.kind, 'result', JSON.stringify(unique));
|
|
assert.notEqual(unique.result.createdObject, 'Offset2D'); check(unique.result, source, -2, 36, unique.result.createdObject);
|
|
record({ invalidInputs: 20, cancel: true, distance: -1, sourcePosition: [5, 6, 7] }, { area: 64, bounds, unique: unique.result.createdObject },
|
|
'Invalid and cancelled operations preserve history; source placement recomputes geometry, and FCStd restores linked editable features with unique names');
|
|
const wire = (await call('createBuilder', { builderMode: 'wire', selection: [{ documentId: document, object: 'Box',
|
|
subelements: ['Edge1', 'Edge2', 'Edge3', 'Edge4'] }] })).createdObject;
|
|
const wireReply = await execute({ mode: undefined, actionIndex: undefined, iMsg: 1,
|
|
selection: [{ documentId: document, object: wire }] });
|
|
assert.equal(wireReply.kind, 'result', JSON.stringify(wireReply));
|
|
const wireFeature = wireReply.result.objects.find(item => item.name === wireReply.result.createdObject);
|
|
assert.equal(wireFeature.type, 'Part::Offset2D'); assert.equal(wireFeature.topology.valid, true);
|
|
assert.equal(wireFeature.topology.Face, 0); assert.equal(wireFeature.topology.Solid, 0);
|
|
assert.ok(wireFeature.dependencies.includes(wire));
|
|
assert.ok(Math.abs(wireFeature.topology.length - (40 + 2 * Math.PI)) < 1e-5);
|
|
record({ iMsg: 1, wireSide: 10, distance: 1 }, { length: wireFeature.topology.length, type: wireFeature.type },
|
|
'Native dropdown selector creates a linked 2D wire offset with analytic perimeter and no artificial face or solid');
|
|
if (composite) {
|
|
const reply = await execute({ mode: 'offset', actionIndex: 0, selection: [{ documentId: document, object: 'Box' }], offsetValue: -1 });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
const solid = reply.result.objects.find(item => item.name === reply.result.createdObject);
|
|
assert.equal(solid.type, 'Part::Offset'); assert.deepEqual(solid.offsetFeature, { source: 'Box', value: -1 });
|
|
assert.ok(Math.abs(solid.volume - 512) < 1e-5); assert.equal(solid.topology.Solid, 1);
|
|
record({ actionIndex: 0, dimensions: [10, 10, 10], distance: -1 }, { volume: solid.volume, type: solid.type },
|
|
'Composite 3D branch dispatches a linked native solid with analytic inset volume');
|
|
}
|
|
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 = 'Offset2DUI';
|
|
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(() => { 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, 10, 10] });
|
|
const uiSource = (await run({ ...payload, action: 'createBuilder', ...faceArgs(uiDocument) })).createdObject;
|
|
await run({ ...payload, action: 'setPosition', object: uiSource, position: [20, 0, 0] });
|
|
await run({ ...payload, action: 'save', path: '/tmp/offset2d-ui-seed.FCStd',
|
|
displayProperties: [{ object: 'Box', color: '#808080', visible: false }] });
|
|
const task = async () => {
|
|
await page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: new RegExp(`^${command}$`) }) }).click();
|
|
await page.getByLabel('Offset document', { exact: true }).fill(uiDocument);
|
|
if (composite) await page.getByLabel('Offset type', { exact: true }).selectOption('offset2d');
|
|
};
|
|
await task(); await page.getByRole('button', { name: 'Read offset document', exact: true }).click();
|
|
await page.getByLabel('Offset source', { exact: true }).selectOption(uiSource);
|
|
await page.getByLabel('Offset distance', { exact: true }).fill('-1');
|
|
await page.getByRole('button', { name: 'Create offset', exact: true }).click();
|
|
const row = page.locator('[data-offset-object="Offset2D"]'); await row.filter({ hasText: '64.000' }).waitFor();
|
|
check((await page.evaluate(() => window.__freecadWorkbenchState.response)).result, uiSource, -1, 64);
|
|
await page.getByRole('button', { name: 'Offset undo', exact: true }).click(); await row.waitFor({ state: 'detached' });
|
|
await page.getByRole('button', { name: 'Offset redo', exact: true }).click(); await row.waitFor();
|
|
await page.getByLabel('Offset feature', { exact: true }).selectOption('Offset2D');
|
|
await page.getByLabel('Offset distance', { exact: true }).fill('-2');
|
|
await page.getByRole('button', { name: 'Apply offset', exact: true }).click(); await row.filter({ hasText: '36.000' }).waitFor();
|
|
if (composite) {
|
|
await page.getByLabel('Offset type', { exact: true }).selectOption('offset');
|
|
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: '512.000' }).waitFor();
|
|
const state = (await page.evaluate(() => window.__freecadWorkbenchState.response)).result;
|
|
assert.equal(state.objects.find(item => item.name === 'Offset').type, 'Part::Offset');
|
|
await page.getByLabel('Offset type', { exact: true }).selectOption('offset2d');
|
|
await page.getByLabel('Offset feature', { exact: true }).selectOption('Offset2D');
|
|
}
|
|
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);
|
|
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 row.filter({ hasText: '36.000' }).waitFor();
|
|
await page.getByLabel('Offset feature', { exact: true }).selectOption('Offset2D');
|
|
assert.equal(await page.getByLabel('Offset source', { exact: true }).inputValue(), uiSource);
|
|
assert.equal(await page.getByLabel('Offset distance', { exact: true }).inputValue(), '-2');
|
|
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 digest(path.join(directory, name));
|
|
};
|
|
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.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({ command, actions: ['create', 'undo', 'redo', 'edit', 'OPFS save', 'reload', 'open', 'rotate'], composite },
|
|
{ areas: [64, 36], solidVolume: composite ? 512 : null, bytes: bytes.length, layout },
|
|
'Mounted controls execute their native offset branches, restore editable FCStd through OPFS and render interactive geometry on desktop/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: ${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));
|
|
}
|
|
results.push(result);
|
|
if (result.status !== 'passed') break;
|
|
}
|
|
return results;
|
|
}
|