feat: 支持教学图鉴

This commit is contained in:
richarjiang
2026-10-18 17:26:16 +08:00
parent 3635b2a906
commit 3dc56a113b
29 changed files with 3056 additions and 5 deletions

View File

@@ -0,0 +1,156 @@
// Dependency-free Chrome DevTools checks. Run after pnpm build:education.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import assert from 'node:assert/strict'
const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
assert.ok(existsSync(chromePath), 'Set CHROME_PATH to a local Chrome/Chromium executable')
const profile = await mkdtemp(join(tmpdir(), 'reformer-chrome-'))
const shots = process.argv[2] || '/tmp/reformer-checks'
await mkdir(shots, { recursive: true })
const chrome = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
let socket
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
try {
const port = await new Promise((done, fail) => {
const timer = setTimeout(() => fail(new Error('Chrome startup timed out')), 15000)
chrome.stderr.on('data', data => {
const match = String(data).match(/DevTools listening on ws:\/\/[^:]+:(\d+)/)
if (match) { clearTimeout(timer); done(Number(match[1])) }
})
chrome.on('error', fail)
chrome.on('exit', code => { clearTimeout(timer); fail(new Error(`Chrome exited: ${code}`)) })
})
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
socket = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl)
await new Promise(resolve => socket.addEventListener('open', resolve, { once: true }))
let id = 0
const pending = new Map(), errors = [], requests = []
socket.addEventListener('message', event => {
const data = JSON.parse(event.data)
if (data.id) {
const task = pending.get(data.id)
if (task) { pending.delete(data.id); clearTimeout(task.timer); data.error ? task.reject(new Error(data.error.message)) : task.resolve(data.result) }
}
if (data.method === 'Runtime.exceptionThrown') errors.push(data.params.exceptionDetails.text)
if (data.method === 'Log.entryAdded' && data.params.entry.level === 'error') errors.push(data.params.entry.text)
if (data.method === 'Network.requestWillBeSent') requests.push(data.params.request.url)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error(`${method} timed out`)) }, 12000)
pending.set(key, { resolve, reject, timer }); socket.send(JSON.stringify({ id: key, method, params }))
})
const evaluate = async expression => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
return result.result.value
}
const until = async expression => {
for (let i = 0; i < 80; i++) { if (await evaluate(expression)) return; await wait(50) }
throw new Error(`Condition timed out: ${expression}`)
}
const shot = async (name, width, height = 900) => {
await evaluate('window.scrollTo(0,0)'); await wait(80)
const { data } = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width, height, scale: 1 } })
await writeFile(join(shots, `${name}.png`), Buffer.from(data, 'base64'))
}
for (const method of ['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable']) await send(method)
const url = pathToFileURL(resolve('packages/server/public/learn/reformer/index.html')).href
const navigate = async (width, reduced = false, noScript = false) => {
await send('Emulation.setDeviceMetricsOverride', { width, height: 920, deviceScaleFactor: 1, mobile: width < 700 })
await send('Emulation.setTouchEmulationEnabled', { enabled: width < 700, maxTouchPoints: 1 })
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' }] })
await send('Emulation.setScriptExecutionDisabled', { value: noScript })
await send('Page.navigate', { url })
await wait(150)
if (!noScript) await until('document.body.classList.contains("ready")')
}
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
const setRange = (selector, value) => evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${value};el.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
const noOverflow = async () => assert.equal(await evaluate('document.documentElement.scrollWidth > innerWidth'), false, 'horizontal overflow')
for (const width of [375, 390, 430, 1100]) {
await navigate(width, true); await noOverflow()
assert.equal(await evaluate("/教练审阅版|教练已审核|尚未完成审核|待教练审核|人体教学暂未开放/.test(document.body.innerText)"), false, 'editorial labels and unavailable feature placeholders must not appear')
assert.equal(await evaluate("document.querySelector('#teaching-media').hidden"), true, 'unconfigured media stays out of the reading flow')
for (const value of [0, 25, 50, 75, 100]) {
await setRange('#timeline', value)
await until(`Math.abs(Number(document.querySelector('.stage').dataset.progress)-${value / 100})<0.0002`)
await noOverflow()
const bbox = await evaluate(`(()=>{const b=document.querySelector('.reformer-svg').getBBox();return {x:b.x,y:b.y,right:b.x+b.width,bottom:b.y+b.height}})()`)
assert.ok(bbox.x >= 0 && bbox.y >= 0 && bbox.right <= 760 && bbox.bottom <= 475, `drawing clipped at ${width}px / ${value}%: ${JSON.stringify(bbox)}`)
}
await setRange('#timeline', 0); await wait(40)
await shot(`assembled-${width}`, width, width < 700 ? 920 : 1000)
await click('[data-view="exploded"]'); await until("document.querySelector('.stage').dataset.explode==='1'")
await noOverflow(); await shot(`exploded-${width}`, width, 920)
await click('[data-view="assembled"]'); await wait(40)
await click('[data-part-button="pulleys"]')
assert.equal(await evaluate("document.querySelector('#part-name').textContent"), '滑轮')
assert.equal(await evaluate("document.querySelectorAll('[data-component=\"pulleys\"].is-selected').length"), 1)
await click('[data-preset="b1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1)
await click('[data-spring-button="b1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1, 'must retain one connection')
await click('[data-preset="r1,r2,r3,b1,g1"]')
assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 5)
await setRange('#position', 70); await until("Math.abs(Number(document.querySelector('.stage').dataset.position)-0.7)<0.001")
await click('#static-toggle'); assert.equal(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height>0"), true)
await click('#static-toggle'); assert.equal(await evaluate("document.body.classList.contains('ready')"), true)
console.log(`${width}px: layout, geometry bounds, selection, springs, manual sliders, static toggle passed`)
}
await navigate(390)
await click('[data-part-button="carriage"]') // cancels the initial tour
await click('#play'); await wait(450); await click('#play')
const paused = await evaluate("Number(document.querySelector('.stage').dataset.progress)")
await wait(200)
assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), paused, 'pause must hold the position')
assert.ok(paused > 0, 'playback did not advance')
await setRange('#timeline', 75); await until("Math.abs(Number(document.querySelector('.stage').dataset.progress)-0.75)<0.0002")
await click('#play'); await wait(250); await click('#play')
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) > 0.75"), 'resume should continue from seek position')
await click('#replay'); await wait(150); await click('#play')
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) < 0.1"), 'replay should restart')
await click('[data-view="exploded"]'); await wait(120); await click('[data-view="assembled"]')
await until("Number(document.querySelector('.stage').dataset.explode)<0.0002")
// Actual touch input on the native range control, with the sticky diagram still in view.
await evaluate("document.querySelector('#position').scrollIntoView({block:'end'})")
await wait(100)
const rect = await evaluate("(()=>{const r=document.querySelector('#position').getBoundingClientRect();return {x:r.x,y:r.y,w:r.width,h:r.height}})()")
const touch = (type, x) => send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y: rect.y + rect.h / 2, id: 1 }] })
await touch('touchStart', rect.x + 20)
for (let i = 0; i <= 12; i++) { await touch('touchMove', rect.x + 20 + i / 12 * (rect.w - 40)); await wait(20) }
await touch('touchEnd', 0); await wait(900)
assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.position)>0.7"), 'touch drag failed')
assert.ok(await evaluate("document.querySelector('.diagram-panel').getBoundingClientRect().bottom > 0"), 'diagram must remain visible while adjusting slider')
const screen = await send('Page.captureScreenshot', { format: 'png' })
await writeFile(join(shots, 'touch-controls.png'), Buffer.from(screen.data, 'base64'))
console.log('Playback: pause, seek, resume, replay, mid-transition reversal and touch drag passed')
await navigate(390, false, true)
await wait(300)
// CDP evaluation remains usable while page-owned scripts are disabled.
assert.ok(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height > 100"), 'no-JS parts must be visible')
assert.equal(await evaluate("document.querySelector('.controls').getBoundingClientRect().height"), 0)
await shot('no-javascript', 390, 1600)
await navigate(390, true)
await wait(2000)
assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), 0, 'reduced motion must not start the tour')
assert.equal(await evaluate("document.querySelector('#play').disabled"), true)
if (process.argv.includes('--write-native-fallback')) {
const svg = await readFile('packages/server/public/learn/reformer/reformer.svg', 'utf8')
const data = await evaluate(`(async()=>{const image=new Image();image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(${JSON.stringify(svg)});await image.decode();const canvas=document.createElement('canvas');canvas.width=1140;canvas.height=713;canvas.getContext('2d').drawImage(image,0,0,1140,713);return canvas.toDataURL('image/png').split(',')[1]})()`)
await writeFile('packages/app/src/static/learn/reformer.png', Buffer.from(data, 'base64'))
console.log('Updated native PNG fallback from the standalone SVG.')
}
assert.deepEqual(errors, [], 'browser console errors')
assert.deepEqual(requests.filter(url => /^https?:/.test(url)), [], 'standalone page must not request external assets')
console.log(`No-JS fallback, reduced motion, zero external requests and clean browser console passed. Screenshots: ${shots}`)
} finally {
socket?.close(); chrome.kill('SIGKILL'); await wait(200); await rm(profile, { recursive: true, force: true })
}