25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

225 lines
12 KiB

  1. import assert from 'node:assert/strict';
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. import { chromium, expect } from '@playwright/test';
  6. import { localCredentials } from './review-api.mjs';
  7. // Run against the local Vite application after saving the intended scene camera:
  8. // node tools/scene-model-visibility.e2e.mjs
  9. // Every scene uses a fresh browser context. Content and asset writes are blocked;
  10. // the only server mutations permitted are login and active-role session changes.
  11. const baseURL = process.env.SCENE_VISIBILITY_BASE_URL || 'http://127.0.0.1:6180';
  12. const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905');
  13. const cases = [
  14. { sceneId: '160', modelProjectId: '157', modelVersionId: '201', label: '狐狸野外训练区', screenshot: '26-fox-visual-regression.png' },
  15. { sceneId: '159', modelProjectId: '156', modelVersionId: '204', label: '牛奶卡车检验区', screenshot: '27-truck-visual-regression.png' },
  16. ];
  17. const credentials = localCredentials();
  18. const evidence = {
  19. generatedAt: new Date().toISOString(),
  20. baseURL,
  21. browser: 'Microsoft Edge / Playwright',
  22. method: 'Fresh context; saved camera; compare rendered pixels with model roots visible versus hidden; model shadow casting disabled in both samples.',
  23. threshold: { sampleWidth: 320, sampleHeight: 180, minimumChangedPixelsExclusive: 500, minimumRgbDelta: 12 },
  24. status: 'RUNNING',
  25. results: [],
  26. };
  27. fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true });
  28. function persist() {
  29. fs.writeFileSync(path.join(reportDir, 'visibility-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`);
  30. }
  31. function safeError(error) {
  32. let message = error instanceof Error ? error.message : String(error);
  33. for (const value of [credentials.username, credentials.password]) {
  34. if (value) message = message.split(value).join('[redacted]');
  35. }
  36. return message.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
  37. }
  38. async function signIn(page) {
  39. await page.goto('/login', { waitUntil: 'domcontentloaded' });
  40. await expect(page.locator('[data-role-code="admin"]')).toBeVisible({ timeout: 45_000 });
  41. await page.locator('[data-role-code="admin"]').click();
  42. await page.locator('input[name="username"]').fill(credentials.username);
  43. await page.locator('input[name="password"]').fill(credentials.password);
  44. await page.getByRole('button', { name: '登录系统', exact: true }).click();
  45. await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 60_000 });
  46. await page.goto('/profile', { waitUntil: 'domcontentloaded' });
  47. const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' });
  48. await expect(teacher).toBeVisible();
  49. let roleSwitchAttempts = 0;
  50. while (!(await teacher.getAttribute('class'))?.includes('active') && roleSwitchAttempts < 3) {
  51. roleSwitchAttempts += 1;
  52. await teacher.click();
  53. try {
  54. await expect(teacher).toHaveClass(/active/, { timeout: 15_000 });
  55. } catch {
  56. // The local auth service occasionally rejects the initial role switch.
  57. // Retry only while the requested role is still inactive; never retry or
  58. // weaken any scene, version, visibility or pixel-contribution assertion.
  59. if (roleSwitchAttempts === 3) throw new Error('Teacher role remained inactive after three session-switch attempts');
  60. }
  61. }
  62. await expect(teacher).toHaveClass(/active/);
  63. return { role: 'teacher', roleSwitchAttempts };
  64. }
  65. async function readPixelContribution(page, testCase) {
  66. return page.evaluate(async ({ modelProjectId, modelVersionId }) => {
  67. const editor = globalThis.__visibilityScene;
  68. const THREE = globalThis.__visibilityThree;
  69. if (!editor?.readySettled || editor.destroyed) throw new Error('Scene editor is not ready');
  70. if (!THREE) throw new Error('The editor Three runtime was not exposed');
  71. editor.selectObject(null);
  72. const roots = editor.objects.filter(object => String(object.userData?.targetProjectId) === modelProjectId);
  73. if (!roots.length) throw new Error(`Scene contains no model reference for project ${modelProjectId}`);
  74. if (roots.some(object => String(object.userData?.targetVersionId) !== modelVersionId)) {
  75. throw new Error(`Scene references an unexpected published model version for project ${modelProjectId}`);
  76. }
  77. const inheritedVisible = object => {
  78. for (let node = object; node; node = node.parent) if (!node.visible) return false;
  79. return true;
  80. };
  81. const measurements = roots.map(root => {
  82. root.updateMatrixWorld(true);
  83. const bounds = new THREE.Box3().setFromObject(root);
  84. const size = bounds.getSize(new THREE.Vector3());
  85. let meshes = 0;
  86. let visibleMeshes = 0;
  87. let skinnedMeshes = 0;
  88. root.traverse(node => {
  89. if (!node.isMesh) return;
  90. meshes += 1;
  91. if (inheritedVisible(node)) visibleMeshes += 1;
  92. if (node.isSkinnedMesh) skinnedMeshes += 1;
  93. });
  94. return {
  95. name: root.userData.displayName || root.name,
  96. sceneObjectId: root.userData.sceneId,
  97. targetProjectId: String(root.userData.targetProjectId),
  98. targetVersionId: String(root.userData.targetVersionId),
  99. visible: inheritedVisible(root), meshes, visibleMeshes, skinnedMeshes,
  100. position: root.position.toArray(), scale: root.scale.toArray(),
  101. bounds: { min: bounds.min.toArray(), max: bounds.max.toArray(), size: size.toArray() },
  102. finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite),
  103. };
  104. });
  105. const sample = document.createElement('canvas');
  106. sample.width = 320;
  107. sample.height = 180;
  108. const sampleContext = sample.getContext('2d', { willReadFrequently: true });
  109. if (!sampleContext) throw new Error('2D pixel sampling is unavailable');
  110. const readPixels = () => {
  111. editor.renderer.render(editor.scene, editor.camera);
  112. sampleContext.clearRect(0, 0, sample.width, sample.height);
  113. sampleContext.drawImage(editor.renderer.domElement, 0, 0, sample.width, sample.height);
  114. return sampleContext.getImageData(0, 0, sample.width, sample.height).data;
  115. };
  116. const visibility = roots.map(root => root.visible);
  117. const shadowCasters = [];
  118. let before;
  119. let withoutModels;
  120. try {
  121. // A shadow alone must not satisfy the visibility check.
  122. for (const root of roots) root.traverse(node => {
  123. if (node.isMesh) { shadowCasters.push([node, node.castShadow]); node.castShadow = false; }
  124. });
  125. editor.renderer.shadowMap.needsUpdate = true;
  126. before = readPixels();
  127. for (const root of roots) root.visible = false;
  128. editor.renderer.shadowMap.needsUpdate = true;
  129. withoutModels = readPixels();
  130. } finally {
  131. roots.forEach((root, index) => { root.visible = visibility[index]; });
  132. shadowCasters.forEach(([mesh, castShadow]) => { mesh.castShadow = castShadow; });
  133. editor.renderer.shadowMap.needsUpdate = true;
  134. editor.renderer.render(editor.scene, editor.camera);
  135. }
  136. let changedPixels = 0;
  137. let rgbDeltaSum = 0;
  138. for (let offset = 0; offset < before.length; offset += 4) {
  139. const delta = Math.abs(before[offset] - withoutModels[offset])
  140. + Math.abs(before[offset + 1] - withoutModels[offset + 1])
  141. + Math.abs(before[offset + 2] - withoutModels[offset + 2]);
  142. rgbDeltaSum += delta;
  143. if (delta >= 12) changedPixels += 1;
  144. }
  145. return {
  146. camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray(), fov: editor.camera.fov },
  147. ready: editor.readySettled, pendingLoads: editor.pendingLoads, modelRoots: measurements,
  148. sample: { width: 320, height: 180, changedPixels, changedPercentage: Number((changedPixels / 576).toFixed(3)), rgbDeltaSum },
  149. };
  150. }, testCase);
  151. }
  152. const browser = await chromium.launch({ channel: 'msedge', headless: true });
  153. try {
  154. for (const testCase of cases) {
  155. const blockedWrites = [];
  156. const context = await browser.newContext({ baseURL, viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' });
  157. const result = { ...testCase, status: 'RUNNING', blockedWrites };
  158. evidence.results.push(result);
  159. try {
  160. await context.route('**/*', async route => {
  161. const request = route.request();
  162. const pathname = new URL(request.url()).pathname;
  163. if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
  164. blockedWrites.push({ method: request.method(), path: pathname });
  165. await route.abort('blockedbyclient');
  166. return;
  167. }
  168. await route.continue();
  169. });
  170. const page = await context.newPage();
  171. page.setDefaultTimeout(45_000);
  172. // Read handles are injected into the module already loaded by the page.
  173. // Re-importing an unversioned URL after HMR would create a second editor
  174. // module and observe the wrong instance. No business logic is replaced.
  175. await page.route('**/src/features/guide-legacy/demo-src/scene-editor.js*', async route => {
  176. const response = await route.fetch();
  177. const body = await response.text();
  178. const marker = 'activeSceneEditor = new WorkshopSceneEditor(container, context);';
  179. assert.ok(body.includes(marker), 'Scene editor instrumentation anchor must exist');
  180. await route.fulfill({ response, body: body.replace(marker,
  181. `${marker} globalThis.__visibilityScene = activeSceneEditor; globalThis.__visibilityThree = THREE;`) });
  182. });
  183. result.session = await signIn(page);
  184. const sceneRead = page.waitForResponse(response => response.request().method() === 'GET'
  185. && new URL(response.url()).pathname === `/api/tran/v1/content/projects/${testCase.sceneId}`);
  186. await page.goto(`/content/scenes/${testCase.sceneId}/edit`, { waitUntil: 'domcontentloaded' });
  187. const response = await sceneRead;
  188. assert.equal(response.status(), 200, 'Scene project API must return 200');
  189. const { data } = await response.json();
  190. result.project = { id: String(data.project.id), name: data.project.name, status: data.project.status, currentVersionId: String(data.currentVersion.id), publishedVersionId: String(data.project.publishedVersionId) };
  191. const shell = page.locator('.se-shell');
  192. await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 });
  193. await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 });
  194. result.visibility = await readPixelContribution(page, testCase);
  195. result.screenshot = `screenshots/${testCase.screenshot}`;
  196. await page.screenshot({ path: path.join(reportDir, result.screenshot), fullPage: true, timeout: 60_000 });
  197. assert.ok(result.visibility.modelRoots.every(root => root.visible && root.visibleMeshes > 0 && root.finiteBounds), 'Model meshes and finite bounds must be visible');
  198. assert.ok(result.visibility.sample.changedPixels > 500, `Expected more than 500 model-contributed pixels; received ${result.visibility.sample.changedPixels}`);
  199. assert.equal(blockedWrites.length, 0, 'Read-only regression must not attempt content or file writes');
  200. result.status = 'PASS';
  201. console.log(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, changedPixels: result.visibility.sample.changedPixels, screenshot: result.screenshot }));
  202. } catch (error) {
  203. result.status = 'FAIL';
  204. result.error = safeError(error);
  205. console.error(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, error: result.error }));
  206. } finally {
  207. await context.close();
  208. persist();
  209. }
  210. }
  211. evidence.status = evidence.results.every(result => result.status === 'PASS') ? 'PASS' : 'FAIL';
  212. persist();
  213. if (evidence.status !== 'PASS') process.exitCode = 1;
  214. } finally {
  215. await browser.close();
  216. }