180 lines
16 KiB
JavaScript
180 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 { 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 makeFaceId = 'Part:desktop:Part_MakeFace';
|
|
const fixture = name => `
|
|
import FreeCAD as App, Part, json
|
|
d=App.getDocument('${name}')
|
|
def wire(x,y,w,h):
|
|
return Part.makePolygon([App.Vector(*p) for p in [(x,y,0),(x+w,y,0),(x+w,y+h,0),(x,y+h,0),(x,y,0)]])
|
|
d.addObject('Part::Feature','Outer').Shape=wire(0,0,10,10)
|
|
d.addObject('Part::Feature','Hole').Shape=wire(2,2,4,4)
|
|
d.addObject('Part::Feature','Island').Shape=wire(3,3,1,1)
|
|
d.addObject('Part::Feature','Separate').Shape=wire(20,0,3,2)
|
|
d.addObject('Part::Feature','Nested').Shape=Part.makeCompound([d.Outer.Shape,d.Hole.Shape,d.Island.Shape])
|
|
d.addObject('Part::Feature','OpenWire').Shape=Part.makePolygon([App.Vector(0,0,0),App.Vector(5,0,0),App.Vector(5,5,0)])
|
|
d.addObject('Part::Feature','NullShape')
|
|
d.addObject('App::FeaturePython','NonShape')
|
|
d.recompute()
|
|
`;
|
|
|
|
export async function runMakeFaceCases(ids) {
|
|
if (!ids.includes(makeFaceId)) return [];
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'make-face-business-'));
|
|
const directory = path.join(root, 'docs/current/part-make-face-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const result = { capabilityId: makeFaceId, 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('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(`face-${++sequence}`, payload), kernel);
|
|
} };
|
|
const document = 'FaceBusiness';
|
|
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, builderInfo: true, includeMesh: false, ...args });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const python = source => {
|
|
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 reply.result.stdout.trim() ? JSON.parse(reply.result.stdout.trim()) : null;
|
|
};
|
|
const selected = names => names.map(object => ({ documentId: document, object, subelements: [] }));
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_MakeFace', args, document);
|
|
const face = (state, name) => state.objects.find(item => item.name === name);
|
|
const area = (state, name, value) => assert.ok(Math.abs(face(state, name).topology.area - value) < 1e-6, JSON.stringify(face(state, name)));
|
|
await call('create'); await call('create', {}, 'FaceOther'); await call('setBox', { object: 'Box', dimensions: [2, 3, 4] }, 'FaceOther');
|
|
const other = await call('snapshot', {}, 'FaceOther'); python(fixture(document));
|
|
for (const [name, sources, expectedArea, count] of [
|
|
['SimpleFace', ['Outer'], 100, 1], ['HoleFace', ['Outer', 'Hole', 'Outer'], 84, 1],
|
|
['IslandFace', ['Outer', 'Hole', 'Island'], 85, 2], ['SeparateFace', ['Outer', 'Separate'], 106, 2],
|
|
['NestedFace', ['Nested'], 85, 2],
|
|
]) {
|
|
const before = await call('snapshot');
|
|
const input = { object: name, selection: selected(sources) }; const reply = await execute(input);
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
const value = face(reply.result, name); assert.equal(value.type, 'Part::Face'); assert.deepEqual(value.sources, [...new Set(sources)]);
|
|
assert.equal(value.faceMakerClass, 'Part::FaceMakerBullseye'); assert.equal(value.topology.valid, true); assert.equal(value.topology.Face, count);
|
|
area(reply.result, name, expectedArea); assert.ok(value.mesh.triangleCount > 0);
|
|
for (const source of before.objects) assert.deepEqual(face(await call('snapshot'), source.name), source);
|
|
const undo = await call('undo'); assert.equal(face(undo, name), undefined);
|
|
area(await call('redo'), name, expectedArea); assert.deepEqual(await call('snapshot', {}, 'FaceOther'), other);
|
|
record(input, value, 'Native Part::Face retains deduplicated Sources, correct hole/island areas, geometry and one undoable creation');
|
|
}
|
|
const a = await execute({ selection: selected(['Outer']) }); const b = await execute({ selection: selected(['Outer']) });
|
|
assert.equal(a.kind, 'result'); assert.equal(b.kind, 'result'); assert.notEqual(a.result.createdObject, b.result.createdObject);
|
|
record({ operation: 'default-name-collision' }, [a.result.createdObject, b.result.createdObject], 'Repeated native creation allocates distinct feature identities');
|
|
const before = await call('snapshot');
|
|
for (const input of [
|
|
{ selection: [] }, { selection: selected(['Missing']) }, { selection: selected(['Outer', 'Missing']) },
|
|
{ selection: selected(['OpenWire']) }, { selection: selected(['NullShape']) }, { selection: selected(['NonShape']) },
|
|
{ selection: [{ documentId: 'FaceOther', object: 'Box' }] }, { document: 'FaceOther', selection: selected(['Outer']) },
|
|
{ selection: selected(['Outer']), object: 'Outer' }, { selection: selected(['Outer']), object: '../Face' },
|
|
{ selection: selected(['Outer']), activeTask: true }, { selection: selected(['Outer']), shapes: ['BREP'] },
|
|
]) {
|
|
const reply = await execute(input); assert.equal(reply.kind, 'error', JSON.stringify(reply));
|
|
assert.deepEqual(await call('snapshot'), before); assert.deepEqual(await call('snapshot', {}, 'FaceOther'), other);
|
|
record(input, reply, 'Invalid wire geometry, missing/foreign sources and overwrite requests roll back without changing either document');
|
|
}
|
|
python("d=App.getDocument('FaceBusiness'); d.openTransaction('Move wires'); d.Outer.Placement.Base=App.Vector(5,7,9); d.Hole.Placement.Base=App.Vector(5,7,9); d.Island.Placement.Base=App.Vector(5,7,9); d.Separate.Placement.Base=App.Vector(5,7,9); d.recompute(); d.commitTransaction()");
|
|
const moved = await call('snapshot', { includeMesh: true }); area(moved, 'HoleFace', 84);
|
|
const bounds = face(moved, 'HoleFace').mesh.bounds; assert.deepEqual(bounds.map(x => Math.round(x)), [5, 7, 9, 15, 17, 9]);
|
|
python("d.openTransaction('Resize wire'); placement=App.Placement(d.Outer.Placement); d.Outer.Shape=wire(0,0,12,10); d.Outer.Placement=placement; d.recompute(); d.commitTransaction()");
|
|
const resized = await call('snapshot'); area(resized, 'SimpleFace', 120);
|
|
area(await call('undo'), 'SimpleFace', 100); area(await call('redo'), 'SimpleFace', 120);
|
|
record({ operations: ['move-sources', 'resize-source', 'undo', 'redo'] }, { bounds, face: face(resized, 'SimpleFace') },
|
|
'Native Sources links propagate source placement and geometry edits through face recompute and history');
|
|
const saved = await call('save', { path: '/tmp/FaceBusiness.FCStd' }); assert.ok(saved.bytes.byteLength > 0);
|
|
const expected = await call('snapshot'); await call('close'); const restored = await call('open', { path: '/tmp/FaceBusiness.FCStd', bytes: saved.bytes });
|
|
assert.deepEqual(restored.objects, expected.objects);
|
|
python("d=App.getDocument('FaceBusiness'); d.openTransaction('Reopen edit'); placement=App.Placement(d.Outer.Placement); d.Outer.Shape=wire(0,0,14,10); d.Outer.Placement=placement; d.recompute(); d.commitTransaction()");
|
|
area(await call('snapshot'), 'SimpleFace', 140); area(await call('undo'), 'SimpleFace', 120);
|
|
record({ operations: ['save', 'close', 'reopen', 'edit-source', 'undo'] }, { savedBytes: saved.bytes.byteLength, face: face(restored, 'SimpleFace') },
|
|
'FCStd preserves native face type and Sources links, with editable dependencies after reopening');
|
|
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 = []; 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.document&document=FaceBrowser`);
|
|
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;
|
|
};
|
|
await run({ schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: 'FaceBrowser', action: 'create' });
|
|
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: 'FaceBrowser', command: 'Std_CommandLine',
|
|
parameters: { operation: 'submit', source: `exec(${JSON.stringify(fixture('FaceBrowser'))})` } }); assert.equal(seeded.stderr, '');
|
|
const selectTask = async () => {
|
|
await page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Part_MakeFace$/ }) }).click();
|
|
await page.getByLabel('Make face document', { exact: true }).fill('FaceBrowser');
|
|
};
|
|
await selectTask(); await page.getByRole('button', { name: 'Read face document', exact: true }).click();
|
|
await page.getByRole('checkbox', { name: 'Face source Outer', exact: true }).check();
|
|
await page.getByRole('checkbox', { name: 'Face source Hole', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Create face', exact: true }).click();
|
|
await page.locator('[data-made-face="Face"]').filter({ hasText: '84.000' }).waitFor();
|
|
await page.getByRole('button', { name: 'Face undo', exact: true }).click(); await page.locator('[data-made-face="Face"]').waitFor({ state: 'detached' });
|
|
await page.getByRole('button', { name: 'Face redo', exact: true }).click(); await page.locator('[data-made-face="Face"]').filter({ hasText: '84.000' }).waitFor();
|
|
await page.getByRole('button', { name: 'Save face document', exact: true }).click(); await page.getByRole('link', { name: 'Open face in project', exact: true }).waitFor();
|
|
const bytes = await page.evaluate(async () => { const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('Face-FaceBrowser.FCStd'))]; });
|
|
const capture = async name => {
|
|
const canvas = page.locator('[aria-label="Face 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 ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); const pixels = ctx.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); return createHash('sha256').update(png).digest('hex');
|
|
};
|
|
const initial = await capture('desktop-geometry.png'); const rect = await page.locator('[aria-label="Face geometry"] canvas').boundingBox();
|
|
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.mouse.down();
|
|
await page.mouse.move(rect.x + rect.width / 2 + 70, rect.y + rect.height / 2 + 30, { steps: 8 }); await page.mouse.up();
|
|
assert.notEqual(await capture('rotated-geometry.png'), initial); await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
|
|
await page.reload(); await selectTask();
|
|
await page.getByLabel('Face FCStd file', { exact: true }).setInputFiles({ name: 'FaceBrowser.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
|
|
await page.locator('[data-made-face="Face"]').filter({ hasText: '84.000' }).waitFor();
|
|
await page.getByRole('checkbox', { name: 'Face source Outer', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Create face', exact: true }).click(); await page.locator('[data-made-face="Face001"]').filter({ hasText: '100.000' }).waitFor();
|
|
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: 'Face from wires', exact: true }).evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth }));
|
|
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout)); assert.deepEqual(errors, []);
|
|
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(root, file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
}
|
|
record({ operations: ['read', 'multi-select', 'create-hole-face', 'undo', 'redo', 'OPFS-save', 'reload', 'FCStd-open', 'create-another-face'] },
|
|
{ savedBytes: bytes.length, layout, screenshots: Object.keys(result.evidenceFiles) }, 'Mounted task creates native faces and keeps them usable through history, storage and desktop/mobile geometry views');
|
|
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];
|
|
}
|