import { chromium, expect } from '@playwright/test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { login, call } from './role-permissions-live.mjs'; import manifest from '../../unreal_tran_web/src/features/virtual-training-scripts/narration-manifest.js'; import { scriptTrainingDefinition } from '../../unreal_tran_web/src/features/virtual-training-scripts/registry.js'; const dir = 'reports/bridge-narration-20260914'; fs.mkdirSync(dir, { recursive: true }); const root = await login('root'); const response = await call('tran/v1/teaching/assignments?channel=VIRTUAL&assignmentKind=TRAINING&size=100', root.accessToken); assert.equal(response.status, 200); const tasks = manifest.scripts.map(s => response.data.records.find(t => t.code === s.code)); const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader'] }); const results = [], errors = []; try { const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); await page.addInitScript(token => sessionStorage.setItem('unreal-tran:web:access-token:v1', token), root.accessToken); page.on('pageerror', e => errors.push(e.message)); const ready = async () => { await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, { timeout: 120000 }); await expect(page.locator('[data-role="step-count"]')).toBeVisible(); }; const audioState = () => page.locator('.training-server-video').evaluate(a => ({ src: new URL(a.currentSrc || a.src, location.href).pathname, paused: a.paused, time: a.currentTime, error: a.error?.message })); const play = async expected => { const launch = page.locator('[data-role="teacher-launch"]'); if (await launch.isVisible()) await launch.click(); await page.locator('[data-action="teacher-speak"]').click(); await expect.poll(async () => (await audioState()).src).toBe(expected); await expect.poll(async () => (await audioState()).time, { timeout: 15000 }).toBeGreaterThan(.1); assert.equal((await audioState()).error, undefined); }; for (const [index, task] of tasks.entries()) { assert.ok(task, manifest.scripts[index].code); const pack = manifest.scripts[index], bound = scriptTrainingDefinition(task); assert.deepEqual(bound.steps.map(s => s.audio.narrationUrl), pack.steps.map(s => s.url)); await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/preview/${task.id}`); await ready(); await play(pack.steps[0].url); const toggle = page.locator('[data-action="toggle-teacher-voice"]'); if (await toggle.getAttribute('aria-pressed') === 'true') await toggle.click(); // Complete the real first-step model actions in preview, then change step. for (let op = 0; op < task.definitionSnapshot.steps[0].action.mechanism.steps.length; op++) { await page.locator('[data-role="part-label"]').click(); if (index > 0) await expect.poll(() => page.evaluate(() => globalThis.__trainingSceneDiagnostics().bridgeWorkshop.pending)).toBe(false); else await page.waitForTimeout(1800); } await expect(page.locator('[data-action="next"]')).toBeEnabled({ timeout: 20000 }); await play(pack.steps[0].url); await page.locator('[data-action="next"]').click(); await expect(page.locator('[data-role="step-count"]')).toHaveText(`2 / ${pack.steps.length}`); assert.equal((await audioState()).paused, true, 'Old narration stops on step change'); await play(pack.steps[1].url); await page.screenshot({ path: `${dir}/${task.code}-step-2.png` }); await page.reload(); await ready(); await play(pack.steps[0].url); await page.locator('.training-teacher-bubble [data-action="toggle-teacher"]').click(); assert.equal((await audioState()).paused, true, 'Closing instructor stops narration'); results.push({ code: task.code, stepsBound: pack.steps.length, firstAndSecondPlayed: true, stopOnStepChange: true, reloadPlayed: true }); console.log(task.code, 'play / step change / reload passed'); } // Download and decode every published clip with the browser's actual codec. const clips = manifest.scripts.flatMap(s => s.steps.flatMap(step => [{ url: step.url, duration: step.stepDuration }, ...(step.safetyUrl ? [{ url: step.safetyUrl, duration: step.safetyDuration }] : [])])); const decoded = await page.evaluate(async clips => { const context = new AudioContext(), results = []; try { for (const clip of clips) { const response = await fetch(clip.url); if (!response.ok) throw new Error(`Audio HTTP ${response.status}: ${clip.url}`); const buffer = await context.decodeAudioData(await response.arrayBuffer()); if (Math.abs(buffer.duration - clip.duration) > .02) throw new Error(`Duration mismatch: ${clip.url}`); results.push({ url: clip.url, duration: buffer.duration, sampleRate: buffer.sampleRate }); } } finally { await context.close(); } return results; }, clips); assert.equal(decoded.length, 42); assert.deepEqual(errors, []); fs.writeFileSync(`${dir}/result.json`, JSON.stringify({ results, decoded, errors }, null, 2)); fs.writeFileSync(`${dir}/index.html`, `桥梁脚本语音验收

四套桥梁脚本语音验收

39段步骤讲解、3段核对提醒,全部真实下载和浏览器解码通过。四个原任务前两步实际播放、切步停止、刷新重播、关闭停止通过。教员预览未写入成绩。

${manifest.scripts.map(s => `

${s.code} ${s.title}

${s.source} · ${s.steps.length}步

${s.steps.map(step => `

${step.text}

`).join('')}
`).join('')}`); console.log('PASS', decoded.length, 'WAVs decoded'); } finally { await browser.close(); }