176 lines
14 KiB
JavaScript
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];
|
|
}
|