Files
workinf_Blender_Wasm/web/tests/e2e/mesh-simplify-production.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

352 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { decodeLODGeometry } from '../../protocol/mesh-cache';
import type { LODCacheRecord } from '../../protocol/lod';
import type { SimplifyResult } from '../../protocol/simplify';
const root = path.resolve(import.meta.dirname, '../../..');
const baseline = path.join(root, 'tests/golden/WBV2-P1424-001/mesh-simplify-20261020');
const lodBaseline = path.join(root, 'tests/golden/WBV2-P1424-001/lod-current-main-20261021');
const targetBaseline = path.join(root, 'tests/golden/WBV2-P1424-001/simplify-target-report-20261021');
const modeBaseline = path.join(root, 'tests/golden/WBV2-P1424-001/mesh-modes-glb-20261021');
const evidence = process.env.WBV2_EVIDENCE_DIR ?? targetBaseline;
const candidate = process.env.WBV2_PRODUCTION_CANDIDATE ?? path.join(root, 'web/dist');
const sha256 = (file: string) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
type Snapshot = { revision: number; meshes: Array<{ id: string; name: string; triangleCount: number; vertexCount: number; attributes: { name: string; dataType: string; domain: string }[] }>; nodes: unknown[] };
type LODObservation = { manifest: { meshId: string; sourceMeshRevision: number }; levels: { triangleBudget: number; outputTriangleCount: number; outputVertexCount: number; simplify?: { sourceMeshRevision: number } }[] };
type Wire = { requestId: string; ok: boolean; result?: { snapshot?: Snapshot; simplify?: SimplifyResult; lod?: LODObservation; projectId?: string; cacheKey?: string; bytes?: number; dataBase64?: string; manifest?: LODCacheRecord; manifests?: LODCacheRecord[]; remainingBytes?: number }; error?: unknown; errorCode?: string };
type Record = { started: number; chrome: string; failures: string[]; errors: string[]; modules: { url: string; sha256: string }[]; wire: Wire[]; fixture?: { path: string; sha256: string }; isolated?: boolean };
const records = new WeakMap<Page, Record>();
test.beforeEach(async ({ page, browser }) => {
const record: Record = { started: Date.now(), chrome: browser.version(), failures: [], errors: [], modules: [], wire: [] }; records.set(page, record);
page.on('requestfailed', request => record.failures.push(`${request.url()}: ${request.failure()?.errorText}`));
page.on('request', request => { if (new URL(request.url()).pathname.startsWith('/src/')) record.failures.push(`NON_PRODUCTION_SOURCE ${request.url()}`); });
page.on('console', message => { if (message.type() === 'error') record.errors.push(message.text()); }); page.on('pageerror', error => record.errors.push(error.message));
page.on('response', response => { const url = new URL(response.url()); if (response.status() >= 400) record.failures.push(`${response.status()} ${url.pathname}`); if (/\.wasm$|wasm-module-manifest\.json$/.test(url.pathname)) { const file = path.join(candidate, decodeURIComponent(url.pathname)); if (fs.existsSync(file)) record.modules.push({ url: url.pathname, sha256: sha256(file) }); } });
await page.exposeFunction('__recordSimplifyWire', (event: Wire) => record.wire.push(event));
await page.addInitScript(() => {
const NativeWorker = window.Worker;
const observe = (data: MessageEvent['data']) => {
if (!data.result?.snapshot && !data.result?.lod && !data.result?.cacheKey && !data.result?.manifests && !data.requestId?.startsWith('lod-') && !data.error) return;
const result = { ...data.result, geometryBuffers: undefined, geometryDelta: undefined, nonMeshGeometryBuffers: undefined, data: undefined };
if (result.lod) result.lod = { ...result.lod, levels: result.lod.levels.map((level: { geometryBuffers?: unknown }) => ({ ...level, geometryBuffers: undefined })) };
if (data.result?.data instanceof ArrayBuffer) { const bytes = new Uint8Array(data.result.data); let binary = ''; for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); result.dataBase64 = btoa(binary); }
void (window as unknown as { __recordSimplifyWire: (event: unknown) => Promise<void> }).__recordSimplifyWire({ requestId: data.requestId ?? '', ok: data.ok, result, error: data.error, errorCode: data.errorCode });
};
(window as unknown as { __lodCacheWrites: unknown[] }).__lodCacheWrites = [];
window.Worker = class extends NativeWorker {
constructor(url: string | URL, options?: WorkerOptions) { super(url, options); if (String(url).includes('web-engine.worker')) (window as unknown as { __simplifyEngine: Worker }).__simplifyEngine = this; if (String(url).includes('storage.worker')) (window as unknown as { __simplifyStorage: Worker }).__simplifyStorage = this; this.addEventListener('message', ({ data }) => observe(data)); }
postMessage(message: unknown, transfer?: Transferable[] | StructuredSerializeOptions) {
const command = (message as { command?: { type: string; projectId?: string; cacheKey?: string } }).command;
if (command?.type === 'saveLOD') (window as unknown as { __lodCacheWrites: unknown[] }).__lodCacheWrites.push({ projectId: command.projectId, cacheKey: command.cacheKey });
super.postMessage(message, transfer as StructuredSerializeOptions);
}
};
});
});
const latest = (page: Page) => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).at(-1)!.result!.snapshot!;
test('high triangle simplified GLB preserves corner normals', async ({ page }) => {
await openFixture(page,path.join(root,'tests/files/web/mesh_simplify_high.blend')); await selectFixtureObject(page); await page.getByRole('button',{name:'应用 Decimate',exact:true}).click(); await expect(page.getByTestId('simplify-triangle-counts')).toHaveText('三角形 16128 → 8064',{timeout:60_000}); const before=latest(page), downloads: {path:string;sha256:string}[]=[];
for (const [label,extension] of [['保存项目','blend'],['导出 GLB','glb']]) { const download=page.waitForEvent('download',{timeout:60_000}); await page.getByRole('button',{name:label,exact:true}).click(); const file=path.join(evidence,`${records.get(page)!.started}-high-normal.${extension}`); await (await download).saveAs(file); downloads.push({path:file,sha256:sha256(file)}); }
expect((await productionRequest(page,'engine','lod-high-glb-unchanged',{type:'snapshot'})).result?.snapshot).toEqual(before); Object.assign(records.get(page)!,{downloads,exportStatus:await page.getByTestId('engine-status').getAttribute('title')}); expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('LOD cache survives Worker restart and rejects stale or corrupt recovery data', async ({ page }) => {
const oracle = JSON.parse(fs.readFileSync(path.join(lodBaseline, 'desktop-lod.json'), 'utf8')); const savedFiles: string[] = [];
await openFixture(page, path.join(root, 'tests/files/web/mesh_simplify_high.blend')); await selectFixtureObject(page);
await page.getByRole('button', { name: '应用 Decimate', exact: true }).click(); await expect(page.getByTestId('simplify-triangle-counts')).toHaveText('三角形 16128 → 8064', { timeout: 60_000 }); const source = latest(page);
const save = async (name: string) => { const download = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const file = path.join(evidence, `${records.get(page)!.started}-${name}.blend`); await (await download).saveAs(file); savedFiles.push(file); return file; };
await page.getByRole('button', { name: '生成 LOD', exact: true }).click(); await expect(page.getByTestId('simplify-cache-status')).toHaveText('LOD 缓存:已加载 3 级', { timeout: 90_000 });
const stored = records.get(page)!.wire.find(event=>event.result?.cacheKey && event.result?.bytes)!.result!, projectId = stored.projectId!, cacheKey = stored.cacheKey!;
const manifest = (await productionRequest(page,'storage','lod-recovery-manifest',{type:'getLODManifest',projectId,cacheKey})).result!.manifest!;
const cached = (await productionRequest(page,'storage','lod-recovery-bytes',{type:'readLOD',projectId,cacheKey})).result!, dataBase64 = cached.dataBase64!; const bytes = Buffer.from(dataBase64,'base64'), decoded = decodeLODGeometry(Uint8Array.from(bytes).buffer);
const hash = (buffer: ArrayBuffer) => crypto.createHash('sha256').update(new Uint8Array(buffer)).digest('hex'); expect(manifest.contentSha256).toBe(hash(Uint8Array.from(bytes).buffer));
decoded.forEach((level,index) => { expect(level.outputTriangleCount).toBe(oracle.levels[index].triangles); expect(level.outputVertexCount).toBe(oracle.levels[index].vertices); expect(hash(level.geometryBuffers[0].positions)).toBe(oracle.levels[index].positionsSha256); expect(hash(level.geometryBuffers[0].indices)).toBe(oracle.levels[index].indicesSha256); });
expect((await productionRequest(page,'engine','lod-recovery-main-before-save',{type:'snapshot'})).result?.snapshot).toEqual(source); const beforeFile = await save('lod-recovery-before'); expect(manifest.sourceBlendSha256).toBe(sha256(beforeFile));
const canonical = (snapshot: Snapshot) => ({ nodes:snapshot.nodes, meshes:snapshot.meshes });
let recovery = 0; const recover = async (loaded: boolean) => { await page.reload(); await expect(page.getByTestId('engine-status')).toContainText('ready, open a .blend file', { timeout: 60_000 }); await expect(page.getByTestId('recent-projects').locator(`option[value="${projectId}"]`)).toHaveCount(1); await page.getByTestId('recent-projects').selectOption(projectId); await expect(page.getByTestId('engine-status')).toContainText('Recovery:', { timeout: 60_000 }); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy','false'); await expect(page.getByTestId('simplify-cache-status')).toHaveText(loaded ? 'LOD 缓存:已加载 3 级' : 'LOD 缓存:未加载'); const current = (await productionRequest(page,'engine',`lod-recovered-main-${++recovery}`,{type:'snapshot'})).result!.snapshot!; expect(canonical(current)).toEqual(canonical(source)); return current; };
await page.evaluate(() => { const state = window as unknown as { __simplifyEngine: Worker; __simplifyStorage: Worker }; state.__simplifyEngine.terminate(); state.__simplifyStorage.terminate(); });
const restored = await recover(true); expect(restored.revision).not.toBe(manifest.sourceMeshRevision);
const restoredBytes = (await productionRequest(page,'storage','lod-restored-cache-bytes',{type:'readLOD',projectId,cacheKey})).result!.dataBase64!; expect(restoredBytes).toBe(dataBase64);
const cacheFile = path.join(evidence,`${records.get(page)!.started}-recovered-lod.mesh`); fs.writeFileSync(cacheFile,bytes);
Object.assign(records.get(page)!,{ cacheFile, cacheSha256:sha256(cacheFile), cacheManifest:manifest, workersTerminated:['WebEngineWorker','StorageWorker'], restoredAtDifferentNativeRevision:true, desktopOracleSha256:sha256(path.join(lodBaseline,'desktop-lod.json')) });
const beforeInvalid = (await productionRequest(page,'storage','lod-invalid-before',{type:'listLODManifests',projectId})).result!.manifests, rejected: Wire[] = [];
for (const [index,invalid] of [{...manifest,schemaVersion:2},{...manifest,sourceBlendSha256:'invalid'},{...manifest,contentSha256:'invalid'},{...manifest,cacheKey:'invalid'}].entries()) { const reply = await productionRequest(page,'storage',`lod-invalid-record-${index}`,{type:'putLODManifest',projectId,manifest:invalid}); expect(reply.ok).toBe(false); expect(reply.errorCode).toBe('INVALID_LOD_MANIFEST'); rejected.push(reply); }
for (const maxBytes of [-1,1.5,Number.MAX_SAFE_INTEGER+1]) { const badBudget = await productionRequest(page,'storage',`lod-invalid-prune-budget-${maxBytes}`,{type:'pruneLOD',projectId,maxBytes}); expect(badBudget.ok).toBe(false); expect(badBudget.errorCode).toBe('INVALID_ARGUMENT'); rejected.push(badBudget); }
expect((await productionRequest(page,'storage','lod-invalid-after',{type:'listLODManifests',projectId})).result!.manifests).toEqual(beforeInvalid);
expect((await productionRequest(page,'engine','lod-invalid-main-after',{type:'snapshot'})).result!.snapshot).toEqual(restored);
Object.assign(records.get(page)!,{ invalidCacheReplies:rejected, invalidCacheUnchanged:true });
const put = async (value: object, id: string) => { const reply = await productionRequest(page,'storage',id,{type:'putLODManifest',projectId,manifest:value}); expect(reply.ok).toBe(true); };
await put({...manifest,sourceBlendSha256:'0'.repeat(64)},'lod-stale-source'); await recover(false);
await put({...manifest,sourceBlendSha256:undefined,contentSha256:undefined},'lod-legacy-record'); await recover(false);
await put(manifest,'lod-valid-record');
const write = async (data: string, requestId: string) => { await page.evaluate(({data,requestId,projectId,cacheKey}) => { const bytes = Uint8Array.from(atob(data),value=>value.charCodeAt(0)); (window as unknown as {__simplifyStorage:Worker}).__simplifyStorage.postMessage({requestId,command:{type:'saveLOD',projectId,cacheKey,data:bytes.buffer}},[bytes.buffer]); },{data,requestId,projectId,cacheKey}); await expect.poll(()=>records.get(page)!.wire.find(event=>event.requestId===requestId)?.ok).toBe(true); };
const corrupted = Buffer.from(bytes); corrupted[corrupted.length-1] ^= 1; expect(()=>decodeLODGeometry(Uint8Array.from(corrupted).buffer)).not.toThrow(); await write(corrupted.toString('base64'),'lod-corrupt-payload'); await recover(false);
await write(dataBase64,'lod-restore-payload'); await recover(true);
const prune = await productionRequest(page,'storage','lod-recovery-prune',{type:'pruneLOD',projectId,maxBytes:0}); expect(prune.ok).toBe(true);
expect((await productionRequest(page,'storage','lod-recovery-pruned-list',{type:'listLODManifests',projectId})).result!.manifests).toEqual([]); expect((await productionRequest(page,'storage','lod-recovery-pruned-read',{type:'readLOD',projectId,cacheKey})).ok).toBe(false);
await recover(false); await save('lod-recovery-after');
delete cached.dataBase64; Object.assign(records.get(page)!,{ recoveries:recovery, staleAndLegacyRejected:true, parseableCorruptionRejected:true, cacheRestoredAfterRepair:true, prunedCacheMissing:true, downloads:savedFiles.map(file=>({path:file,sha256:sha256(file)})) });
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('pending preview cancellation discards a delayed native result', async ({ page }) => {
await page.addInitScript(() => { const NativeWorker = window.Worker; window.Worker = class extends NativeWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url,options); const state = window as unknown as { __heldPreview?: unknown; __releasePreview?: () => void }; const worker = this; const hold = (event: MessageEvent) => { if (!event.data.result?.simplify) return; event.stopImmediatePropagation(); worker.removeEventListener('message',hold); state.__heldPreview = event.data; state.__releasePreview = () => worker.dispatchEvent(new MessageEvent('message',{data:state.__heldPreview})); }; worker.addEventListener('message',hold); } }; });
await openFixture(page, path.join(root, 'tests/files/web/mesh_simplify_high.blend')); await selectFixtureObject(page); const before = latest(page);
const beforeDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const beforeFile = path.join(evidence, `${records.get(page)!.started}-pending-before.blend`); await (await beforeDownload).saveAs(beforeFile);
await page.getByRole('button', { name: '预览 Decimate', exact: true }).click(); await expect(page.getByRole('button', { name: '取消 Decimate 预览', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => Boolean((window as unknown as { __heldPreview?: unknown }).__heldPreview)), { timeout: 60_000 }).toBe(true);
await expect(page.getByTestId('simplify-report-empty')).toBeVisible(); await expect(page.getByTestId('engine-status')).not.toContainText('Preview');
await page.getByRole('button', { name: '取消 Decimate 预览', exact: true }).click(); await page.evaluate(() => (window as unknown as { __releasePreview: () => void }).__releasePreview());
const after = await productionRequest(page, 'engine', 'lod-cancel-delayed-after', { type: 'snapshot' }); expect(after.result?.snapshot).toEqual(before);
await expect(page.getByTestId('simplify-report-empty')).toBeVisible(); await expect(page.getByRole('button', { name: '取消 Decimate 预览', exact: true })).toHaveCount(0); await expect(page.getByTestId('engine-status')).not.toContainText('Preview');
const afterDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const afterFile = path.join(evidence, `${records.get(page)!.started}-pending-after.blend`); await (await afterDownload).saveAs(afterFile); expect(sha256(afterFile)).toBe(sha256(beforeFile));
Object.assign(records.get(page)!, { delayedPreviewCancelled: true, downloads: [beforeFile,afterFile].map(file=>({ path:file,sha256:sha256(file) })) }); expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
async function productionRequest(page: Page, worker: 'engine' | 'storage', requestId: string, command: object) {
await page.evaluate(({ worker, requestId, command }) => { const owned = window as unknown as { __simplifyEngine: Worker; __simplifyStorage: Worker }; (worker === 'engine' ? owned.__simplifyEngine : owned.__simplifyStorage).postMessage({ requestId, command }); }, { worker, requestId, command });
await expect.poll(() => records.get(page)!.wire.find(event => event.requestId === requestId), { timeout: 60_000 }).toBeDefined();
return records.get(page)!.wire.find(event => event.requestId === requestId)!;
}
async function rejectWithoutMutation(page: Page, id: string, meshId: string, profile: object, code: string, type = 'previewDecimateMesh') {
const before = latest(page);
await page.evaluate(({ id, meshId, profile, type }) => { (window as unknown as { __simplifyEngine: Worker }).__simplifyEngine.postMessage({ requestId: id, command: { type: 'applyCommand', payload: { type, meshId, profile } } }); }, { id, meshId, profile, type });
await expect.poll(() => records.get(page)!.wire.find(event => event.requestId === id)?.error).toMatchObject({ code });
await page.evaluate(id => { (window as unknown as { __simplifyEngine: Worker }).__simplifyEngine.postMessage({ requestId: id + '-after', command: { type: 'snapshot' } }); }, id);
await expect.poll(() => records.get(page)!.wire.find(event => event.requestId === id + '-after')?.result?.snapshot).toEqual(before);
}
test.afterEach(async ({ page }, info) => { const record = records.get(page)!; const receipt = path.join(evidence, `${record.started}-${info.title.replace(/[^a-z0-9]+/gi, '-')}.json`); fs.writeFileSync(receipt, JSON.stringify({ ...record, status: info.status, exitCode: info.status === 'passed' ? 0 : 1, durationMs: Date.now() - record.started, viewport: page.viewportSize(), testSourceSha256: sha256(import.meta.filename), errors: info.errors, consoleErrors: record.errors }, null, 2)); await info.attach('production-receipt', { path: receipt, contentType: 'application/json' }); });
async function openFixture(page: Page, fixture: string) {
records.get(page)!.fixture = { path: path.relative(root, fixture), sha256: sha256(fixture) };
await page.goto('/?ui=web'); records.get(page)!.isolated = await page.evaluate(() => crossOriginIsolated); expect(records.get(page)!.isolated).toBe(true);
await page.getByTestId('blend-file-input').setInputFiles(fixture);
await expect(page.getByTestId('engine-status')).toContainText(/SceneIR r\d+/, { timeout: 60_000 });
await expect.poll(() => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length).toBeGreaterThan(0);
}
async function selectFixtureObject(page: Page, name = 'SimplifyHighSphere') {
const revision = latest(page).revision;
await page.getByText(name, { exact: true }).click();
await expect.poll(() => latest(page).revision).toBeGreaterThan(revision);
await expect(page.getByTestId('editor-workflow-workbench')).toHaveAttribute('data-revision', String(latest(page).revision));
}
test('production prerequisites', async ({ page }) => {
await openFixture(page, path.join(root, 'tests/files/web/basic_scene.blend'));
await expect(page.getByText('BasicCube', { exact: true })).toBeVisible();
expect(latest(page).meshes.find(mesh => mesh.name === 'BasicCubeMesh')?.triangleCount ?? latest(page).meshes[0]?.triangleCount).toBe(12);
expect(records.get(page)!.modules.some(module => module.url.endsWith('.wasm'))).toBe(true);
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('high triangle preview cancel apply history save reopen', async ({ page }) => {
const desktop = JSON.parse(fs.readFileSync(path.join(baseline, 'desktop-fixture.json'), 'utf8'));
const fixture = path.join(root, desktop.fixture); expect(sha256(fixture)).toBe(desktop.fixtureSha256);
await openFixture(page, fixture); await selectFixtureObject(page);
const before = latest(page); const mesh = before.meshes.find(mesh => mesh.name === 'SimplifyHighMesh')!;
expect(mesh.triangleCount).toBe(desktop.before.triangles); expect(mesh.triangleCount).toBeGreaterThanOrEqual(16000);
const requiredAttribute = { name: 'fixture_weight', domain: 'POINT', dataType: 'FLOAT' };
expect(mesh.attributes).toContainEqual(requiredAttribute);
const profile = { schemaVersion: 1, sourceMeshRevision: before.revision, mode: 'COLLAPSE', attributePolicy: 'PRESERVE', ratio: 0.5 };
await rejectWithoutMutation(page, 'negative-stale', mesh.id, { ...profile, sourceMeshRevision: before.revision - 1 }, 'REVISION_CONFLICT');
await rejectWithoutMutation(page, 'negative-stale-apply', mesh.id, { ...profile, sourceMeshRevision: before.revision - 1 }, 'REVISION_CONFLICT', 'decimateMesh');
await rejectWithoutMutation(page, 'negative-ratio', mesh.id, { ...profile, ratio: -0.01 }, 'INVALID_ARGUMENT', 'decimateMesh');
await rejectWithoutMutation(page, 'negative-malformed', mesh.id, { ...profile, ratio: 'invalid' }, 'INVALID_ARGUMENT', 'decimateMesh');
await expect(page.getByRole('slider', { name: 'Decimate 比例' })).toHaveValue('0.5');
await page.getByRole('button', { name: '预览 Decimate' }).click();
await expect(page.getByTestId('engine-status')).toContainText('Preview', { timeout: 60_000 });
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(desktop.after.triangles);
await page.getByRole('button', { name: '取消 Decimate 预览' }).click();
await expect(page.getByTestId('engine-status')).toContainText(`SceneIR r${before.revision}`);
const cancelledDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目' }).click();
const cancelledFile = path.join(evidence, `${records.get(page)!.started}-cancelled-preview.blend`); await (await cancelledDownload).saveAs(cancelledFile);
await page.getByTestId('blend-file-input').setInputFiles(cancelledFile);
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(mesh.triangleCount);
await selectFixtureObject(page); const applyRevision = latest(page).revision;
await page.getByRole('button', { name: '应用 Decimate' }).click();
await expect(page.getByTestId('engine-status')).toContainText('Decimate', { timeout: 60_000 });
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(desktop.after.triangles);
const applied = latest(page); expect(applied.revision).toBe(applyRevision + 1);
expect(applied.meshes.find(value => value.id === mesh.id)?.attributes).toContainEqual(requiredAttribute);
await page.getByRole('button', { name: '撤销', exact: true }).click();
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(mesh.triangleCount);
expect(latest(page).revision).toBe(applied.revision + 1);
await page.getByRole('button', { name: '重做', exact: true }).click();
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(desktop.after.triangles);
expect(latest(page).revision).toBe(applied.revision + 2);
const savedDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目' }).click();
const savedFile = path.join(evidence, `${records.get(page)!.started}-simplified.blend`); await (await savedDownload).saveAs(savedFile);
const reopenEventCount = records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length;
await page.getByTestId('blend-file-input').setInputFiles(savedFile);
await expect(page.getByTestId('engine-status')).toContainText(/SceneIR r\d+/);
await expect.poll(() => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length).toBeGreaterThan(reopenEventCount);
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(desktop.after.triangles);
expect(latest(page).meshes.find(value => value.id === mesh.id)?.vertexCount).toBe(desktop.after.vertices);
expect(latest(page).meshes.find(value => value.id === mesh.id)?.attributes).toContainEqual(requiredAttribute);
expect(latest(page).nodes).toEqual(applied.nodes);
Object.assign(records.get(page)!, { downloads: [cancelledFile, savedFile].map(file => ({ path: file, sha256: sha256(file) })) });
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('LOD on edited Main preserves state and enforces cache budgets', async ({ page }) => {
const oracle = JSON.parse(fs.readFileSync(path.join(lodBaseline, 'desktop-lod.json'), 'utf8'));
await openFixture(page, path.join(root, 'tests/files/web/mesh_simplify_high.blend')); await selectFixtureObject(page);
const selected = latest(page).revision; await page.getByRole('button', { name: '应用 Decimate' }).click();
await expect(page.getByTestId('engine-status')).toContainText('Decimate', { timeout: 60_000 });
await expect.poll(() => latest(page).revision).toBe(selected + 1);
const source = latest(page), mesh = source.meshes.find(value => value.name === 'SimplifyHighMesh')!;
expect(mesh.triangleCount).toBe(8064); await page.getByRole('button', { name: '生成 LOD' }).click();
await expect(page.getByTestId('engine-status')).toContainText('cache ready', { timeout: 90_000 });
const lod = records.get(page)!.wire.find(event => event.result?.lod)?.result?.lod!;
expect(lod.manifest.sourceMeshRevision).toBe(source.revision);
expect(lod.levels.map(level => level.outputTriangleCount)).toEqual(oracle.levels.map((level: { triangles: number }) => level.triangles));
for (const level of lod.levels) { expect(level.outputTriangleCount).toBeLessThanOrEqual(level.triangleBudget); expect(level.simplify?.sourceMeshRevision).toBe(source.revision); }
const after = await productionRequest(page, 'engine', 'lod-source-after', { type: 'snapshot' }); expect(after.result?.snapshot).toEqual(source);
const stored = records.get(page)!.wire.find(event => event.result?.cacheKey && event.result?.bytes)!.result!;
const queried = await productionRequest(page, 'storage', 'lod-manifest', { type: 'getLODManifest', projectId: stored.projectId!, cacheKey: stored.cacheKey! });
const cache = queried.result!.manifest!, projectId = stored.projectId!; expect(cache.sourceMeshRevision).toBe(source.revision);
expect((source.nodes as { id: string }[]).some(node => node.id === cache.objectId)).toBe(true);
const cached = await productionRequest(page, 'storage', 'lod-cache-read', { type: 'readLOD', projectId, cacheKey: cache.cacheKey }); expect(cached.ok).toBe(true);
const bytes = Buffer.from(cached.result!.dataBase64!, 'base64'); expect(bytes.byteLength).toBe(cache.byteLength);
const decoded = decodeLODGeometry(Uint8Array.from(bytes).buffer); expect(decoded).toHaveLength(3);
const cacheFile = path.join(evidence, `${records.get(page)!.started}-lod-cache.mesh`); fs.writeFileSync(cacheFile, bytes);
Object.assign(records.get(page)!, { cacheFile, cacheSha256: sha256(cacheFile), cacheManifest: cache, desktopOracleSha256: sha256(path.join(lodBaseline, 'desktop-lod.json')) });
delete cached.result!.dataBase64;
const hash = (buffer: ArrayBuffer) => crypto.createHash('sha256').update(new Uint8Array(buffer)).digest('hex');
decoded.forEach((level, index) => {
expect(level.outputTriangleCount).toBe(oracle.levels[index].triangles); expect(level.outputVertexCount).toBe(oracle.levels[index].vertices);
expect(hash(level.geometryBuffers[0].positions)).toBe(oracle.levels[index].positionsSha256);
expect(hash(level.geometryBuffers[0].indices)).toBe(oracle.levels[index].indicesSha256);
});
const beforeCache = await productionRequest(page, 'storage', 'lod-manifests-before', { type: 'listLODManifests', projectId });
const profile = { schemaVersion: 1, sourceMeshRevision: source.revision, mode: 'COLLAPSE', attributePolicy: 'PRESERVE', ratio: 1 };
const payload = { meshId: mesh.id, sourceMeshRevision: source.revision, levels: [{ profile, triangleBudget: mesh.triangleCount }] };
const rejected = [
{ id: 'lod-stale', payload: { ...payload, sourceMeshRevision: source.revision - 1 }, code: 'REVISION_CONFLICT' },
{ id: 'lod-profile-stale', payload: { ...payload, levels: [{ ...payload.levels[0], profile: { ...profile, sourceMeshRevision: source.revision - 1 } }] }, code: 'REVISION_CONFLICT' },
{ id: 'lod-malformed', payload: { ...payload, levels: [{ ...payload.levels[0], profile: { ...profile, ratio: 'invalid' } }] }, code: 'INVALID_ARGUMENT' },
{ id: 'lod-zero-budget', payload: { ...payload, levels: [{ profile, triangleBudget: 0 }] }, code: 'INVALID_ARGUMENT' },
{ id: 'lod-output-budget', payload: { ...payload, levels: [{ profile, triangleBudget: 1 }] }, code: 'CAPABILITY_CONTRACT_BUDGET_EXCEEDED' },
];
for (const item of rejected) {
const failure = await productionRequest(page, 'engine', item.id, { type: 'generateLOD', payload: item.payload });
expect(failure.ok).toBe(false); expect(failure.error).toMatchObject({ code: item.code });
const unchanged = await productionRequest(page, 'engine', item.id + '-after', { type: 'snapshot' }); expect(unchanged.result?.snapshot).toEqual(source);
}
const afterCache = await productionRequest(page, 'storage', 'lod-manifests-after', { type: 'listLODManifests', projectId });
expect(afterCache.result?.manifests).toEqual(beforeCache.result?.manifests);
const pruned = await productionRequest(page, 'storage', 'lod-prune', { type: 'pruneLOD', projectId, maxBytes: 0 }); expect(pruned.ok).toBe(true);
expect(pruned.result).toMatchObject({ removed: 1, bytes: cache.byteLength, cacheKeys: [cache.cacheKey] });
const noCache = await productionRequest(page, 'storage', 'lod-pruned-manifests', { type: 'listLODManifests', projectId }); expect(noCache.result?.manifests).toEqual([]);
const missing = await productionRequest(page, 'storage', 'lod-evicted-read', { type: 'readLOD', projectId, cacheKey: cache.cacheKey }); expect(missing.ok).toBe(false);
const unchanged = await productionRequest(page, 'engine', 'lod-prune-after', { type: 'snapshot' }); expect(unchanged.result?.snapshot).toEqual(source);
await page.getByRole('button', { name: '撤销', exact: true }).click();
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(16128); expect(latest(page).revision).toBe(source.revision + 1);
await page.getByRole('button', { name: '重做', exact: true }).click();
await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(8064); expect(latest(page).revision).toBe(source.revision + 2);
const pendingDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目' }).click();
const savedFile = path.join(evidence, `${records.get(page)!.started}-lod-source.blend`); await (await pendingDownload).saveAs(savedFile);
const count = records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length; await page.getByTestId('blend-file-input').setInputFiles(savedFile);
await expect.poll(() => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length).toBeGreaterThan(count);
expect(latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(8064);
Object.assign(records.get(page)!, { downloads: [{ path: savedFile, sha256: sha256(savedFile) }] });
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('LOD discards late results when another file opens', async ({ page }) => {
await openFixture(page, path.join(baseline, 'desktop-decimated.blend')); await selectFixtureObject(page);
const sourceMeshId = latest(page).meshes.find(mesh => mesh.name === 'SimplifyHighMesh')!.id;
await page.getByRole('button', { name: '生成 LOD' }).click();
await page.getByTestId('blend-file-input').setInputFiles(path.join(root, 'tests/files/web/basic_scene.blend'));
await page.getByRole('button', { name: '不保存,继续', exact: true }).click();
await expect(page.getByText('BasicCube', { exact: true })).toBeVisible({ timeout: 90_000 });
await expect.poll(() => records.get(page)!.wire.some(event => event.result?.lod?.manifest.meshId === sourceMeshId), { timeout: 90_000 }).toBe(true);
const writes = await page.evaluate(() => (window as unknown as { __lodCacheWrites: unknown[] }).__lodCacheWrites);
expect(writes).toEqual([]); Object.assign(records.get(page)!, { lateCacheWrites: writes });
expect(latest(page).meshes.some(mesh => mesh.id === sourceMeshId)).toBe(false);
expect(latest(page).meshes[0].triangleCount).toBe(12);
await expect(page.getByTestId('engine-status')).not.toContainText('LOD:');
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
test('target triangle controls report and reject budgets without mutation', async ({ page }) => {
const oracle = JSON.parse(fs.readFileSync(path.join(targetBaseline, 'desktop-target.json'), 'utf8'));
await openFixture(page, path.join(root, 'tests/files/web/mesh_simplify_high.blend')); await selectFixtureObject(page);
const before = latest(page), mesh = before.meshes.find(value => value.name === 'SimplifyHighMesh')!;
await page.getByRole('combobox', { name: 'Decimate 目标方式' }).selectOption('TRIANGLES');
const target = page.getByRole('spinbutton', { name: '目标三角形' }); await target.fill('0');
await expect(page.getByRole('button', { name: '预览 Decimate' })).toBeDisabled(); await expect(page.getByRole('button', { name: '应用 Decimate' })).toBeDisabled();
expect((await productionRequest(page, 'engine', 'lod-target-invalid-ui', { type: 'snapshot' })).result?.snapshot).toEqual(before);
const profile = { schemaVersion: 1, sourceMeshRevision: before.revision, mode: 'COLLAPSE', attributePolicy: 'PRESERVE', ratio: 0.5 };
await rejectWithoutMutation(page, 'target-zero-budget', mesh.id, { ...profile, triangleBudget: 0 }, 'INVALID_ARGUMENT', 'decimateMesh');
await rejectWithoutMutation(page, 'target-fraction-budget', mesh.id, { ...profile, triangleBudget: 10.5 }, 'INVALID_ARGUMENT', 'decimateMesh');
await rejectWithoutMutation(page, 'target-staged-budget', mesh.id, { ...profile, triangleBudget: 1 }, 'CAPABILITY_CONTRACT_BUDGET_EXCEEDED', 'decimateMesh');
await rejectWithoutMutation(page, 'target-identity-budget', mesh.id, { ...profile, ratio: 1, triangleBudget: 1 }, 'CAPABILITY_CONTRACT_BUDGET_EXCEEDED', 'decimateMesh');
await rejectWithoutMutation(page, 'target-preview-budget', mesh.id, { ...profile, triangleBudget: 1 }, 'CAPABILITY_CONTRACT_BUDGET_EXCEEDED');
await target.fill(String(oracle.targetTriangles)); await page.getByRole('button', { name: '预览 Decimate' }).click();
await expect(page.getByTestId('simplify-triangle-counts')).toHaveText(`三角形 ${oracle.beforeTriangles} → ${oracle.triangles}`, { timeout: 60_000 });
const preview = records.get(page)!.wire.filter(event => event.result?.simplify).at(-1)!.result!;
expect(preview.simplify?.triangleBudget).toBe(oracle.targetTriangles); expect(preview.snapshot?.meshes.find(value => value.id === mesh.id)?.vertexCount).toBe(oracle.vertices);
await expect(page.getByTestId('simplify-result-phase')).toContainText('尚未应用'); await expect(page.getByTestId('simplify-result-ratio')).toContainText('25.00%');
await expect(page.getByTestId('simplify-retained-attributes')).toContainText('fixture_weight'); await expect(page.getByTestId('simplify-channel-counts')).toContainText('UV 层 1 → 1;材质槽 2 → 2');
await page.getByRole('button', { name: '取消 Decimate 预览' }).click(); await expect(page.getByTestId('simplify-report-empty')).toBeVisible();
expect((await productionRequest(page, 'engine', 'lod-target-cancel', { type: 'snapshot' })).result?.snapshot).toEqual(before);
await target.fill('8064'); await page.getByRole('button', { name: '应用 Decimate' }).click();
await expect(page.getByTestId('simplify-triangle-counts')).toHaveText('三角形 16128 → 8064', { timeout: 60_000 });
const applied = latest(page); expect(applied.revision).toBe(before.revision + 1);
await expect(page.getByTestId('simplify-result-target')).toContainText('8064'); await expect(page.getByTestId('simplify-result-ratio')).toContainText('50.00%');
await expect(page.getByTestId('simplify-result-phase')).toContainText('应用结果'); await expect(page.getByTestId('simplify-report')).toContainText('几何误差未估算');
await expect(page.getByTestId('simplify-cache-status')).toContainText('未加载'); await page.getByRole('button', { name: '生成 LOD' }).click();
await expect(page.getByTestId('simplify-cache-status')).toHaveText('LOD 缓存:已加载 3 级', { timeout: 90_000 });
await page.getByTestId('simplify-report').screenshot({ path: path.join(evidence, `${records.get(page)!.started}-simplify-report.png`) });
expect((await productionRequest(page, 'engine', 'lod-target-after-cache', { type: 'snapshot' })).result?.snapshot).toEqual(applied);
await page.getByRole('button', { name: '撤销', exact: true }).click(); await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(16128);
await expect(page.getByTestId('simplify-report-empty')).toBeVisible(); expect(latest(page).revision).toBe(applied.revision + 1);
await page.getByRole('button', { name: '重做', exact: true }).click(); await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(8064);
expect(latest(page).revision).toBe(applied.revision + 2);
const pendingDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目' }).click();
const savedFile = path.join(evidence, `${records.get(page)!.started}-target-applied.blend`); await (await pendingDownload).saveAs(savedFile);
const count = records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length; await page.getByTestId('blend-file-input').setInputFiles(savedFile);
await expect.poll(() => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length).toBeGreaterThan(count);
expect(latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(8064); await expect(page.getByTestId('simplify-report-empty')).toBeVisible();
Object.assign(records.get(page)!, { downloads: [{ path: savedFile, sha256: sha256(savedFile) }] });
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
const modeOracle = JSON.parse(fs.readFileSync(path.join(modeBaseline, 'desktop-modes.json'), 'utf8'));
for (const definition of modeOracle.cases as { name: string; mode: string; summary: { vertices: number; triangles: number } }[]) {
test(`mesh modes ${definition.name} preserve open grid and export GLB`, async ({ page }) => {
const fixture = path.join(root, modeOracle.fixture); expect(sha256(fixture)).toBe(modeOracle.fixtureSha256);
await openFixture(page, fixture); await selectFixtureObject(page, 'ModeGrid');
const before = latest(page), mesh = before.meshes.find(value => value.name === 'ModeGridMesh')!;
await page.getByRole('combobox', { name: 'Decimate 模式' }).selectOption(definition.mode);
if (definition.mode === 'DISSOLVE_PLANAR') { await page.getByRole('checkbox', { name: 'MATERIAL', exact: true }).check(); await page.getByRole('checkbox', { name: 'UV', exact: true }).check(); }
const invalid = { schemaVersion: 1, sourceMeshRevision: before.revision, attributePolicy: 'PRESERVE', mode: definition.mode, ...(definition.mode === 'COLLAPSE' ? { ratio: -0.01 } : definition.mode === 'UNSUBDIV' ? { iterations: -1 } : { angleLimit: -1, delimit: ['MATERIAL', 'UV'], useDissolveBoundaries: false }) };
const modeBefore = latest(page);
if (definition.mode === 'COLLAPSE' || definition.mode === 'UNSUBDIV') {
const zero = { ...invalid, ...(definition.mode === 'COLLAPSE' ? { ratio: 0 } : { iterations: 0 }) };
const accepted = await productionRequest(page, 'engine', `modes-${definition.name}-zero`, { type: 'applyCommand', payload: { type: 'previewDecimateMesh', meshId: mesh.id, profile: zero } });
expect(accepted.ok).toBe(true); const zeroAfter = await productionRequest(page, 'engine', `modes-${definition.name}-zero-after`, { type: 'snapshot' });
expect(zeroAfter.result?.snapshot?.meshes).toEqual(modeBefore.meshes); expect(zeroAfter.result?.snapshot?.nodes).toEqual(modeBefore.nodes);
}
await rejectWithoutMutation(page, `modes-${definition.name}-invalid`, mesh.id, invalid, 'INVALID_ARGUMENT', 'decimateMesh');
await page.getByRole('button', { name: '预览 Decimate' }).click();
await expect(page.getByTestId('simplify-triangle-counts')).toHaveText(`三角形 ${mesh.triangleCount} → ${definition.summary.triangles}`, { timeout: 60_000 });
expect(latest(page).meshes.find(value => value.id === mesh.id)?.vertexCount).toBe(definition.summary.vertices);
await expect(page.getByTestId('simplify-retained-attributes')).toContainText('fixture_weight');
await page.getByRole('button', { name: '取消 Decimate 预览' }).click();
expect((await productionRequest(page, 'engine', `lod-modes-${definition.name}-cancel`, { type: 'snapshot' })).result?.snapshot).toEqual(before);
await page.getByRole('button', { name: '应用 Decimate' }).click();
await expect(page.getByTestId('simplify-triangle-counts')).toHaveText(`三角形 ${mesh.triangleCount} → ${definition.summary.triangles}`, { timeout: 60_000 });
const applied = latest(page); expect(applied.revision).toBe(before.revision + 1);
await page.getByRole('button', { name: '撤销', exact: true }).click(); await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(mesh.triangleCount);
expect(latest(page).revision).toBe(applied.revision + 1);
await page.getByRole('button', { name: '重做', exact: true }).click(); await expect.poll(() => latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(definition.summary.triangles);
expect(latest(page).revision).toBe(applied.revision + 2);
const saved = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目' }).click();
const blend = path.join(evidence, `${records.get(page)!.started}-${definition.name}.blend`); await (await saved).saveAs(blend);
const count = records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length; await page.getByTestId('blend-file-input').setInputFiles(blend);
await expect.poll(() => records.get(page)!.wire.filter(event => event.ok && event.result?.snapshot).length).toBeGreaterThan(count);
expect(latest(page).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(definition.summary.triangles);
expect(latest(page).nodes).toEqual(applied.nodes);
const beforeExport = latest(page); const glbDownload = page.waitForEvent('download', { timeout: 60_000 }); await page.getByRole('button', { name: '导出 GLB', exact: true }).click();
const glb = path.join(evidence, `${records.get(page)!.started}-${definition.name}.glb`); await (await glbDownload).saveAs(glb);
await expect(page.getByTestId('engine-status')).toContainText('fixture_weight');
expect((await productionRequest(page, 'engine', `lod-modes-${definition.name}-export-after`, { type: 'snapshot' })).result?.snapshot).toEqual(beforeExport);
Object.assign(records.get(page)!, { mode: definition.name, downloads: [blend, glb].map(file => ({ path: file, sha256: sha256(file) })), exportStatus: await page.getByTestId('engine-status').textContent() });
expect(records.get(page)!.failures).toEqual([]); expect(records.get(page)!.errors).toEqual([]);
});
}