import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { chromium, expect } from '@playwright/test'; import { localCredentials } from './review-api.mjs'; // Read-only verification of the retained training acceptance fixtures. // Run from ute2e: node tools/training-scene-visibility.e2e.mjs // For PHYSICAL/CONFRONTATION fixtures, set UTE2E_TRAINING_MODES=1. const baseURL = process.env.UTE2E_BASE_URL || 'http://127.0.0.1:6180'; const testModes = process.env.UTE2E_TRAINING_MODES === '1'; const reportDir = path.resolve(testModes ? 'reports/training-modes-20260905' : 'reports/training-live-20260905'); fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true }); const cases = testModes ? [ { id: '163', key: 'physical', name: '牛奶卡车实装检查', mode: 'PHYSICAL', weights: [30, 30, 40], sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false }, { id: '164', key: 'confrontation', name: '狐狸红蓝协同巡检', mode: 'CONFRONTATION', weights: [50, 50], sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true }, ] : [ { id: '161', key: 'fox', name: '狐狸野外巡检', sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true }, { id: '162', key: 'truck', name: '牛奶卡车进场检查', sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false }, ]; const evidence = { title: '独立浏览器重开、真实模型可见性及封面验证', status: 'RUNNING', startedAt: new Date().toISOString(), checks: [], projects: [] }; const browser = await chromium.launch({ channel: 'msedge', headless: true }); async function login(page) { const credentials = localCredentials(); await page.goto(`${baseURL}/login`); await page.locator('[data-role-code=admin]').click(); await page.locator('input[name=username]').fill(credentials.username); await page.locator('input[name=password]').fill(credentials.password); for (let attempt = 0; attempt < 3; attempt++) { await page.getByRole('button', { name: '登录系统', exact: true }).click(); try { await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 20_000 }); break; } catch { if (attempt === 2) throw new Error('Development sign-in did not complete'); } } await page.goto(`${baseURL}/profile`); const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' }); for (let attempt = 0; attempt < 3; attempt++) { if ((await teacher.getAttribute('class')).includes('active')) break; await teacher.click(); try { await expect(teacher).toHaveClass(/active/, { timeout: 15_000 }); break; } catch { if (attempt === 2) throw new Error('Development teacher role switch did not complete'); } } } async function visibility(page, sceneObjectId) { return page.evaluate(sceneObjectId => { const editor = globalThis.__training.sceneEditor; const THREE = globalThis.__trainingThree; const root = editor.publishedSceneObjects.find(object => object.userData.sceneId === sceneObjectId); if (!root) throw new Error('Expected scene model was not restored'); const bounds = new THREE.Box3().setFromObject(root); let meshes = 0, visibleMeshes = 0, skinnedMeshes = 0; const visible = object => { for (let p = object; p; p = p.parent) if (!p.visible) return false; return true; }; root.traverse(object => { if (!object.isMesh) return; meshes++; if (visible(object)) visibleMeshes++; if (object.isSkinnedMesh) skinnedMeshes++; }); const sample = document.createElement('canvas'); sample.width = 320; sample.height = 180; const ctx = sample.getContext('2d', { willReadFrequently: true }); const read = () => { editor.renderer.render(editor.scene, editor.camera); ctx.drawImage(editor.renderer.domElement, 0, 0, 320, 180); return ctx.getImageData(0, 0, 320, 180).data; }; const shadows = []; const originalVisibility = root.visible; let withModel, withoutModel; try { root.traverse(object => { if (object.isMesh) { shadows.push([object, object.castShadow]); object.castShadow = false; } }); editor.renderer.shadowMap.needsUpdate = true; withModel = read(); root.visible = false; editor.renderer.shadowMap.needsUpdate = true; withoutModel = read(); } finally { root.visible = originalVisibility; shadows.forEach(([object, castShadow]) => { object.castShadow = castShadow; }); editor.renderer.shadowMap.needsUpdate = true; editor.renderer.render(editor.scene, editor.camera); } let changedPixels = 0; for (let offset = 0; offset < withModel.length; offset += 4) { const delta = Math.abs(withModel[offset] - withoutModel[offset]) + Math.abs(withModel[offset + 1] - withoutModel[offset + 1]) + Math.abs(withModel[offset + 2] - withoutModel[offset + 2]); if (delta >= 12) changedPixels++; } return { state: editor.container.dataset.state, meshes, visibleMeshes, skinnedMeshes, position: root.position.toArray(), scale: root.scale.toArray(), finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite), size: bounds.getSize(new THREE.Vector3()).toArray(), camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray() }, sample: { width: 320, height: 180, changedPixels }, scene: { projectId: globalThis.__training.project.scenario.sceneResource.sourceProjectId, versionId: globalThis.__training.project.scenario.sceneResource.sourceVersionId }, targets: globalThis.__training.availableTargets.map(target => ({ id: target.id, name: target.label, source: target.source })), steps: globalThis.__training.project.steps.map(step => ({ title: step.title, targetId: step.targetId, weight: step.assessment.weight })), }; }, sceneObjectId); } try { for (const testCase of cases) { const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' }); let token = ''; const errors = [], blockedWrites = []; const check = { title: `${testCase.name}:发布重开、模型实际可见、截图封面`, status: 'RUNNING', screenshots: [] }; evidence.checks.push(check); try { await context.route('**/*', async route => { const request = route.request(), pathname = new URL(request.url()).pathname; if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) { blockedWrites.push({ path: pathname, method: request.method() }); return route.abort('blockedbyclient'); } return route.continue(); }); const page = await context.newPage(); page.setDefaultTimeout(30_000); page.on('pageerror', error => errors.push(error.message)); page.on('response', async response => { if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) { const data = await response.json().catch(() => null); if (data?.data?.accessToken) token = data.data.accessToken; } }); await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => { const response = await route.fetch(); const text = await response.text(); const marker = 'const editor = new TrainingProjectEditor(root, options);'; assert.ok(text.includes(marker)); return route.fulfill({ response, body: text.replace(marker, marker + ' globalThis.__training = editor;') }); }); await page.route('**/src/features/guide-legacy/demo-src/training-scene-editor.js*', async route => { const response = await route.fetch(); const text = await response.text(), marker = 'export function createTrainingSceneEditor'; assert.ok(text.includes(marker)); return route.fulfill({ response, body: text.replace(marker, 'globalThis.__trainingThree = THREE;\n' + marker) }); }); await login(page); await page.goto(`${baseURL}/content/training-projects/${testCase.id}/legacy`); await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 }); await page.evaluate(() => globalThis.__training.sceneEditor.publishedSceneLoadPromise); await page.waitForFunction(() => globalThis.__training.sceneEditor.container.dataset.state === 'ready', null, { timeout: 60_000 }); const measured = await visibility(page, testCase.sceneObjectId); assert.equal(measured.scene.projectId, testCase.sceneProjectId); assert.equal(measured.scene.versionId, testCase.sceneVersionId); assert.ok(measured.finiteBounds && measured.visibleMeshes > 0); assert.ok(measured.sample.changedPixels > 500, `Only ${measured.sample.changedPixels} model pixels visible`); if (testCase.skinned) assert.ok(measured.skinnedMeshes > 0); assert.ok(measured.targets.length > 0 && measured.targets.every(target => target.source === 'scene')); assert.equal(measured.steps.length, (testCase.weights ?? [50, 50]).length); assert.deepEqual(measured.steps.map(step => step.weight), testCase.weights ?? [50, 50]); assert.ok(measured.steps.every(step => measured.targets.some(target => target.id === step.targetId))); const screenshot = `screenshots/${testCase.key}-independent-reopen.png`; await page.screenshot({ path: path.join(reportDir, screenshot), fullPage: true }); check.screenshots.push(screenshot); const detailResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}`, { headers: { Authorization: `Bearer ${token}` } }); assert.equal(detailResponse.status, 200); const detail = (await detailResponse.json()).data; assert.equal(detail.project.status, 'PUBLISHED'); if (testCase.mode) { const content = detail.currentVersion.content; assert.equal(detail.project.trainingMode, testCase.mode); assert.deepEqual(content.enabledChannels, [testCase.mode]); assert.deepEqual(content.modeConfig, content.channelProfiles[testCase.mode]); if (testCase.mode === 'PHYSICAL') { assert.equal(content.modeConfig.stationName, '牛奶卡车测试工位 A'); assert.equal(content.modeConfig.equipmentCode, 'E2E-TRUCK-163'); assert.deepEqual(content.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100]); assert.ok(content.steps.every(step => step.physicalEventRule.source === 'MANUAL' && step.physicalEventRule.manualConfirmAllowed)); } else { assert.equal(content.modeConfig.teamSize, 2); assert.equal(content.modeConfig.roundDurationMinutes, 7); assert.deepEqual(content.modeConfig.submitPositions, ['COMMANDER']); assert.ok(content.steps.every(step => JSON.stringify(step.allowedPositions) === '["COMMANDER"]')); } await page.locator('[data-action=configure-training-mode]').click(); const field = testCase.mode === 'PHYSICAL' ? 'stationName' : 'roundDuration'; await expect(page.locator(`[data-field="modeConfig.${field}"]`)).toHaveValue(testCase.mode === 'PHYSICAL' ? '牛奶卡车测试工位 A' : '7'); const settingsShot = `screenshots/${testCase.key}-independent-settings.png`; await page.screenshot({ path: path.join(reportDir, settingsShot), fullPage: true }); check.screenshots.push(settingsShot); await page.getByRole('button', { name: '完成配置', exact: true }).click(); } const dependency = detail.currentVersion.dependencies.find(item => item.relationType === 'TRAINING_SCENE' && item.required); assert.equal(dependency.targetProjectId, testCase.sceneProjectId); assert.equal(dependency.targetVersionId, testCase.sceneVersionId); const coverAsset = detail.currentVersion.assets.find(asset => asset.code === 'TRAINING_COVER'); assert.ok(coverAsset && coverAsset.storageUri === detail.project.coverUri); const coverResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}/cover`, { headers: { Authorization: `Bearer ${token}` } }); assert.equal(coverResponse.status, 200); assert.ok(coverResponse.headers.get('content-type').startsWith('image/jpeg')); const bytes = Buffer.from(await coverResponse.arrayBuffer()); const sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); assert.equal(sha256.toLowerCase(), coverAsset.sha256.toLowerCase()); const coverFile = `screenshots/${testCase.key}-final-cover.jpg`; fs.writeFileSync(path.join(reportDir, coverFile), bytes); const dimensions = await page.evaluate(async base64 => { const img = new Image(); img.src = `data:image/jpeg;base64,${base64}`; await img.decode(); return { width: img.naturalWidth, height: img.naturalHeight }; }, bytes.toString('base64')); assert.ok(Math.max(dimensions.width, dimensions.height) <= 640 && Math.min(dimensions.width, dimensions.height) > 100); check.screenshots.push(coverFile); assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, []); check.status = 'PASS'; check.details = { ...measured, cover: { assetId: coverAsset.id, bytes: bytes.length, sha256, ...dimensions }, pageErrors: errors, blockedWrites }; evidence.projects.push({ id: testCase.id, name: detail.project.name, mode: detail.project.trainingMode, status: detail.project.status, currentVersionId: detail.project.currentVersionId, publishedVersionId: detail.project.publishedVersionId, sceneProjectId: testCase.sceneProjectId, sceneVersionId: testCase.sceneVersionId, stepCount: measured.steps.length, totalScore: detail.currentVersion.content.totalScore, cover: { assetId: coverAsset.id, image: coverFile } }); } catch (error) { check.status = 'FAIL'; check.details = { error: error.message, pageErrors: errors, blockedWrites }; } finally { await context.close(); } } evidence.status = evidence.checks.every(check => check.status === 'PASS') ? 'PASS' : 'FAIL'; } finally { evidence.finishedAt = new Date().toISOString(); fs.writeFileSync(path.join(reportDir, 'readonly-evidence.json'), JSON.stringify(evidence, null, 2)); await browser.close(); } console.log(JSON.stringify({ status: evidence.status, checks: evidence.checks.map(({ title, status, details }) => ({ title, status, changedPixels: details?.sample?.changedPixels, error: details?.error })) }, null, 2)); if (evidence.status !== 'PASS') process.exitCode = 1;