Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

226 строки
14 KiB

  1. import assert from 'node:assert/strict';
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import crypto from 'node:crypto';
  5. import { chromium, expect } from '@playwright/test';
  6. import { localCredentials } from './review-api.mjs';
  7. // Read-only verification of the retained training acceptance fixtures.
  8. // Run from ute2e: node tools/training-scene-visibility.e2e.mjs
  9. // For PHYSICAL/CONFRONTATION fixtures, set UTE2E_TRAINING_MODES=1.
  10. const baseURL = process.env.UTE2E_BASE_URL || 'http://127.0.0.1:6180';
  11. const testModes = process.env.UTE2E_TRAINING_MODES === '1';
  12. const reportDir = path.resolve(testModes ? 'reports/training-modes-20260905' : 'reports/training-live-20260905');
  13. fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true });
  14. const cases = testModes ? [
  15. { id: '163', key: 'physical', name: '牛奶卡车实装检查', mode: 'PHYSICAL', weights: [30, 30, 40], sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false },
  16. { id: '164', key: 'confrontation', name: '狐狸红蓝协同巡检', mode: 'CONFRONTATION', weights: [50, 50], sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true },
  17. ] : [
  18. { id: '161', key: 'fox', name: '狐狸野外巡检', sceneProjectId: '160', sceneVersionId: '210', sceneObjectId: 'OBJ-1788605916565-6', skinned: true },
  19. { id: '162', key: 'truck', name: '牛奶卡车进场检查', sceneProjectId: '159', sceneVersionId: '209', sceneObjectId: 'OBJ-1788605199171-2', skinned: false },
  20. ];
  21. const evidence = { title: '独立浏览器重开、真实模型可见性及封面验证', status: 'RUNNING', startedAt: new Date().toISOString(), checks: [], projects: [] };
  22. const browser = await chromium.launch({ channel: 'msedge', headless: true });
  23. async function login(page) {
  24. const credentials = localCredentials();
  25. await page.goto(`${baseURL}/login`);
  26. await page.locator('[data-role-code=admin]').click();
  27. await page.locator('input[name=username]').fill(credentials.username);
  28. await page.locator('input[name=password]').fill(credentials.password);
  29. for (let attempt = 0; attempt < 3; attempt++) {
  30. await page.getByRole('button', { name: '登录系统', exact: true }).click();
  31. try { await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 20_000 }); break; }
  32. catch { if (attempt === 2) throw new Error('Development sign-in did not complete'); }
  33. }
  34. await page.goto(`${baseURL}/profile`);
  35. const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' });
  36. for (let attempt = 0; attempt < 3; attempt++) {
  37. if ((await teacher.getAttribute('class')).includes('active')) break;
  38. await teacher.click();
  39. try { await expect(teacher).toHaveClass(/active/, { timeout: 15_000 }); break; }
  40. catch { if (attempt === 2) throw new Error('Development teacher role switch did not complete'); }
  41. }
  42. }
  43. async function visibility(page, sceneObjectId) {
  44. return page.evaluate(sceneObjectId => {
  45. const editor = globalThis.__training.sceneEditor;
  46. const THREE = globalThis.__trainingThree;
  47. const root = editor.publishedSceneObjects.find(object => object.userData.sceneId === sceneObjectId);
  48. if (!root) throw new Error('Expected scene model was not restored');
  49. const bounds = new THREE.Box3().setFromObject(root);
  50. let meshes = 0, visibleMeshes = 0, skinnedMeshes = 0;
  51. const visible = object => { for (let p = object; p; p = p.parent) if (!p.visible) return false; return true; };
  52. root.traverse(object => {
  53. if (!object.isMesh) return;
  54. meshes++;
  55. if (visible(object)) visibleMeshes++;
  56. if (object.isSkinnedMesh) skinnedMeshes++;
  57. });
  58. const sample = document.createElement('canvas');
  59. sample.width = 320; sample.height = 180;
  60. const ctx = sample.getContext('2d', { willReadFrequently: true });
  61. const read = () => {
  62. editor.renderer.render(editor.scene, editor.camera);
  63. ctx.drawImage(editor.renderer.domElement, 0, 0, 320, 180);
  64. return ctx.getImageData(0, 0, 320, 180).data;
  65. };
  66. const shadows = [];
  67. const originalVisibility = root.visible;
  68. let withModel, withoutModel;
  69. try {
  70. root.traverse(object => { if (object.isMesh) { shadows.push([object, object.castShadow]); object.castShadow = false; } });
  71. editor.renderer.shadowMap.needsUpdate = true;
  72. withModel = read();
  73. root.visible = false;
  74. editor.renderer.shadowMap.needsUpdate = true;
  75. withoutModel = read();
  76. } finally {
  77. root.visible = originalVisibility;
  78. shadows.forEach(([object, castShadow]) => { object.castShadow = castShadow; });
  79. editor.renderer.shadowMap.needsUpdate = true;
  80. editor.renderer.render(editor.scene, editor.camera);
  81. }
  82. let changedPixels = 0;
  83. for (let offset = 0; offset < withModel.length; offset += 4) {
  84. const delta = Math.abs(withModel[offset] - withoutModel[offset])
  85. + Math.abs(withModel[offset + 1] - withoutModel[offset + 1])
  86. + Math.abs(withModel[offset + 2] - withoutModel[offset + 2]);
  87. if (delta >= 12) changedPixels++;
  88. }
  89. return {
  90. state: editor.container.dataset.state, meshes, visibleMeshes, skinnedMeshes,
  91. position: root.position.toArray(), scale: root.scale.toArray(),
  92. finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite),
  93. size: bounds.getSize(new THREE.Vector3()).toArray(),
  94. camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray() },
  95. sample: { width: 320, height: 180, changedPixels },
  96. scene: { projectId: globalThis.__training.project.scenario.sceneResource.sourceProjectId, versionId: globalThis.__training.project.scenario.sceneResource.sourceVersionId },
  97. targets: globalThis.__training.availableTargets.map(target => ({ id: target.id, name: target.label, source: target.source })),
  98. steps: globalThis.__training.project.steps.map(step => ({ title: step.title, targetId: step.targetId, weight: step.assessment.weight })),
  99. };
  100. }, sceneObjectId);
  101. }
  102. try {
  103. for (const testCase of cases) {
  104. const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' });
  105. let token = '';
  106. const errors = [], blockedWrites = [];
  107. const check = { title: `${testCase.name}:发布重开、模型实际可见、截图封面`, status: 'RUNNING', screenshots: [] };
  108. evidence.checks.push(check);
  109. try {
  110. await context.route('**/*', async route => {
  111. const request = route.request(), pathname = new URL(request.url()).pathname;
  112. if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
  113. blockedWrites.push({ path: pathname, method: request.method() });
  114. return route.abort('blockedbyclient');
  115. }
  116. return route.continue();
  117. });
  118. const page = await context.newPage();
  119. page.setDefaultTimeout(30_000);
  120. page.on('pageerror', error => errors.push(error.message));
  121. page.on('response', async response => {
  122. if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) {
  123. const data = await response.json().catch(() => null);
  124. if (data?.data?.accessToken) token = data.data.accessToken;
  125. }
  126. });
  127. await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => {
  128. const response = await route.fetch();
  129. const text = await response.text();
  130. const marker = 'const editor = new TrainingProjectEditor(root, options);';
  131. assert.ok(text.includes(marker));
  132. return route.fulfill({ response, body: text.replace(marker, marker + ' globalThis.__training = editor;') });
  133. });
  134. await page.route('**/src/features/guide-legacy/demo-src/training-scene-editor.js*', async route => {
  135. const response = await route.fetch();
  136. const text = await response.text(), marker = 'export function createTrainingSceneEditor';
  137. assert.ok(text.includes(marker));
  138. return route.fulfill({ response, body: text.replace(marker, 'globalThis.__trainingThree = THREE;\n' + marker) });
  139. });
  140. await login(page);
  141. await page.goto(`${baseURL}/content/training-projects/${testCase.id}/legacy`);
  142. await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 });
  143. await page.evaluate(() => globalThis.__training.sceneEditor.publishedSceneLoadPromise);
  144. await page.waitForFunction(() => globalThis.__training.sceneEditor.container.dataset.state === 'ready', null, { timeout: 60_000 });
  145. const measured = await visibility(page, testCase.sceneObjectId);
  146. assert.equal(measured.scene.projectId, testCase.sceneProjectId);
  147. assert.equal(measured.scene.versionId, testCase.sceneVersionId);
  148. assert.ok(measured.finiteBounds && measured.visibleMeshes > 0);
  149. assert.ok(measured.sample.changedPixels > 500, `Only ${measured.sample.changedPixels} model pixels visible`);
  150. if (testCase.skinned) assert.ok(measured.skinnedMeshes > 0);
  151. assert.ok(measured.targets.length > 0 && measured.targets.every(target => target.source === 'scene'));
  152. assert.equal(measured.steps.length, (testCase.weights ?? [50, 50]).length);
  153. assert.deepEqual(measured.steps.map(step => step.weight), testCase.weights ?? [50, 50]);
  154. assert.ok(measured.steps.every(step => measured.targets.some(target => target.id === step.targetId)));
  155. const screenshot = `screenshots/${testCase.key}-independent-reopen.png`;
  156. await page.screenshot({ path: path.join(reportDir, screenshot), fullPage: true });
  157. check.screenshots.push(screenshot);
  158. const detailResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}`, { headers: { Authorization: `Bearer ${token}` } });
  159. assert.equal(detailResponse.status, 200);
  160. const detail = (await detailResponse.json()).data;
  161. assert.equal(detail.project.status, 'PUBLISHED');
  162. if (testCase.mode) {
  163. const content = detail.currentVersion.content;
  164. assert.equal(detail.project.trainingMode, testCase.mode);
  165. assert.deepEqual(content.enabledChannels, [testCase.mode]);
  166. assert.deepEqual(content.modeConfig, content.channelProfiles[testCase.mode]);
  167. if (testCase.mode === 'PHYSICAL') {
  168. assert.equal(content.modeConfig.stationName, '牛奶卡车测试工位 A');
  169. assert.equal(content.modeConfig.equipmentCode, 'E2E-TRUCK-163');
  170. assert.deepEqual(content.steps.map(step => step.physicalEventRule.progressPercent), [33, 67, 100]);
  171. assert.ok(content.steps.every(step => step.physicalEventRule.source === 'MANUAL' && step.physicalEventRule.manualConfirmAllowed));
  172. } else {
  173. assert.equal(content.modeConfig.teamSize, 2);
  174. assert.equal(content.modeConfig.roundDurationMinutes, 7);
  175. assert.deepEqual(content.modeConfig.submitPositions, ['COMMANDER']);
  176. assert.ok(content.steps.every(step => JSON.stringify(step.allowedPositions) === '["COMMANDER"]'));
  177. }
  178. await page.locator('[data-action=configure-training-mode]').click();
  179. const field = testCase.mode === 'PHYSICAL' ? 'stationName' : 'roundDuration';
  180. await expect(page.locator(`[data-field="modeConfig.${field}"]`)).toHaveValue(testCase.mode === 'PHYSICAL' ? '牛奶卡车测试工位 A' : '7');
  181. const settingsShot = `screenshots/${testCase.key}-independent-settings.png`;
  182. await page.screenshot({ path: path.join(reportDir, settingsShot), fullPage: true });
  183. check.screenshots.push(settingsShot);
  184. await page.getByRole('button', { name: '完成配置', exact: true }).click();
  185. }
  186. const dependency = detail.currentVersion.dependencies.find(item => item.relationType === 'TRAINING_SCENE' && item.required);
  187. assert.equal(dependency.targetProjectId, testCase.sceneProjectId);
  188. assert.equal(dependency.targetVersionId, testCase.sceneVersionId);
  189. const coverAsset = detail.currentVersion.assets.find(asset => asset.code === 'TRAINING_COVER');
  190. assert.ok(coverAsset && coverAsset.storageUri === detail.project.coverUri);
  191. const coverResponse = await fetch(`${baseURL}/api/tran/v1/content/projects/${testCase.id}/cover`, { headers: { Authorization: `Bearer ${token}` } });
  192. assert.equal(coverResponse.status, 200);
  193. assert.ok(coverResponse.headers.get('content-type').startsWith('image/jpeg'));
  194. const bytes = Buffer.from(await coverResponse.arrayBuffer());
  195. const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
  196. assert.equal(sha256.toLowerCase(), coverAsset.sha256.toLowerCase());
  197. const coverFile = `screenshots/${testCase.key}-final-cover.jpg`;
  198. fs.writeFileSync(path.join(reportDir, coverFile), bytes);
  199. const dimensions = await page.evaluate(async base64 => {
  200. const img = new Image(); img.src = `data:image/jpeg;base64,${base64}`; await img.decode();
  201. return { width: img.naturalWidth, height: img.naturalHeight };
  202. }, bytes.toString('base64'));
  203. assert.ok(Math.max(dimensions.width, dimensions.height) <= 640 && Math.min(dimensions.width, dimensions.height) > 100);
  204. check.screenshots.push(coverFile);
  205. assert.deepEqual(errors, []);
  206. assert.deepEqual(blockedWrites, []);
  207. check.status = 'PASS';
  208. check.details = { ...measured, cover: { assetId: coverAsset.id, bytes: bytes.length, sha256, ...dimensions }, pageErrors: errors, blockedWrites };
  209. 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 } });
  210. } catch (error) {
  211. check.status = 'FAIL'; check.details = { error: error.message, pageErrors: errors, blockedWrites };
  212. } finally { await context.close(); }
  213. }
  214. evidence.status = evidence.checks.every(check => check.status === 'PASS') ? 'PASS' : 'FAIL';
  215. } finally {
  216. evidence.finishedAt = new Date().toISOString();
  217. fs.writeFileSync(path.join(reportDir, 'readonly-evidence.json'), JSON.stringify(evidence, null, 2));
  218. await browser.close();
  219. }
  220. 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));
  221. if (evidence.status !== 'PASS') process.exitCode = 1;