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>
352 lines
44 KiB
TypeScript
352 lines
44 KiB
TypeScript
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([]);
|
||
});
|
||
}
|