201 lines
13 KiB
JavaScript
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];
|
|
}
|