180 lines
15 KiB
JavaScript
180 lines
15 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 { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
|
|
import { playwright } from './part-primitive-business-cases.mjs';
|
|
|
|
export const checkGeometryId = 'Part:desktop:Part_CheckGeometry';
|
|
const fixture = document => `
|
|
import FreeCAD as App, Part, json
|
|
d=App.getDocument('${document}')
|
|
d.addObject('Part::Feature','InvalidFace').Shape=Part.Face(Part.makePolygon([App.Vector(*v) for v in [(0,0,0),(4,4,0),(0,4,0),(4,0,0),(0,0,0)]]))
|
|
d.addObject('Part::Feature','Overlap').Shape=Part.makeCompound([Part.makeBox(3,3,3),Part.makeBox(3,3,3,App.Vector(1,1,1))])
|
|
d.addObject('Part::Feature','Empty')
|
|
d.addObject('App::FeaturePython','NonShape')
|
|
d.recompute()
|
|
`;
|
|
|
|
export async function runCheckGeometryCases(ids) {
|
|
if (!ids.includes(checkGeometryId)) return [];
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'check-geometry-business-'));
|
|
const directory = path.join(root, 'docs/current/part-check-geometry-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const result = { capabilityId: checkGeometryId, status: 'failed', cases: [], evidenceFiles: {} };
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
let kernel, browser;
|
|
try {
|
|
execFileSync(path.join(root, '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'], { cwd: root, 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')));
|
|
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? 'web/public/kernel', 'FreeCADWebDocumentProbe'));
|
|
const queue = new FacadeSerialQueue(); let sequence = 0;
|
|
const router = { request: (_module, command, payload) => {
|
|
assert.equal(command, facade.PART_DOCUMENT_COMMAND);
|
|
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`geometry-${++sequence}`, payload), kernel);
|
|
} };
|
|
const document = 'GeometryBusiness';
|
|
const call = async (action, args = {}, name = document) => {
|
|
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, { schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part',
|
|
document: name, action, includeMesh: false, ...args });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const raw = args => JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: facade.PART_DOCUMENT_COMMAND,
|
|
args: { document, ...args } })]));
|
|
const python = source => {
|
|
const reply = JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd: 'part.documentViewCommand', args: {
|
|
schema: 'freecad-web/document-view/v1', 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 reply.result.stdout.trim() ? JSON.parse(reply.result.stdout.trim()) : null;
|
|
};
|
|
const snapshot = () => python(`
|
|
import FreeCAD as App, json, hashlib
|
|
d=App.getDocument('${document}')
|
|
print(json.dumps({'objects':[{'name':o.Name,'label':o.Label,'type':o.TypeId,'state':o.State,
|
|
'brep':hashlib.sha256(o.Shape.exportBrepToString().encode()).hexdigest() if hasattr(o,'Shape') and not o.Shape.isNull() else None} for o in d.Objects],
|
|
'undo':d.UndoCount,'redo':d.RedoCount,'other':App.getDocument('GeometryOther').Box.Shape.Volume}))
|
|
`);
|
|
const selections = names => names.map(object => ({ documentId: document, object, subelements: [] }));
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_CheckGeometry', args, document);
|
|
for (const name of [document, 'GeometryOther']) {
|
|
await call('create', {}, name); await call('setBox', { object: 'Box', dimensions: [2, 3, 4] }, name);
|
|
}
|
|
python(fixture(document));
|
|
const before = snapshot();
|
|
for (const bop of [false, true]) {
|
|
const input = { selection: selections(['Box', 'InvalidFace', 'Overlap', 'Empty', 'Box']), runBopCheck: bop };
|
|
const started = performance.now(); const reply = await execute(input); const elapsedMs = performance.now() - started;
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); assert.equal(reply.result.documentChanged, false);
|
|
const checks = reply.result.geometryChecks; assert.deepEqual(checks.map(item => item.object), ['Box', 'InvalidFace', 'Overlap', 'Empty']);
|
|
assert.equal(checks[0].valid, true); assert.equal(checks[0].topology.Solid, 1); assert.equal(checks[0].topology.Face, 6);
|
|
assert.equal(checks[1].valid, false); assert.match(checks[1].issues.join(' '), /intersect|wire/i);
|
|
assert.equal(checks[2].valid, !bop); assert.equal(checks[2].topology.Solid, 2);
|
|
if (bop) assert.match(checks[2].issues.join(' '), /SelfIntersect/);
|
|
assert.equal(checks[3].valid, false); assert.equal(checks[3].null, true); assert.deepEqual(checks[3].issues, ['Null shape']);
|
|
assert.deepEqual(snapshot(), before);
|
|
record(input, { checks, elapsedMs }, 'Native BREP and optional BOP analysis distinguish valid, self-intersecting and null geometry without mutation');
|
|
}
|
|
for (const input of [
|
|
{ selection: [] }, { selection: selections(['Missing']) }, { selection: selections(['NonShape']) },
|
|
{ selection: selections(['Box', 'Missing']) }, { selection: [{ documentId: 'GeometryOther', object: 'Box' }] },
|
|
{ document: 'GeometryOther', selection: selections(['Box']) }, { selection: selections(['Box']), runBopCheck: 'yes' },
|
|
{ selection: selections(['Box']), activeTask: true }, { action: 'apply', selection: selections(['Box']) },
|
|
]) {
|
|
const reply = await execute(input); assert.equal(reply.kind, 'error', JSON.stringify(reply)); assert.deepEqual(snapshot(), before);
|
|
record(input, reply, 'Bad selections, non-shapes, document overrides, modes and active tasks fail without changing either document');
|
|
}
|
|
for (const args of [{ checkObjects: [] }, { checkObjects: ['Box', 'NonShape'] }, { checkObjects: ['Box'], runBopCheck: 1 }]) {
|
|
assert.equal(raw({ action: 'checkGeometry', ...args }).ok, false); assert.deepEqual(snapshot(), before);
|
|
}
|
|
record({ operation: 'native-input-validation' }, { unchanged: true }, 'The native endpoint validates the complete selection before analysis');
|
|
python(`App.getDocument('${document}').Box.Length=9`);
|
|
const touched = snapshot(); assert.notDeepEqual(touched, before);
|
|
for (const action of ['read', 'accept', 'cancel']) {
|
|
const reply = await execute({ action, selection: selections(['Box', 'InvalidFace']) });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); assert.equal(reply.result.documentChanged, false);
|
|
assert.deepEqual(snapshot(), touched);
|
|
}
|
|
record({ operations: ['touch-box', 'read', 'check', 'close'] }, touched, 'Task read, check and close preserve dirty shapes, object states and undo history without recompute');
|
|
python(`App.getDocument('${document}').recompute()`);
|
|
const saved = await call('save', { path: '/tmp/GeometryBusiness.FCStd' }); assert.ok(saved.bytes.byteLength > 0);
|
|
const previous = await call('checkGeometry', { checkObjects: ['Box', 'InvalidFace', 'Overlap', 'Empty'], runBopCheck: true });
|
|
await call('close'); await call('open', { path: '/tmp/GeometryBusiness.FCStd', bytes: saved.bytes });
|
|
const restored = await call('checkGeometry', { checkObjects: ['Box', 'InvalidFace', 'Overlap', 'Empty'], runBopCheck: true });
|
|
assert.deepEqual(restored.geometryChecks, previous.geometryChecks);
|
|
const changed = await call('setBox', { object: 'Box', dimensions: [5, 6, 7] });
|
|
assert.ok(Math.abs(changed.objects.find(o => o.name === 'Box').volume - 210) < 1e-6);
|
|
await call('undo'); const afterUndo = snapshot(); await execute({ selection: selections(['Box']) }); assert.deepEqual(snapshot(), afterUndo);
|
|
await call('redo');
|
|
record({ operations: ['save', 'close', 'open', 'check', 'edit', 'undo', 'check', 'redo'] }, { savedBytes: saved.bytes.byteLength, checks: restored.geometryChecks },
|
|
'FCStd restores both valid and invalid topology; inspection preserves subsequent editable transactions');
|
|
kernel.PThread?.terminateAllThreads(); kernel = null;
|
|
|
|
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 }, deviceScaleFactor: 1 });
|
|
const errors = [], downloads = []; page.on('pageerror', error => errors.push(String(error)));
|
|
page.on('request', request => { if (/FreeCADWebDocumentProbe\.wasm(?:\?|$)/.test(request.url())) downloads.push(request.url()); });
|
|
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=part.document`);
|
|
const webDocument = 'GeometryBrowser';
|
|
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: '执行选中命令', 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;
|
|
};
|
|
for (const args of [{ action: 'create' }, { action: 'setBox', object: 'Box', dimensions: [2, 3, 4] }])
|
|
await run({ schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: webDocument, ...args });
|
|
const selectCommand = command => page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: new RegExp(`^${command.replace(/\./g, '\\.')}\$`) }) }).click();
|
|
await page.locator('aside:nth-child(2) > button.command').filter({ has: page.getByText('part.documentViewCommand', { exact: true }) }).click();
|
|
const seeded = await run({ schema: 'freecad-web/document-view/v1', owner: 'DocumentProbe', document: webDocument, command: 'Std_CommandLine',
|
|
parameters: { operation: 'submit', source: `exec(${JSON.stringify(fixture(webDocument))})` } }); assert.equal(seeded.stderr, '');
|
|
await selectCommand('Part_CheckGeometry'); await page.getByLabel('Geometry check document', { exact: true }).fill(webDocument);
|
|
await page.getByRole('button', { name: 'Read shapes', exact: true }).click(); await page.getByRole('checkbox', { name: 'Check Box', exact: true }).waitFor();
|
|
assert.equal(await page.getByRole('checkbox', { name: 'Check NonShape', exact: true }).count(), 0);
|
|
assert.equal(await page.getByRole('button', { name: 'Check shapes', exact: true }).isDisabled(), true);
|
|
for (const object of ['Box', 'InvalidFace', 'Overlap', 'Empty']) await page.getByRole('checkbox', { name: `Check ${object}`, exact: true }).check();
|
|
await page.getByRole('button', { name: 'Check shapes', exact: true }).click();
|
|
await page.locator('[data-geometry-check="Overlap"]').filter({ hasText: 'Valid' }).waitFor();
|
|
await page.getByRole('checkbox', { name: 'Boolean operation check', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Check shapes', exact: true }).click();
|
|
await page.locator('[data-geometry-check="Overlap"]').filter({ hasText: 'SelfIntersect' }).waitFor();
|
|
assert.match(await page.locator('[data-geometry-check="InvalidFace"]').innerText(), /Invalid/);
|
|
assert.match(await page.locator('[data-geometry-check="Empty"]').innerText(), /Null shape/);
|
|
const checks = await page.evaluate(() => window.__freecadWorkbenchState.response.result.geometryChecks);
|
|
await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
|
|
await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(directory, 'mobile.png'), fullPage: true });
|
|
const layout = await page.getByRole('region', { name: 'Check geometry task', exact: true }).evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth }));
|
|
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout));
|
|
await page.getByRole('button', { name: 'Close check', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Check shapes', exact: true }).waitFor({ state: 'detached' });
|
|
await page.getByRole('button', { name: 'Read shapes', exact: true }).click(); await page.getByRole('checkbox', { name: 'Check Box', exact: true }).waitFor();
|
|
await page.getByRole('button', { name: 'Check shapes', exact: true }).click();
|
|
await page.locator('[data-geometry-check="Overlap"]').filter({ hasText: 'SelfIntersect' }).waitFor();
|
|
assert.deepEqual(await page.evaluate(() => window.__freecadWorkbenchState.response.result.geometryChecks), checks);
|
|
assert.equal(downloads.length, 1); assert.deepEqual(errors, []);
|
|
for (const name of ['desktop.png', 'mobile.png']) {
|
|
const file = path.join(directory, name); result.evidenceFiles[path.relative(root, file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
}
|
|
record({ actions: ['multi-select', 'BREP-check', 'BOP-check', 'close', 'read', 'repeat'], viewports: [[1440, 900], [390, 844]] },
|
|
{ checks, layout, wasmDownloads: downloads.length, screenshots: Object.keys(result.evidenceFiles) },
|
|
'Mounted task displays real native diagnostics on desktop/mobile and reuses one Worker across repeated inspection');
|
|
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 });
|
|
fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2));
|
|
}
|
|
return [result];
|
|
}
|