338 lines
31 KiB
JavaScript
338 lines
31 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 chamferId = 'Part:desktop:Part_Chamfer';
|
|
export const filletId = 'Part:desktop:Part_Fillet';
|
|
|
|
export async function runFilletCases(ids) {
|
|
if (!ids.includes(filletId)) return [];
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fillet-business-'));
|
|
const directory = path.join(root, 'docs/current/part-fillet-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const result = { capabilityId: filletId, status: 'failed', cases: [], evidenceFiles: {}, stages: [] };
|
|
const stage = name => { result.stages.push({ name, at: new Date().toISOString() }); process.stderr.write(`Fillet: ${name}\n`); };
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
const edges = (radius1, radius2 = radius1, edge = 1) => [{ edge, radius1, radius2 }];
|
|
const feature = (state, name = 'Fillet') => state.objects.find(item => item.name === name);
|
|
// Edge1 runs along the box height. A constant quarter-circle fillet removes
|
|
// (square - quarter disk) * height, independently of the native computation.
|
|
const expectedVolume = (radius, height = 10) => 100 * height - (1 - Math.PI / 4) * radius ** 2 * height;
|
|
const volume = (state, radius, height = 10, name = 'Fillet') => {
|
|
assert.ok(Math.abs(feature(state, name).volume - expectedVolume(radius, height)) < 1e-5, JSON.stringify(feature(state, name)));
|
|
};
|
|
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')));
|
|
stage('native-load-enter');
|
|
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? 'web/public/kernel', 'FreeCADWebDocumentProbe'));
|
|
stage('native-load-complete');
|
|
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(`fillet-${++sequence}`, payload), kernel);
|
|
} };
|
|
const document = 'FilletBusiness';
|
|
const call = async (name, action, args = {}) => {
|
|
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, { schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part',
|
|
document: name, action, builderInfo: true, ...args });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
let selection;
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_Fillet', args, document, (name, value) => { selection = { name, value }; });
|
|
for (const name of [document, 'FilletOther']) {
|
|
await call(name, 'create'); await call(name, 'setBox', { object: 'Box', dimensions: [10, 10, 10] });
|
|
}
|
|
const other = await call('FilletOther', 'snapshot');
|
|
stage('native-features-enter');
|
|
const input = { radius: 1, selection: [{ documentId: document, object: 'Box', subelements: ['Edge1'] }] };
|
|
const created = await execute(input); assert.equal(created.kind, 'result', JSON.stringify(created)); volume(created.result, 1);
|
|
const initial = feature(created.result);
|
|
assert.equal(initial.type, 'Part::Fillet'); assert.equal(initial.base, 'Box'); assert.equal(initial.topology.valid, true);
|
|
assert.deepEqual(initial.filletEdges, edges(1)); assert.ok(initial.dependencies.includes('Box'));
|
|
assert.equal(feature(created.result, 'Box').displayVisibility, false); assert.equal(initial.displayVisibility, true);
|
|
assert.deepEqual(selection, { name: document, value: [] });
|
|
assert.equal(feature(await call(document, 'undo')), undefined); volume(await call(document, 'redo'), 1);
|
|
record(input, initial, 'Selected native edge creates linked Part::Fillet with analytic volume and undo/redo');
|
|
const variable = await execute({ object: 'Variable', base: 'Box', filletEdges: edges(1, 2) });
|
|
assert.equal(variable.kind, 'result', JSON.stringify(variable));
|
|
const varied = feature(variable.result, 'Variable'); assert.equal(varied.topology.valid, true); assert.deepEqual(varied.filletEdges, edges(1, 2));
|
|
assert.ok(varied.volume < expectedVolume(1) && varied.volume > expectedVolume(2), JSON.stringify(varied));
|
|
const multiple = await execute({ object: 'Multiple', base: 'Box', filletEdges: [...edges(1), ...edges(1, 1, 3)] });
|
|
assert.equal(multiple.kind, 'result', JSON.stringify(multiple));
|
|
assert.ok(Math.abs(feature(multiple.result, 'Multiple').volume - (1000 - 2 * (1000 - expectedVolume(1)))) < 1e-5);
|
|
record({ variable: edges(1, 2), multiple: [1, 3] }, { variable: varied, multiple: feature(multiple.result, 'Multiple') },
|
|
'Variable radii change native geometry and two disjoint edges remove twice the constant-radius volume');
|
|
const moved = await call(document, 'setPosition', { object: 'Box', position: [31, -17, 9], includeMesh: true });
|
|
const bounds = feature(moved).mesh.bounds;
|
|
assert.ok(bounds[0] >= 31 - 1e-6 && bounds[1] >= -17 - 1e-6 && bounds[2] >= 9 - 1e-6); volume(moved, 1);
|
|
volume(await call(document, 'setBox', { object: 'Box', dimensions: [10, 10, 20] }), 1, 20);
|
|
volume(await call(document, 'undo'), 1); volume(await call(document, 'redo'), 1, 20);
|
|
const edited = await execute({ action: 'apply', object: 'Fillet', base: 'Box', filletEdges: edges(2), label: 'Edited fillet' });
|
|
assert.equal(edited.kind, 'result', JSON.stringify(edited)); volume(edited.result, 2, 20); assert.equal(feature(edited.result).label, 'Edited fillet');
|
|
volume(await call(document, 'undo'), 1, 20); volume(await call(document, 'redo'), 2, 20);
|
|
record({ actions: ['move-base', 'resize-base', 'edit-radius', 'undo', 'redo'] }, { bounds, edited: feature(edited.result) },
|
|
'Base placement, height and radius edits propagate through native dependencies');
|
|
const before = await call(document, 'snapshot');
|
|
for (const args of [
|
|
{ base: 'Box', filletEdges: edges(100) }, { action: 'apply', object: 'Fillet', base: 'Box', filletEdges: edges(100) },
|
|
{ base: 'Box', filletEdges: edges(1, 1, 999) }, { base: 'Box', filletEdges: edges(0) },
|
|
{ base: 'Box', filletEdges: [...edges(1), ...edges(1)] }, { base: 'Missing', filletEdges: edges(1) },
|
|
{ action: 'apply', object: 'Fillet', base: 'Fillet', filletEdges: edges(1) }, { base: 'Box', object: 'Box', filletEdges: edges(1) },
|
|
{ base: 'Box', filletEdges: edges(1), selection: [{ documentId: 'FilletOther', object: 'Box', subelements: ['Edge1'] }] },
|
|
{ base: 'Box', filletEdges: edges(1), selection: [{ documentId: document, object: 'Box', subelements: ['Edge2'] }] },
|
|
{ base: 'Box', filletEdges: edges(1), activeTask: true },
|
|
]) {
|
|
const rejected = await execute(args); assert.equal(rejected.kind, 'error', JSON.stringify(rejected));
|
|
assert.deepEqual(await call(document, 'snapshot'), before); record(args, rejected, 'Invalid fillet leaves native document and undo history unchanged');
|
|
}
|
|
assert.equal((await execute({ action: 'cancel' })).kind, 'result'); assert.deepEqual(await call(document, 'snapshot'), before);
|
|
assert.deepEqual(await call('FilletOther', 'snapshot'), other);
|
|
const saved = await call(document, 'save', { path: '/tmp/FilletBusiness.FCStd' }); assert.ok(saved.bytes.byteLength > 0);
|
|
const expected = await call(document, 'snapshot'); await call(document, 'close');
|
|
const reopened = await call(document, 'open', { path: '/tmp/FilletBusiness.FCStd', bytes: saved.bytes }); assert.deepEqual(reopened.objects, expected.objects);
|
|
const post = await execute({ action: 'apply', object: 'Fillet', base: 'Box', filletEdges: edges(0.5) });
|
|
assert.equal(post.kind, 'result', JSON.stringify(post)); volume(post.result, 0.5, 20);
|
|
record({ actions: ['cancel', 'save', 'close', 'open', 'edit-restored'] }, feature(post.result), 'Cancellation and isolation preserve state; FCStd retains editable Part::Fillet');
|
|
stage('native-features-complete'); 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 }, deviceScaleFactor: 1 });
|
|
const errors = []; page.on('pageerror', error => errors.push(String(error))); stage('browser-enter');
|
|
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=part.document`);
|
|
for (const args of [{ action: 'create' }, { action: 'setBox', object: 'Box', dimensions: [10, 10, 10] }]) {
|
|
await page.locator('#payload-editor').fill(JSON.stringify({ schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: 'FilletBrowser', ...args }));
|
|
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);
|
|
assert.equal((await page.evaluate(() => window.__freecadWorkbenchState.response)).kind, 'result');
|
|
}
|
|
const select = () => page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Part_Fillet$/ }) }).click();
|
|
const row = () => page.locator('[data-chamfer-object="Fillet"]');
|
|
const checkVolume = radius => row().filter({ hasText: expectedVolume(radius).toFixed(3) }).waitFor();
|
|
await select(); await page.getByLabel('Fillet document', { exact: true }).fill('FilletBrowser');
|
|
await page.getByRole('button', { name: 'Read fillet document', exact: true }).click(); await page.locator('[data-chamfer-object="Box"]').waitFor();
|
|
await page.getByRole('checkbox', { name: 'Fillet Edge1', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Create fillet', exact: true }).click(); await checkVolume(1);
|
|
await page.getByLabel('Edge1 radius 1', { exact: true }).fill('2');
|
|
await page.getByRole('button', { name: 'Apply fillet', exact: true }).click(); await checkVolume(2);
|
|
await page.getByRole('button', { name: 'Fillet undo', exact: true }).click(); await checkVolume(1);
|
|
await page.getByRole('button', { name: 'Fillet redo', exact: true }).click(); await checkVolume(2);
|
|
await page.getByRole('button', { name: 'Save fillet document', exact: true }).click();
|
|
await page.getByRole('link', { name: 'Open fillet in project', exact: true }).waitFor();
|
|
const stored = await page.evaluate(async () => { const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('Fillet-FilletBrowser.FCStd'))]; });
|
|
assert.ok(stored.length > 0);
|
|
const capture = async name => {
|
|
const png = await page.locator('[aria-label="Fillet geometry"] 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 data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const colors = new Set(); for (let i = 0; i < data.length; i += 4) colors.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); return colors.size;
|
|
}, png.toString('base64'));
|
|
assert.ok(colors > 8); return createHash('sha256').update(png).digest('hex');
|
|
};
|
|
const initialImage = await capture('desktop-geometry.png'); const rect = await page.locator('[aria-label="Fillet 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 + 65, rect.y + rect.height / 2 + 25, { steps: 8 }); await page.mouse.up();
|
|
assert.notEqual(await capture('rotated-geometry.png'), initialImage); await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
|
|
await page.getByRole('button', { name: 'Close fillet task', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Apply fillet', exact: true }).waitFor({ state: 'detached' });
|
|
await page.reload(); await select(); await page.getByLabel('Fillet document', { exact: true }).fill('FilletRestored');
|
|
await page.getByLabel('Fillet FCStd file', { exact: true }).setInputFiles({ name: 'FilletBrowser.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(stored) });
|
|
await checkVolume(2); await page.getByLabel('Fillet feature', { exact: true }).selectOption('Fillet');
|
|
await page.getByLabel('Edge1 radius 1', { exact: true }).fill('0.5');
|
|
await page.getByRole('button', { name: 'Apply fillet', exact: true }).click(); await checkVolume(0.5);
|
|
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: 'Fillet task', exact: true }).evaluate(element => ({ width: element.clientWidth, scroll: element.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({ actions: ['create', 'edit', 'undo', 'redo', 'OPFS-save', 'reload', 'FCStd-open', 'edit-restored'], viewports: [[1440, 900], [390, 844]] },
|
|
{ fileBytes: stored.length, layout, screenshots: Object.keys(result.evidenceFiles) }, 'Mounted Fillet controls produce measured native geometry and editable OPFS/FCStd restoration');
|
|
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];
|
|
}
|
|
|
|
export async function runChamferCases(ids) {
|
|
if (!ids.includes(chamferId)) return [];
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'chamfer-business-'));
|
|
const directory = path.join(root, 'docs/current/part-chamfer-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const result = { capabilityId: chamferId, 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(`chamfer-${++sequence}`, payload), kernel);
|
|
} };
|
|
const document = 'ChamferBusiness';
|
|
const call = async (name, action, args = {}) => {
|
|
const reply = await router.request('Part', facade.PART_DOCUMENT_COMMAND, { schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part',
|
|
document: name, action, builderInfo: true, ...args });
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_Chamfer', args, document);
|
|
const edges = (distance1, distance2 = distance1, edge = 1) => [{ edge, distance1, distance2 }];
|
|
const feature = (state, name = 'Chamfer') => state.objects.find(o => o.name === name);
|
|
const volume = (state, expected, name) => assert.ok(Math.abs(feature(state, name).volume - expected) < 1e-5, JSON.stringify(feature(state, name)));
|
|
for (const name of [document, 'ChamferOther']) {
|
|
await call(name, 'create'); await call(name, 'setBox', { object: 'Box', dimensions: [10, 10, 10] });
|
|
}
|
|
const other = await call('ChamferOther', 'snapshot');
|
|
for (const [name, values, expected] of [['Chamfer', edges(1), 995], ['Unequal', edges(1, 2), 990],
|
|
['Multiple', [...edges(1), ...edges(1, 1, 3)], 990]]) {
|
|
const input = { base: 'Box', object: name, chamferEdges: values };
|
|
const reply = await execute(input); assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
const value = feature(reply.result, name);
|
|
assert.equal(value.type, 'Part::Chamfer'); assert.equal(value.base, 'Box'); assert.equal(value.topology.valid, true);
|
|
assert.deepEqual(value.chamferEdges, values); volume(reply.result, expected, name); assert.ok(value.dependencies.includes('Box'));
|
|
assert.equal(feature(reply.result, 'Box').displayVisibility, false);
|
|
assert.equal(value.displayVisibility, true);
|
|
await call(document, 'undo'); assert.equal(feature(await call(document, 'snapshot'), name), undefined);
|
|
volume(await call(document, 'redo'), expected, name);
|
|
assert.deepEqual(await call('ChamferOther', 'snapshot'), other);
|
|
record(input, value, 'Equal, unequal and multiple edge distances produce linked native features with expected volume and undo/redo');
|
|
}
|
|
volume(await call(document, 'setPosition', { object: 'Box', position: [31, -17, 9] }), 995);
|
|
const shifted = await call(document, 'snapshot', { includeMesh: true }); const bounds = feature(shifted).mesh.bounds;
|
|
assert.ok(bounds[0] >= 31 - 1e-6 && bounds[1] >= -17 - 1e-6 && bounds[2] >= 9 - 1e-6, JSON.stringify(bounds));
|
|
const changed = await call(document, 'setBox', { object: 'Box', dimensions: [10, 10, 20] }); volume(changed, 1990);
|
|
volume(await call(document, 'undo'), 995); volume(await call(document, 'redo'), 1990);
|
|
record({ actions: ['move-base', 'edit-dimensions', 'undo', 'redo'] }, { bounds, changed: feature(changed) },
|
|
'Base placement and dimension edits propagate through native feature links');
|
|
const edited = await execute({ action: 'apply', object: 'Chamfer', base: 'Box', chamferEdges: edges(2), label: 'Edited chamfer' });
|
|
assert.equal(edited.kind, 'result', JSON.stringify(edited)); volume(edited.result, 1960); assert.equal(feature(edited.result).label, 'Edited chamfer');
|
|
volume(await call(document, 'undo'), 1990); volume(await call(document, 'redo'), 1960);
|
|
record({ action: 'apply', distance: 2 }, feature(edited.result), 'Existing chamfer parameters and label edit atomically with undo/redo');
|
|
const before = await call(document, 'snapshot');
|
|
for (const input of [
|
|
{ base: 'Box', chamferEdges: edges(100) }, { action: 'apply', object: 'Chamfer', base: 'Box', chamferEdges: edges(100) },
|
|
{ base: 'Box', chamferEdges: edges(1, 1, 999) }, { base: 'Box', chamferEdges: edges(0) },
|
|
{ base: 'Box', chamferEdges: [...edges(1), ...edges(1)] }, { base: 'Missing', chamferEdges: edges(1) },
|
|
{ action: 'apply', object: 'Chamfer', base: 'Chamfer', chamferEdges: edges(1) }, { base: 'Box', object: 'Box', chamferEdges: edges(1) },
|
|
{ base: 'Box', chamferEdges: edges(1), selection: [{ documentId: 'ChamferOther', object: 'Box', subelements: ['Edge1'] }] },
|
|
{ base: 'Box', chamferEdges: edges(2), selection: [{ documentId: document, object: 'Box', subelements: ['Edge2'] }] },
|
|
]) {
|
|
const reply = await execute(input); assert.equal(reply.kind, 'error', JSON.stringify(reply));
|
|
assert.deepEqual(await call(document, 'snapshot'), before);
|
|
record(input, reply, 'Invalid geometry, edges, distances, overwrite and cross-document requests preserve native state');
|
|
}
|
|
const cancelled = await execute({ action: 'cancel' }); assert.equal(cancelled.kind, 'result');
|
|
assert.deepEqual(await call(document, 'snapshot'), before);
|
|
record({ action: 'cancel' }, cancelled.result.objectCount, 'Closing the task makes no document mutation');
|
|
await call(document, 'exportShape', { object: 'Box', path: '/tmp/ChamferFixture.brep' });
|
|
await call(document, 'importShape', { object: 'Pending', path: '/tmp/ChamferFixture.brep', deferImportCommit: true });
|
|
const pending = await execute({ base: 'Box', chamferEdges: edges(1) }); assert.equal(pending.kind, 'error');
|
|
await call(document, 'finishImport', { commitImport: false });
|
|
assert.deepEqual(await call(document, 'snapshot'), before);
|
|
record({ operation: 'pending-native-import' }, pending, 'A pending native transaction is not committed or aborted by the chamfer command');
|
|
const child = await execute({ object: 'Child', base: 'Chamfer', chamferEdges: edges(0.1) }); assert.equal(child.kind, 'result', JSON.stringify(child));
|
|
const cycleBefore = await call(document, 'snapshot');
|
|
const cycle = await execute({ action: 'apply', object: 'Chamfer', base: 'Child', chamferEdges: edges(1) });
|
|
assert.equal(cycle.kind, 'error'); assert.deepEqual(await call(document, 'snapshot'), cycleBefore);
|
|
record({ operation: 'dependency-cycle' }, cycle, 'A dependent chamfer cannot become its own ancestor');
|
|
const bytes = await call(document, 'save', { path: '/tmp/ChamferBusiness.FCStd' }); assert.ok(bytes.bytes.byteLength > 0);
|
|
const expected = await call(document, 'snapshot'); await call(document, 'close');
|
|
const restored = await call(document, 'open', { path: '/tmp/ChamferBusiness.FCStd', bytes: bytes.bytes }); assert.deepEqual(restored.objects, expected.objects);
|
|
const post = await execute({ action: 'apply', object: 'Unequal', base: 'Box', chamferEdges: edges(0.5, 1.5) });
|
|
assert.equal(post.kind, 'result', JSON.stringify(post)); volume(post.result, 1992.5, 'Unequal');
|
|
record({ actions: ['save', 'close', 'open', 'edit-unequal'] }, feature(post.result, 'Unequal'), 'FCStd retains native feature type, base links and editable edge parameters');
|
|
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`);
|
|
const webDocument = 'ChamferBrowser';
|
|
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);
|
|
return page.evaluate(() => window.__freecadWorkbenchState.response);
|
|
};
|
|
for (const args of [{ action: 'create' }, { action: 'setBox', object: 'Box', dimensions: [10, 10, 10] }]) {
|
|
const reply = await run({ schema: facade.PART_DOCUMENT_SCHEMA, module: 'Part', document: webDocument, ...args }); assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
}
|
|
const selectCommand = () => page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Part_Chamfer$/ }) }).click();
|
|
await selectCommand(); await page.getByLabel('Chamfer document', { exact: true }).fill(webDocument);
|
|
await page.getByRole('button', { name: 'Read chamfer document', exact: true }).click(); await page.locator('[data-chamfer-object="Box"]').waitFor();
|
|
await page.getByRole('checkbox', { name: 'Chamfer Edge1', exact: true }).check();
|
|
await page.getByRole('button', { name: 'Create chamfer', exact: true }).click(); await page.locator('[data-chamfer-object="Chamfer"]').waitFor();
|
|
await page.getByLabel('Chamfer type', { exact: true }).selectOption('two'); await page.getByLabel('Edge1 distance 2', { exact: true }).fill('2');
|
|
await page.getByRole('button', { name: 'Apply chamfer', exact: true }).click();
|
|
await page.locator('[data-chamfer-object="Chamfer"]').filter({ hasText: '990.000' }).waitFor();
|
|
await page.getByRole('button', { name: 'Chamfer undo', exact: true }).click(); await page.locator('[data-chamfer-object="Chamfer"]').filter({ hasText: '995.000' }).waitFor();
|
|
await page.getByRole('button', { name: 'Chamfer redo', exact: true }).click(); await page.locator('[data-chamfer-object="Chamfer"]').filter({ hasText: '990.000' }).waitFor();
|
|
await page.getByRole('button', { name: 'Save chamfer document', exact: true }).click(); await page.getByRole('link', { name: 'Open chamfer in project', exact: true }).waitFor();
|
|
const stored = await page.evaluate(async () => { const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('Chamfer-ChamferBrowser.FCStd'))]; });
|
|
const capture = async name => {
|
|
const canvas = page.locator('[aria-label="Chamfer 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 data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const colors = new Set(); for (let i = 0; i < data.length; i += 4) colors.add(`${data[i]},${data[i + 1]},${data[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="Chamfer 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 + 65, rect.y + rect.height / 2 + 25, { 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.getByRole('button', { name: 'Close chamfer task', exact: true }).click(); await page.getByRole('button', { name: 'Apply chamfer', exact: true }).waitFor({ state: 'detached' });
|
|
await page.reload(); await selectCommand(); await page.getByLabel('Chamfer document', { exact: true }).fill('ChamferRestored');
|
|
await page.getByLabel('Chamfer FCStd file', { exact: true }).setInputFiles({ name: 'ChamferBrowser.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(stored) });
|
|
await page.locator('[data-chamfer-object="Chamfer"]').filter({ hasText: '990.000' }).waitFor();
|
|
await page.getByLabel('Chamfer feature', { exact: true }).selectOption('Chamfer'); await page.getByLabel('Edge1 distance 1', { exact: true }).fill('0.5');
|
|
await page.getByRole('button', { name: 'Apply chamfer', exact: true }).click(); await page.locator('[data-chamfer-object="Chamfer"]').filter({ hasText: '995.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: 'Chamfer task', 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({ actions: ['create', 'edit', 'undo', 'redo', 'OPFS-save', 'close-task', 'reload', 'FCStd-open', 'edit-restored'], viewports: [[1440, 900], [390, 844]] },
|
|
{ fileBytes: stored.length, layout, screenshots: Object.keys(result.evidenceFiles) }, 'Mounted task and native geometry remain editable across OPFS, reload and FCStd reopening');
|
|
result.status = 'passed';
|
|
result.resolvedIssueIds = ['runtime:42876ac8d8754c52b449'];
|
|
result.resolvedFailureKeys = ['99e2862ad207c42767fd'];
|
|
} 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];
|
|
}
|