|
- 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;
|