Files
FreeCADWasmDev/tools/web-task-interaction-business-cases.mjs

201 lines
13 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { playwright } from './part-primitive-business-cases.mjs';
export const taskInteractionId = 'product:desktop-task-interaction-parity';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export async function runTaskInteractionCases(ids) {
if (!ids.includes(taskInteractionId)) return [];
// These are incremental Part cases. Full Sketcher/PartDesign task interaction
// proof is still required; do not claim a complete proof area from this subset.
const result = { capabilityId: taskInteractionId, status: 'failed', cases: [], evidenceFiles: {} };
const directory = path.join(root, 'docs/current/web-task-interaction-business', new Date().toISOString().replace(/[:.]/g, '-'));
fs.mkdirSync(directory, { recursive: true });
const artifact = (name, value) => {
const file = path.join(directory, name);
fs.writeFileSync(file, JSON.stringify(value, null, 2));
result.evidenceFiles[path.relative(root, file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
};
const record = (name, actual) => result.cases.push({ input: { route: '/', workbench: 'Part' }, actual, assertions: [{ name, passed: true }] });
let browser, server, page;
try {
let origin = process.env.FREECAD_BROWSER_ORIGIN;
if (!origin) {
server = spawn(path.join(root, 'web/node_modules/.bin/vite'), ['preview', '--host', '127.0.0.1', '--port', '0'], { cwd: path.join(root, 'web'), stdio: ['ignore', 'pipe', 'pipe'] });
origin = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('Task preview startup timeout')), 20000);
let output = '';
server.on('error', error => { clearTimeout(timer); reject(error); });
server.stdout.on('data', chunk => { output += chunk; const match = output.match(/http:\/\/127\.0\.0\.1:\d+/); if (match) { clearTimeout(timer); resolve(match[0]); } });
});
}
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-dev-shm-usage'] });
page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(45000);
await page.addInitScript(() => {
window.taskNativeMessages = [];
const NativeWorker = window.Worker;
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args);
this.addEventListener('message', event => window.taskNativeMessages.push(event.data));
}
};
});
await page.goto(origin, { waitUntil: 'domcontentloaded' });
const tool = name => page.getByRole('toolbar', { name: 'Part workbench toolbar' }).getByRole('button', { name, exact: true });
const messages = () => page.evaluate(() => window.taskNativeMessages);
const lastDocument = async () => (await messages()).filter(value => value.kind === 'result' && Array.isArray(value.result?.objects)).at(-1).result;
const nativeAction = async action => {
const before = (await messages()).length;
await action();
await page.waitForFunction(count => window.taskNativeMessages.slice(count).some(value => value.kind === 'result' || value.kind === 'error'), before);
await page.waitForFunction(() => !document.querySelector('[aria-label="Part workbench toolbar"] button')?.disabled);
};
await tool('Primitive').click();
await nativeAction(() => page.locator('[data-create-primitive]').click());
const initial = await lastDocument();
const cylinder = initial.objects.find(object => object.name === 'Cylinder1');
assert.equal(cylinder.type, 'Part::Cylinder');
assert.ok(Math.abs(cylinder.volume - Math.PI * 25 * 10) < 1e-5);
assert.equal(await page.locator('[role="treeitem"][aria-selected="true"]').getAttribute('data-object-id'), 'native:Cylinder1');
const beforeCancel = (await messages()).length;
await page.getByLabel('Primitive Radius', { exact: true }).fill('8');
await page.getByRole('button', { name: 'Close task / Cancel', exact: true }).click();
await tool('Primitive').click();
assert.equal(await page.getByLabel('Primitive Radius', { exact: true }).inputValue(), '5');
assert.equal((await messages()).length, beforeCancel);
assert.deepEqual(await lastDocument(), initial);
record('Cancelling a primitive draft restores committed native parameters without changing geometry or history', { volume: cylinder.volume, undo: initial.undoCount, redo: initial.redoCount });
await page.getByLabel('Primitive Radius', { exact: true }).fill('9');
await page.keyboard.press('Escape');
await tool('Primitive').click();
assert.equal(await page.getByLabel('Primitive Radius', { exact: true }).inputValue(), '5');
await page.getByLabel('Primitive Radius', { exact: true }).fill('11');
await tool('Position').click();
await tool('Primitive').click();
assert.equal(await page.getByLabel('Primitive Radius', { exact: true }).inputValue(), '5');
assert.equal((await messages()).length, beforeCancel);
record('Escape and switching tasks discard primitive drafts', { restoredRadius: 5 });
await page.getByLabel('Primitive Radius', { exact: true }).fill('-1');
await nativeAction(() => page.locator('[data-update-primitive]').click());
assert.equal((await messages()).at(-1).kind, 'error');
assert.deepEqual(await lastDocument(), initial);
assert.equal(await page.getByLabel('Primitive Radius', { exact: true }).inputValue(), '-1');
await page.getByLabel('Primitive Radius', { exact: true }).fill('6');
await nativeAction(() => page.locator('[data-update-primitive]').click());
const edited = await lastDocument();
assert.ok(Math.abs(edited.objects.find(object => object.name === 'Cylinder1').volume - Math.PI * 36 * 10) < 1e-5);
assert.equal(edited.undoCount, initial.undoCount + 1);
record('Rejected parameters preserve native state; corrected parameters commit one native edit', { initialUndo: initial.undoCount, editedUndo: edited.undoCount });
await page.getByText('Edit', { exact: true }).click();
await nativeAction(() => page.getByRole('button', { name: 'Undo', exact: true }).click());
const undone = await lastDocument();
assert.ok(Math.abs(undone.objects.find(object => object.name === 'Cylinder1').volume - cylinder.volume) < 1e-5);
await page.getByText('Edit', { exact: true }).click();
await page.getByLabel('Primitive Radius', { exact: true }).fill('12');
await page.keyboard.press('Escape');
await page.getByText('Edit', { exact: true }).click();
await nativeAction(() => page.getByRole('button', { name: 'Redo', exact: true }).click());
const redone = await lastDocument();
assert.equal(redone.undoCount, edited.undoCount);
assert.equal(redone.redoCount, edited.redoCount);
assert.ok(Math.abs(redone.objects.find(object => object.name === 'Cylinder1').volume - Math.PI * 36 * 10) < 1e-5);
await page.getByText('Edit', { exact: true }).click();
record('Cancelling a draft preserves the native redo branch', { undone: undone.undoCount, redone: redone.undoCount });
await nativeAction(() => tool('创建长方体').click());
await tool('Boolean').click();
const base = await page.getByLabel('Boolean Base', { exact: true }).inputValue();
const operand = await page.getByLabel('Boolean Tool', { exact: true }).inputValue();
const beforeBoolean = await lastDocument();
const count = (await messages()).length;
await page.getByRole('button', { name: '交换运算对象', exact: true }).click();
await page.locator('[data-boolean="Cut"]').click();
await page.getByRole('button', { name: 'Close task / Cancel', exact: true }).click();
await tool('Boolean').click();
assert.equal(await page.getByLabel('Boolean Base', { exact: true }).inputValue(), base);
assert.equal(await page.getByLabel('Boolean Tool', { exact: true }).inputValue(), operand);
assert.equal(await page.locator('[data-boolean="Fuse"]').getAttribute('aria-pressed'), 'true');
assert.equal((await messages()).length, count);
assert.deepEqual(await lastDocument(), beforeBoolean);
record('Cancelled Boolean operation and operands reset without adding a native transaction', { base, operand, undo: beforeBoolean.undoCount });
// Continue the same primary UI contract through the delivered native
// Sketcher/PartDesign owner, without a console or diagnostic-page fixture.
await page.getByText('File', {exact:true}).click();
await page.getByRole('button',{name:'新建 Document',exact:true}).click();
await page.getByText('File', {exact:true}).click();
const workbench=page.getByRole('combobox',{name:'Workbench',exact:true});
await workbench.selectOption('Sketcher');
const designAction=async action=>{
const before=(await messages()).length;
await action();
await page.waitForFunction(count=>window.taskNativeMessages.slice(count).some(m=>m.kind==='result'||m.kind==='error'),before);
const reply=(await messages()).slice(before).filter(m=>m.kind==='result'||m.kind==='error').at(-1);
return reply;
};
assert.equal((await designAction(()=>page.getByRole('button',{name:'Create body',exact:true}).click())).kind,'result');
await page.getByLabel('Sketch width',{exact:true}).fill('12');
await page.getByLabel('Sketch height',{exact:true}).fill('8');
assert.equal((await designAction(()=>page.getByRole('button',{name:'Accept sketch',exact:true}).click())).kind,'result');
await workbench.selectOption('PartDesign');
await page.getByLabel('Pad length',{exact:true}).fill('5');
let response=await designAction(()=>page.getByRole('button',{name:'Accept Pad',exact:true}).click());
assert.equal(response.kind,'result');
const initialDesign=response.result;
const pad=initialDesign.objects.find(o=>o.type==='PartDesign::Pad'),sketch=initialDesign.objects.find(o=>o.type==='Sketcher::SketchObject');
assert.ok(Math.abs(pad.volume-480)<1e-7);assert.equal(pad.profile,sketch.name);
await page.locator(`[role="treeitem"][data-object-id="native:${pad.name}"]`).click();
await page.getByLabel('Pad length',{exact:true}).fill('11');
const cancelMessages=(await messages()).length;
await page.getByRole('button',{name:'Cancel draft',exact:true}).click();
assert.equal(await page.getByLabel('Pad length',{exact:true}).inputValue(),'5');
assert.equal((await messages()).length,cancelMessages);
await workbench.selectOption('Sketcher');
await page.locator(`[role="treeitem"][data-object-id="native:${sketch.name}"]`).click();
await page.getByLabel('Sketch width',{exact:true}).fill('19');
await page.getByRole('button',{name:'Cancel draft',exact:true}).click();
assert.equal(await page.getByLabel('Sketch width',{exact:true}).inputValue(),'12');
await page.getByLabel('Sketch width',{exact:true}).fill('-1');
response=await designAction(()=>page.getByRole('button',{name:'Update sketch',exact:true}).click());
assert.equal(response.kind,'error');
await page.getByLabel('Sketch width',{exact:true}).fill('14');
response=await designAction(()=>page.getByRole('button',{name:'Update sketch',exact:true}).click());
assert.equal(response.kind,'result');
assert.ok(Math.abs(response.result.objects.find(o=>o.name===pad.name).volume-560)<1e-7);
response=await designAction(()=>page.getByRole('button',{name:'撤销',exact:true}).click());
assert.equal(response.kind,'result');assert.ok(Math.abs(response.result.objects.find(o=>o.name===pad.name).volume-480)<1e-7);
response=await designAction(()=>page.getByRole('button',{name:'重做',exact:true}).click());
assert.equal(response.kind,'result');assert.ok(Math.abs(response.result.objects.find(o=>o.name===pad.name).volume-560)<1e-7);
await workbench.selectOption('PartDesign');
const canvas=page.locator('[aria-label="Imported native 3D view"] canvas');
await canvas.scrollIntoViewIfNeeded();await canvas.click({position:{x:(await canvas.boundingBox()).width/2,y:(await canvas.boundingBox()).height/2}});
await page.waitForFunction(()=>!!document.querySelector('[role="treeitem"][aria-selected="true"]'));
const selected=await page.locator('[role="treeitem"][aria-selected="true"]').getAttribute('data-object-id');
assert.ok(initialDesign.objects.some(o=>'native:'+o.name===selected));
result.cases.push({input:{route:'/',workbenches:['Part','Sketcher','PartDesign'],drafts:[11,19],invalidWidth:-1,acceptedWidth:14},
actual:{initialDesign,finalDocument:response.result,selected},
covers:['context_selection','edit_accept_cancel','undo_redo','validation_recovery','primary_workspace_controls'],
assertions:[{name:'Primary tree/viewport selection, native sketch/Pad acceptance, cancelled drafts, invalid recovery and undo/redo preserve the linked current document',passed:true}]});
result.status='passed';
} catch (error) {
result.error = String(error.stack ?? error);
} finally {
if (page) artifact('browser-native-state.json', { body: await page.locator('body').innerText().catch(() => ''), messages: await page.evaluate(() => window.taskNativeMessages).catch(() => []), cases: result.cases, error: result.error });
await browser?.close();
server?.kill('SIGTERM');
}
artifact('result.json', { ...result, evidenceFiles: undefined });
return [result];
}