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

176 lines
14 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 { pathToFileURL } from 'node:url';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const faceColorId = 'Part:desktop:Part_ColorPerFace';
export async function runFaceColorCases(ids) {
if (!ids.includes(faceColorId)) return [];
const result = { capabilityId: faceColorId, status: 'failed', cases: [], evidenceFiles: {}, stages: [] };
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-face-colors-'));
const directory = path.resolve('docs/current/part-face-color-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const stage = name => { result.stages.push({ name, time: Date.now() }); process.stderr.write(`Face colors: ${name}\n`); };
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const palette = [[1,0,0,1], [0,1,0,1], [0,0,1,1], [1,1,0,1], [1,0,1,1], [0,1,1,1]];
const check = state => {
const box = state.objects.find(item => item.name === 'Box');
assert.deepEqual(box.faceColors, palette);
assert.ok(Math.abs(box.volume - 24) < 1e-9); assert.equal(box.topology.Face, 6); assert.equal(box.topology.valid, true);
assert.equal(box.mesh.colors.length, box.mesh.positions.length);
const colors = new Set();
for (let i = 0; i < box.mesh.colors.length; i += 3) colors.add(box.mesh.colors.slice(i, i + 3).join(','));
assert.deepEqual([...colors].sort(), palette.map(color => color.slice(0, 3).join(',')).sort());
return box;
};
let kernel, browser;
try {
stage('compile-enter');
execFileSync('web/node_modules/.bin/tsc', ['--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler',
'--jsx', 'react-jsx', '--strict', '--skipLibCheck', '--outDir', temporary, 'web/node_modules/vite/client.d.ts',
'web/src/part-desktop-adapter.ts', 'web/src/part-face-color-task.tsx'], { stdio: 'pipe' });
stage('compile-complete');
const adapter = await import(pathToFileURL(path.join(temporary, 'part-desktop-adapter.js')));
const facade = await import(pathToFileURL(path.join(temporary, 'part-document-facade.js')));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(temporary, 'facade-envelope.js')));
stage('native-load-enter');
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebDocumentProbe'));
stage('native-load-complete');
const queue = new FacadeSerialQueue(); let sequence = 0;
const router = { request: (_module, endpoint, payload) => {
assert.equal(endpoint, facade.PART_DOCUMENT_COMMAND);
return facade.dispatchPartDocument(queue, facade.createPartDocumentRequest(`face-color-${++sequence}`, payload), kernel);
} };
const document = 'FaceColorsBusiness';
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 selection = [{ documentId: document, object: 'Box', subelements: [] }];
const execute = args => adapter.executePartFixedBoxDesktop(router, 'Part_ColorPerFace', args, document);
await call('create'); await call('setBox', { object: 'Box', dimensions: [2, 3, 4] });
await call('setBox', { object: 'Other', dimensions: [1, 1, 1] });
await call('setPosition', { object: 'Other', position: [5, 0, 0] });
await call('create', {}, 'FaceColorsOther'); await call('setBox', { object: 'Box', dimensions: [7, 8, 9] }, 'FaceColorsOther');
const other = await call('snapshot', {}, 'FaceColorsOther');
const baseline = await call('snapshot');
const fixture = await call('save', { path: '/tmp/FaceColorsFixture.FCStd' });
stage('native-apply-enter');
const applied = await execute({ action: 'accept', selection, faceColors: palette });
assert.equal(applied.kind, 'result', JSON.stringify(applied)); check(applied.result);
assert.equal(applied.result.undoCount, baseline.undoCount + 1);
assert.deepEqual(applied.result.objects.find(item => item.name === 'Other'), baseline.objects.find(item => item.name === 'Other'));
assert.deepEqual((await call('snapshot', {}, 'FaceColorsOther')).objects, other.objects);
record({ selection, faceColors: palette }, { volume: 24, faces: 6, colors: palette },
'One selected native Part feature receives six distinct face colors and matching mesh colors without changing geometry, other objects or other documents');
for (const args of [{ selection: [] }, { selection: [...selection, { documentId: document, object: 'Other' }] },
{ selection: [{ documentId: 'FaceColorsOther', object: 'Box' }] }, { activeTask: true },
{ faceColors: [[-1,0,0,1]] }, { faceColors: [[1,0,0]] }, { faceColors: [[NaN,0,0,1]] },
{ faceColors: palette.slice(1) }, { selection: [{ documentId: document, object: 'Missing' }] }]) {
const reply = await execute({ action: 'accept', selection, faceColors: palette, ...args });
assert.equal(reply.kind, 'error', JSON.stringify(args));
}
const raw = args => JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({
cmd: facade.PART_DOCUMENT_COMMAND, args: { document, action: 'setFaceColors', selection, faceColors: palette, ...args },
})]));
for (const args of [{ selection: [] }, { faceColors: palette.slice(1) }, { faceColors: [[2,0,0,1], ...palette.slice(1)] },
{ selection: [{ documentId: 'FaceColorsOther', object: 'Box' }] }]) assert.equal(raw(args).ok, false);
const cancelled = await execute({ action: 'cancel', selection });
assert.equal(cancelled.kind, 'result'); assert.equal(cancelled.result.documentChanged, false);
const afterInvalid = await call('snapshot'); check(afterInvalid);
assert.equal(afterInvalid.undoCount, applied.result.undoCount);
assert.deepEqual((await call('undo')).objects, baseline.objects);
check(await call('redo'));
const saved = await call('save', { path: '/tmp/FaceColorsBusiness.FCStd' });
await call('close'); check(await call('open', { path: '/tmp/FaceColorsBusiness.FCStd', bytes: saved.bytes }));
record({ actions: ['invalid-adapter', 'invalid-native', 'cancel', 'undo', 'redo', 'save', 'open'] }, { bytes: saved.bytes.byteLength },
'Invalid inputs and cancellation preserve the transaction history; undo, redo and native FCStd reopening restore exact per-face colors');
stage('native-apply-complete'); kernel.PThread?.terminateAllThreads(); kernel = null;
stage('browser-enter');
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: 1280, height: 900 } });
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_ColorPerFace&document=${document}`);
await page.getByLabel('Face colors FCStd file', { exact: true }).setInputFiles({ name: 'Fixture.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(fixture.bytes) });
await page.waitForFunction(() => [...document.querySelectorAll('select[aria-label="Face colors shape"] option')].some(option => option.value === 'Box'));
await page.getByLabel('Face colors shape', { exact: true }).selectOption('Box');
const hexes = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'];
for (const [index, value] of hexes.entries()) await page.getByLabel(`Face${index + 1} color`, { exact: true }).fill(value);
await page.getByRole('button', { name: 'Apply colors', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.objects?.find(item => item.name === 'Box')?.faceColors?.[0]?.[0] === 1);
check(await page.evaluate(() => window.__freecadWorkbenchState.response.result));
const capture = async name => {
const canvas = page.locator('[aria-label="Face colors geometry"] canvas');
const file = path.join(directory, name), png = await canvas.screenshot({ path: file });
result.evidenceFiles[path.relative('.', file)] = createHash('sha256').update(png).digest('hex');
const pixels = await page.evaluate(async encoded => {
const img = new Image(); img.src = `data:image/png;base64,${encoded}`; await img.decode();
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); const rgba = ctx.getImageData(0, 0, c.width, c.height).data;
let colored = 0; const unique = new Set();
for (let i = 0; i < rgba.length; i += 4) {
const rgb = [...rgba.slice(i, i + 3)]; unique.add(rgb.join(','));
if (Math.max(...rgb) - Math.min(...rgb) > 60) colored++;
}
return { colored, unique: unique.size };
}, png.toString('base64'));
assert.ok(pixels.colored > 100 && pixels.unique > 8, JSON.stringify(pixels)); return png;
};
const initial = await capture('desktop.png');
const rect = await page.locator('[aria-label="Face colors 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 + 60, rect.y + rect.height / 2 + 30, { steps: 8 }); await page.mouse.up();
assert.notDeepEqual(await capture('rotated.png'), initial);
await page.setViewportSize({ width: 390, height: 844 }); await capture('mobile.png');
const task = page.getByRole('region', { name: 'Face colors task', exact: true });
assert.ok(await task.evaluate(node => node.scrollWidth <= node.clientWidth + 1));
await task.screenshot({ path: path.join(directory, 'mobile-task.png') });
await page.getByRole('button', { name: 'Face colors undo', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Face1 color"]').value === '#cccccc');
await page.getByRole('button', { name: 'Face colors redo', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Face1 color"]').value === '#ff0000');
await page.getByLabel('Face1 color', { exact: true }).fill('#123456');
await page.getByRole('button', { name: 'Cancel face colors', exact: true }).click();
await page.getByLabel('Face1 color', { exact: true }).waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Read face colors document', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Face1 color"]')?.value === '#ff0000');
await page.getByRole('button', { name: 'Save face colors document', exact: true }).click();
await task.getByRole('status').waitFor();
const bytes = await page.evaluate(async () => {
const { readDocumentFile } = await import('/src/storage.ts'); return [...new Uint8Array(await readDocumentFile('FaceColors-FaceColorsBusiness.FCStd'))];
});
assert.ok(bytes.length > 1000);
await page.reload();
await page.getByLabel('Face colors FCStd file', { exact: true }).setInputFiles({ name: 'Restored.FCStd', mimeType: 'application/octet-stream', buffer: Buffer.from(bytes) });
await page.waitForFunction(() => [...document.querySelectorAll('select[aria-label="Face colors shape"] option')].some(option => option.value === 'Box'));
await page.getByLabel('Face colors shape', { exact: true }).selectOption('Box');
for (const [index, value] of hexes.entries()) assert.equal(await page.getByLabel(`Face${index + 1} color`, { exact: true }).inputValue(), value);
await page.getByRole('button', { name: 'Read face colors document', exact: true }).click();
await page.waitForFunction(() => window.__freecadWorkbenchState?.response?.result?.objects?.find(item => item.name === 'Box')?.faceColors?.length === 6);
check(await page.evaluate(() => window.__freecadWorkbenchState.response.result));
assert.deepEqual(errors, []);
record({ controls: ['shape', 'face-swatches', 'apply', 'undo', 'redo', 'cancel', 'save', 'reload', 'open'], viewports: [[1280,900],[390,844]] },
{ restoredBytes: bytes.length, screenshots: ['desktop.png', 'rotated.png', 'mobile.png'] },
'Mounted Part command applies native face colors, renders colored interactive geometry at both viewports, cancels drafts and restores colors through OPFS and FCStd');
stage('browser-complete');
for (const file of ['tools/part-face-color-business-cases.mjs', 'web/src/part-face-color-task.tsx', 'web/src/part-desktop-adapter.ts',
'web/src/workbench-shell.tsx', 'web/src/part-document-facade.ts', 'freecad-source/src/WebAssembly/FreeCADCoreProbe/DocumentProbe.cpp',
...['js','wasm','worker.js'].map(extension => `web/public/kernel/FreeCADWebDocumentProbe.${extension}`)])
result.evidenceFiles[file] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
result.status = 'passed';
} catch (error) { result.error = [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n'); }
finally {
stage('cleanup-enter'); 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];
}