|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; |
|
|
import { chromium, expect } from '@playwright/test'; |
|
|
import { chromium, expect } from '@playwright/test'; |
|
|
import { login, call } from './role-permissions-live.mjs'; |
|
|
import { login, call } from './role-permissions-live.mjs'; |
|
|
|
|
|
|
|
|
const output = 'reports/virtual-preparation-20260913'; |
|
|
|
|
|
|
|
|
const output = 'reports/virtual-floating-preparation-20260913'; |
|
|
fs.mkdirSync(output, { recursive: true }); |
|
|
fs.mkdirSync(output, { recursive: true }); |
|
|
const session = await login('root'); |
|
|
const session = await login('root'); |
|
|
const task = await call('tran/v1/teaching/assignments/81', session.accessToken); |
|
|
const task = await call('tran/v1/teaching/assignments/81', session.accessToken); |
|
|
@@ -20,35 +20,47 @@ page.on('pageerror', error => pageErrors.push(error.message)); |
|
|
const checked = name => { checks.push({ name, pass: true }); console.log('PASS', name); }; |
|
|
const checked = name => { checks.push({ name, pass: true }); console.log('PASS', name); }; |
|
|
const diagnostic = () => page.evaluate(() => globalThis.__trainingSceneDiagnostics?.()); |
|
|
const diagnostic = () => page.evaluate(() => globalThis.__trainingSceneDiagnostics?.()); |
|
|
try { |
|
|
try { |
|
|
await page.goto('http://127.0.0.1:6180/teaching/virtual-training'); |
|
|
|
|
|
await expect(page.locator('.virtual-prototype-card').first()).toBeVisible({ timeout: 30000 }); |
|
|
|
|
|
for (const width of [1920, 1440]) { |
|
|
|
|
|
await page.setViewportSize({ width, height: 900 }); |
|
|
|
|
|
const layouts = await page.locator('.virtual-prototype-card footer').evaluateAll(footers => footers.map(footer => { |
|
|
|
|
|
const rect = footer.getBoundingClientRect(); |
|
|
|
|
|
const buttons = [...footer.querySelectorAll('.el-button')].map(button => { const b = button.getBoundingClientRect(); return { x:b.x,y:b.y,right:b.right,bottom:b.bottom,height:b.height }; }); |
|
|
|
|
|
return { left:rect.left,right:rect.right,buttons }; |
|
|
|
|
|
})); |
|
|
|
|
|
assert.ok(layouts.length); |
|
|
|
|
|
for (const row of layouts) for (const [i, button] of row.buttons.entries()) { |
|
|
|
|
|
assert.ok(button.x >= row.left && button.right <= row.right + 1); |
|
|
|
|
|
assert.equal(button.height, 34); |
|
|
|
|
|
for (const other of row.buttons.slice(i + 1)) assert.ok(button.right <= other.x || other.right <= button.x || button.bottom <= other.y || other.bottom <= button.y); |
|
|
|
|
|
} |
|
|
|
|
|
await page.screenshot({ path: `${output}/buttons-${width}.png`, fullPage: true }); |
|
|
|
|
|
checked(`${width}px task buttons stay within cards without overlap`); |
|
|
|
|
|
} |
|
|
|
|
|
await page.goto('http://127.0.0.1:6180/teaching/virtual-training/preview/81'); |
|
|
await page.goto('http://127.0.0.1:6180/teaching/virtual-training/preview/81'); |
|
|
await expect.poll(async () => Boolean((await diagnostic())?.publishedContentMounted), { timeout: 90000 }).toBe(true); |
|
|
await expect.poll(async () => Boolean((await diagnostic())?.publishedContentMounted), { timeout: 90000 }).toBe(true); |
|
|
const panel = page.locator('.training-server-preparation'); |
|
|
const panel = page.locator('.training-server-preparation'); |
|
|
await expect(panel).toHaveAttribute('open', ''); |
|
|
|
|
|
|
|
|
await expect(panel).not.toBeVisible(); |
|
|
await expect(page.locator('[data-role="instruction"]')).toContainText('操作准备'); |
|
|
await expect(page.locator('[data-role="instruction"]')).toContainText('操作准备'); |
|
|
await expect(page.locator('[data-role="instruction"]')).toContainText('数字压力表'); |
|
|
await expect(page.locator('[data-role="instruction"]')).toContainText('数字压力表'); |
|
|
await expect(page.locator('[data-open-preparation]')).toBeVisible(); |
|
|
await expect(page.locator('[data-open-preparation]')).toBeVisible(); |
|
|
await panel.locator('summary').click(); |
|
|
|
|
|
await page.locator('[data-open-preparation]').click(); |
|
|
await page.locator('[data-open-preparation]').click(); |
|
|
await expect(panel).toHaveAttribute('open', ''); |
|
|
await expect(panel).toHaveAttribute('open', ''); |
|
|
checked('Preparation opens by default and has a visible header entry and concrete instructions'); |
|
|
|
|
|
|
|
|
assert.equal(await page.locator('.training-steps-panel .training-server-preparation').count(), 0); |
|
|
|
|
|
for (const size of [{width:1920,height:1080},{width:1440,height:900},{width:1366,height:768}]) { |
|
|
|
|
|
await page.setViewportSize(size); |
|
|
|
|
|
await page.waitForTimeout(300); |
|
|
|
|
|
const bounds = await page.evaluate(() => { |
|
|
|
|
|
const rect = selector => { const r=document.querySelector(selector).getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}; }; |
|
|
|
|
|
return {panel:rect('.training-server-preparation'), canvas:rect('.training-viewport'), left:rect('.training-steps-panel'), header:rect('.training-header'), dock:rect('.training-operation-dock'), footer:rect('.training-bottom-bar')}; |
|
|
|
|
|
}); |
|
|
|
|
|
assert.ok(bounds.left.width <= 220); |
|
|
|
|
|
assert.ok(bounds.header.height <= 46); |
|
|
|
|
|
assert.ok(bounds.footer.height <= 36); |
|
|
|
|
|
assert.ok(bounds.panel.x >= bounds.canvas.x && bounds.panel.right <= bounds.canvas.right); |
|
|
|
|
|
assert.ok(bounds.panel.y >= bounds.canvas.y && bounds.panel.bottom < bounds.dock.y); |
|
|
|
|
|
assert.ok(bounds.canvas.bottom <= size.height, '3D viewport stays within screen'); |
|
|
|
|
|
assert.ok(bounds.footer.bottom <= size.height, 'Bottom actions stay within screen'); |
|
|
|
|
|
await panel.locator('[data-measurement]').fill('浮窗收起与尺寸切换保留输入'); |
|
|
|
|
|
await panel.locator('[data-close-preparation]').click(); |
|
|
|
|
|
await expect(panel).not.toBeVisible(); |
|
|
|
|
|
await page.screenshot({path:`${output}/workspace-${size.width}.png`,fullPage:true}); |
|
|
|
|
|
await page.locator('[data-open-preparation]').click(); |
|
|
|
|
|
await expect(panel.locator('[data-measurement]')).toHaveValue('浮窗收起与尺寸切换保留输入'); |
|
|
|
|
|
const summary = await panel.locator('summary').boundingBox(), floating = await panel.boundingBox(); |
|
|
|
|
|
assert.ok(summary.y >= floating.y && summary.y + summary.height <= floating.y + floating.height, 'Floating header stays visible after textarea scroll'); |
|
|
|
|
|
await page.screenshot({path:`${output}/floating-${size.width}.png`,fullPage:true}); |
|
|
|
|
|
checked(`${size.width}×${size.height}: compact layout, floating window stays inside viewport, input survives closing`); |
|
|
|
|
|
} |
|
|
|
|
|
await panel.locator('[data-measurement]').fill(''); |
|
|
|
|
|
await panel.locator('[data-measurement]').press('Escape'); |
|
|
|
|
|
await expect(panel).not.toBeVisible(); |
|
|
|
|
|
await expect(page.locator('[data-open-preparation]')).toBeFocused(); |
|
|
|
|
|
await page.locator('[data-open-preparation]').click(); |
|
|
|
|
|
checked('Preparation starts collapsed, opens inside 3D area, and Escape returns focus'); |
|
|
const clickTarget = async () => { |
|
|
const clickTarget = async () => { |
|
|
await page.locator('[data-action="toggle-issue-highlight"]').click(); |
|
|
await page.locator('[data-action="toggle-issue-highlight"]').click(); |
|
|
await page.waitForTimeout(700); |
|
|
await page.waitForTimeout(700); |
|
|
@@ -83,10 +95,26 @@ try { |
|
|
await page.screenshot({ path: `${output}/step-completed.png`, fullPage: true }); |
|
|
await page.screenshot({ path: `${output}/step-completed.png`, fullPage: true }); |
|
|
checked('Prepared truck target click passes real preview validation and enables next step'); |
|
|
checked('Prepared truck target click passes real preview validation and enables next step'); |
|
|
await page.locator('[data-action="next"]').click(); |
|
|
await page.locator('[data-action="next"]').click(); |
|
|
|
|
|
await expect(panel).not.toBeVisible(); |
|
|
|
|
|
await page.locator('[data-open-preparation]').click(); |
|
|
await expect(panel).toHaveAttribute('open', ''); |
|
|
await expect(panel).toHaveAttribute('open', ''); |
|
|
await expect(panel.locator('b').first()).toContainText(task.data.definitionSnapshot.steps[1].title); |
|
|
await expect(panel.locator('b').first()).toContainText(task.data.definitionSnapshot.steps[1].title); |
|
|
await expect(panel.locator('[data-tool]').first()).not.toBeChecked(); |
|
|
await expect(panel.locator('[data-tool]').first()).not.toBeChecked(); |
|
|
checked('Next step opens fresh preparation rather than reusing previous confirmations'); |
|
|
|
|
|
|
|
|
checked('Next step keeps viewport clear and provides fresh preparation without previous confirmations'); |
|
|
|
|
|
await page.evaluate(() => { |
|
|
|
|
|
document.documentElement.classList.add('dark'); |
|
|
|
|
|
document.documentElement.dataset.theme = 'dark'; |
|
|
|
|
|
document.documentElement.dataset.colorTheme = 'dark'; |
|
|
|
|
|
document.documentElement.style.colorScheme = 'dark'; |
|
|
|
|
|
}); |
|
|
|
|
|
const colors = await panel.locator('[data-save-preparation]').evaluate(button => { |
|
|
|
|
|
const style = getComputedStyle(button); return {color:style.color,background:style.backgroundColor}; |
|
|
|
|
|
}); |
|
|
|
|
|
assert.notEqual(colors.color, colors.background); |
|
|
|
|
|
assert.equal(colors.color, 'rgb(5, 34, 30)', 'Dark theme uses dark text on mint action buttons'); |
|
|
|
|
|
await expect(panel.locator('summary')).toBeVisible(); |
|
|
|
|
|
await page.screenshot({path:`${output}/floating-dark.png`,fullPage:true}); |
|
|
|
|
|
checked('Dark theme floating controls use readable surface and button colors'); |
|
|
assert.deepEqual(pageErrors, []); |
|
|
assert.deepEqual(pageErrors, []); |
|
|
} catch (error) { |
|
|
} catch (error) { |
|
|
await page.screenshot({ path: `${output}/failure.png`, fullPage: true }); |
|
|
await page.screenshot({ path: `${output}/failure.png`, fullPage: true }); |
|
|
@@ -94,6 +122,6 @@ try { |
|
|
} finally { |
|
|
} finally { |
|
|
fs.writeFileSync(`${output}/checks.json`, JSON.stringify({ checks, pageErrors }, null, 2)); |
|
|
fs.writeFileSync(`${output}/checks.json`, JSON.stringify({ checks, pageErrors }, null, 2)); |
|
|
const escape = value => String(value).replace(/[&<>"']/g, ch => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[ch])); |
|
|
const escape = value => String(value).replace(/[&<>"']/g, ch => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[ch])); |
|
|
fs.writeFileSync(`${output}/index.html`, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>虚拟仿真按钮与操作准备验证</title><style>body{max-width:1100px;margin:32px auto;padding:0 20px;font:15px/1.7 system-ui;color:#163d37}img{width:100%;border:1px solid #cadcd7}h2{margin-top:32px}li{margin:8px 0}</style><h1>虚拟仿真按钮与操作准备验证</h1><p>2026-09-13 · 本地 6180 前端与真实教学接口 · 内部维护账号 · 任务 81 教员预览。未创建教学任务或学员成绩。</p><p>共 ${checks.length} 项通过;页面运行错误 ${pageErrors.length} 项。前端另执行 34 项测试与生产构建并通过。</p><ul>${checks.map(check=>`<li>通过:${escape(check.name)}</li>`).join('')}</ul><h2>操作顺序</h2><p>打开操作准备 → 选择数字压力表 → 确认本步安全条件 → 填写测量观察记录 → 保存准备记录 → 点击高亮卡车模型 → 下一步。测量记录应按实际观察填写。</p>${['buttons-1920','buttons-1440','preparation-required','step-completed'].map(name=>`<h2>${name}</h2><img src="${name}.png" alt="${name}">`).join('')}</html>`); |
|
|
|
|
|
|
|
|
fs.writeFileSync(`${output}/index.html`, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>虚拟仿真浮窗与空间布局验证</title><style>body{max-width:1100px;margin:32px auto;padding:0 20px;font:15px/1.7 system-ui;color:#163d37}img{width:100%;border:1px solid #cadcd7}h2{margin-top:32px}li{margin:8px 0}</style><h1>虚拟仿真浮窗与空间布局验证</h1><p>2026-09-13 · 本地 6180 前端与真实教学接口 · 内部维护账号 · 任务 81 教员预览。未创建教学任务或学员成绩。</p><p>共 ${checks.length} 项通过;页面运行错误 ${pageErrors.length} 项。本报告记录本轮实际完成的浏览器检查,未写入学员成绩。</p><ul>${checks.map(check=>`<li>通过:${escape(check.name)}</li>`).join('')}</ul><h2>操作顺序</h2><p>打开操作准备 → 选择数字压力表 → 确认本步安全条件 → 填写测量观察记录 → 保存准备记录 → 点击高亮卡车模型 → 下一步。测量记录应按实际观察填写。</p>${['workspace-1920','floating-1920','workspace-1440','floating-1440','workspace-1366','floating-1366','preparation-required','step-completed'].map(name=>`<h2>${name}</h2><img src="${name}.png" alt="${name}">`).join('')}</html>`); |
|
|
await browser.close(); |
|
|
await browser.close(); |
|
|
} |
|
|
} |