Files
FreeCADWasmDev/tools/partdesign-face-color-business-cases.mjs

113 lines
9.8 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const faceColorId = 'PartDesign:desktop:Part_ColorPerFace';
export async function runFaceColorCases(loadBusinessWorker) {
const result = { capabilityId: faceColorId, status: 'failed', cases: [], timing: [], evidenceFiles: {} };
let browser, kernel, native;
const document = 'FaceColorBusiness';
const plain = value => JSON.parse(JSON.stringify(value));
const object = state => state.objects.find(item => item.name === 'Box');
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
try {
let start = performance.now();
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebPartDesignRuntimeProbe'));
result.timing.push({ stage: 'load', seconds: (performance.now() - start) / 1000 });
const worker = loadBusinessWorker(kernel); let sequence = 0;
const router = { request: (_module, command, payload, documentId) => worker.send(worker.request(`face-color-${++sequence}`, command, payload, documentId)) };
native = async (action, args = {}) => {
const start = performance.now();
const reply = await router.request('PartDesign', 'partdesign.document', { schema: 'freecad-web-partdesign/document/v1', module: 'PartDesign', document, action, includeMesh: true, ...args }, document);
result.timing.push({ stage: action, seconds: (performance.now() - start) / 1000, kind: reply.kind });
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return plain(reply.result);
};
await native('create'); await native('createBody', { object: 'Body' });
const before = await native('setPrimitive', { object: 'Box', body: 'Body', primitiveType: 'AdditiveBox', parameters: { Length: 4, Width: 5, Height: 6 } });
let selected;
const input = { document, selection: [{ documentId: document, object: 'Box' }], faces: ['Face1', 'Face3'], color: [1, 0, 0] };
const desktop = args => worker.desktop(router, 'Part_ColorPerFace', { ...input, ...args }, document, (_doc, values) => { selected = plain(values); });
const applied = await desktop({}); assert.equal(applied.kind, 'result', JSON.stringify(applied));
const state = plain(applied.result), feature = object(state);
assert.equal(feature.type, 'PartDesign::AdditiveBox'); assert.equal(feature.volume, 120);
assert.deepEqual(feature.faceColors[0], [1, 0, 0, 1]); assert.deepEqual(feature.faceColors[2], [1, 0, 0, 1]);
assert.equal(feature.faceColors.length, 6); assert.ok(feature.faceColors[1][1] > 0.79);
assert.equal(feature.mesh.colors.length, feature.mesh.positions.length);
assert.equal(feature.mesh.colors.filter((value, index) => index % 3 === 0 && value === 1).length, 12);
assert.deepEqual(feature.mesh.bounds, object(before).mesh.bounds);
assert.equal(state.objects.find(item => item.name === 'Body').tip, 'Box');
assert.deepEqual(selected, [{ documentId: document, object: 'Box' }]);
record(input, { colors: feature.faceColors, volume: feature.volume, mesh: { bounds: feature.mesh.bounds, triangleCount: feature.mesh.triangleCount }, selected }, 'selected native faces receive RGB values and matching vertex colors while geometry, Body tip and selection are preserved');
for (const invalid of [{ selection: [] }, { selection: [{ documentId: 'Other', object: 'Box' }] }, { activeTask: true }, { faces: ['Edge1'] }, { faces: ['Face99'] }, { color: [2, 0, 0] }, { object: 'Other' }]) {
const reply = await desktop(invalid); assert.equal(reply.kind, 'error');
assert.deepEqual(await native('snapshot'), state);
record(invalid, { kind: reply.kind, unchanged: true }, 'invalid selection, task state, face or color is rejected without document or history mutation');
}
const cancelled = await desktop({ operation: 'cancel', color: [0, 1, 0] });
assert.deepEqual(plain(cancelled.result), state);
await native('undo'); const undone = await native('snapshot'); assert.equal(object(undone).faceColors, undefined);
const redone = await native('redo'); assert.deepEqual(object(redone).faceColors, feature.faceColors);
record({ actions: ['cancel', 'undo', 'redo'] }, { undoneColors: object(undone).faceColors ?? null, redoneColors: object(redone).faceColors }, 'cancel is read-only and native undo/redo restores per-face appearance');
await native('save', { path: '/tmp/FaceColorBusiness.FCStd' }); await native('close');
const reopened = await native('open', { path: '/tmp/FaceColorBusiness.FCStd' });
assert.deepEqual(object(reopened).faceColors, feature.faceColors); assert.equal(object(reopened).volume, 120);
const edited = await desktop({ faces: ['Face2'], color: [0, 1, 0] }); assert.equal(edited.kind, 'result');
assert.deepEqual(plain(object(edited.result).faceColors[1]), [0, 1, 0, 1]);
record({ actions: ['save', 'close', 'open', 'edit'] }, { colors: object(edited.result).faceColors }, 'FCStd preserves native face colors and remains editable after reopening');
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: 1000 } }); page.setDefaultTimeout(20000);
const errors = []; page.on('pageerror', error => errors.push(String(error)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://localhost:5180'}/workbench.html?module=PartDesign&document=BrowserFaceColors`);
const run = async (action, args = {}) => {
await page.locator('button.command').filter({ hasText: 'partdesign.document' }).first().click();
await page.locator('#payload-editor').fill(JSON.stringify({ schema: 'freecad-web-partdesign/document/v1', module: 'PartDesign', document: 'BrowserFaceColors', documentId: 'BrowserFaceColors', action, includeMesh: true, ...args }));
await page.getByRole('button', { name: '执行选中命令', exact: true }).click();
await page.waitForFunction(() => { try { return ['result', 'error'].includes(JSON.parse(document.querySelector('.result')?.textContent).kind); } catch { return false; } });
const reply = JSON.parse(await page.locator('.result').textContent()); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
await run('create'); await run('createBody', { object: 'Body' });
await run('setPrimitive', { object: 'Box', body: 'Body', primitiveType: 'AdditiveBox', parameters: { Length: 4, Width: 5, Height: 6 } });
await page.locator('button.command').filter({ hasText: 'Part_ColorPerFace' }).filter({ hasText: 'desktop-gui' }).click();
const task = page.getByRole('region', { name: 'Face colors', exact: true });
await task.getByRole('combobox', { name: 'Face color shape' }).selectOption('Box');
await task.getByRole('checkbox', { name: 'Face1', exact: true }).check();
await task.getByRole('checkbox', { name: 'Face3', exact: true }).check();
await task.getByLabel('Face color', { exact: true }).fill('#ff0000');
await task.getByRole('button', { name: 'Apply face color', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[data-face-swatch="Face1"]')?.getAttribute('data-color') === '1,0,0');
assert.equal(await task.locator('[data-face-swatch="Face3"]').getAttribute('data-color'), '1,0,0');
const directory = 'docs/current/partdesign-face-color-browser'; fs.mkdirSync(directory, { recursive: true });
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport); const canvas = task.locator('canvas'); await canvas.scrollIntoViewIfNeeded();
const png = await canvas.screenshot();
const pixels = await page.evaluate(async url => {
const image = new Image(); image.src = url; await image.decode(); const canvas = document.createElement('canvas'); canvas.width = canvas.height = 128;
const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, 128, 128); const data = ctx.getImageData(0, 0, 128, 128).data;
let red = 0; for (let i = 0; i < data.length; i += 4) if (data[i] > data[i + 1] + 40 && data[i] > data[i + 2] + 40) red++;
return red;
}, `data:image/png;base64,${png.toString('base64')}`);
assert.ok(pixels > 30, `Expected red native faces, got ${pixels} red pixels`);
assert.ok(await task.evaluate(element => element.scrollWidth <= element.clientWidth + 1));
const file = path.join(directory, `${viewport.width}.png`); await task.screenshot({ path: file });
result.evidenceFiles[file] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const box = await 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 + 60, box.y + box.height / 2 + 30, { steps: 8 }); await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), png);
record({ route: '/workbench.html', viewport }, { redPixels: pixels, screenshot: file }, 'mounted desktop task applies native face colors and displays a nonblank colored interactive canvas without task overflow');
}
assert.deepEqual(errors, []); result.status = 'passed';
} catch (error) {
result.error = String(error.stack ?? error);
result.cases.push({ actual: { error: result.error }, assertions: [{ name: 'native and browser face-color workflow completes', passed: false }] });
} finally {
await browser?.close();
try { await native?.('close'); } catch {}
try { kernel?.FS.unlink('/tmp/FaceColorBusiness.FCStd'); } catch {}
}
return result;
}