// 用最小 DOM 桩把「跳转动画」这条链路真的跑一遍:主进程下发参数 → 呈现时间层 → 数字 / 表盘 // node test/renderer-clock-anim.dom-test.js // 桩终究不是真浏览器:它能证明「缓动从哪里出发、落在哪里、被打断时连不连续」, // 证明不了「滚起来顺不顺眼」——那部分只能在 Windows 上肉眼确认。 const fs = require('fs'); const path = require('path'); const vm = require('vm'); const root = path.join(__dirname, '..'); const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); let passed = 0; const failures = []; const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } }; function makeEl(id) { return { id, textContent: '', hidden: false, style: {}, dataset: {}, children: [], attrs: {}, handlers: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, appendChild(c) { this.children.push(c); }, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k]; }, getBoundingClientRect: () => ({ width: 520, height: 280, top: 0, left: 0 }), addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); }, setPointerCapture() {}, releasePointerCapture() {} }; } const els = new Map(); const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); }; const body = makeEl('body'); const RealDate = Date; const T0 = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); // 03:00:00.000 const MIN = 60 * 1000; let fakeNow = T0; class FakeDate extends RealDate { constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); } static now() { return fakeNow; } } // 主进程下发的参数就是这个形状;这里照原样造 const snap = (baseMs, baseRealMs, rate, style = 'digital') => ({ baseMs, baseRealMs, rate, lastRate: rate, style, paused: rate === 0, clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } }); let rafCb = null; let stateCb = null; const sandbox = { console, Date: FakeDate, requestAnimationFrame: (cb) => { rafCb = cb; return 1; }, ResizeObserver: class { observe() {} }, getComputedStyle: () => ({}), addEventListener: () => {}, document: { body, getElementById: byId, querySelectorAll: () => [], createElementNS: () => makeEl('svg-node') }, window: {} }; sandbox.window = sandbox; sandbox.window.clockApi = { getState: () => Promise.resolve(snap(T0, T0, 1)), onState: (cb) => { stateCb = cb; }, setRate: () => Promise.resolve({}), fastForward: () => Promise.resolve({}), setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}), togglePause: () => Promise.resolve({}), setAlwaysOnTop: () => Promise.resolve({}), toggleClockWindow: () => Promise.resolve({}), closeClockWindow: () => Promise.resolve({}), closeControlWindow: () => Promise.resolve({}), getBoundsSync: () => null, setBounds: () => {} }; const ctx = vm.createContext(sandbox); try { for (const file of ['renderer/resize.js', 'renderer/presented-time.js', 'renderer/clock.js']) { vm.runInContext(read(file), ctx, { filename: file }); } check('渲染进程脚本加载无异常', true); } catch (e) { check('渲染进程脚本加载无异常', false, e.message); console.log('\n致命:渲染进程脚本无法加载'); process.exit(1); } // —— 读数小工具 —— const frame = () => rafCb(); // 渲染一帧(真实时间就是 fakeNow) const advance = (ms) => { fakeNow += ms; rafCb(); }; const shown = () => Number(body.dataset.displayMs); const text = () => byId('digitalTime').textContent; const rot = (el) => Number(/rotate\(([-\d.eE]+)/.exec(el.attrs.transform)[1]); const targetOf = (s, now) => s.baseMs + (now - s.baseRealMs) * s.rate; // 与 clock.js 同一套角度公式:秒针按毫秒连续走 function anglesFor(ms) { const d = new RealDate(ms); const seconds = d.getSeconds() + d.getMilliseconds() / 1000; const minutes = d.getMinutes() + seconds / 60; const hours = (d.getHours() % 12) + minutes / 60; return { hour: hours * 30, minute: minutes * 6, second: seconds * 6 }; } // 三根指针是否都严格由「当前呈现时间」推出来(差 0.05° 以内 ≈ 0.1ms 的误差) function handsMatchPresented() { const expect = anglesFor(shown()); return Math.abs(rot(byId('hourHand')) - expect.hour) < 0.05 && Math.abs(rot(byId('minuteHand')) - expect.minute) < 0.05 && Math.abs(rot(byId('secondHand')) - expect.second) < 0.05; } setTimeout(() => { console.log('--- 平时不动画 ---'); frame(); check('初始显示 03:00:00', text() === '03:00:00', text()); advance(1000); check('走 1 秒就是 1 秒(动画层不拖后腿)', text() === '03:00:01', text()); console.log('--- 快进 +30 分钟:数字滚过去 ---'); const before = shown(); stateCb(snap(before + 30 * MIN, fakeNow, 1)); // 主进程 shift:以此刻的显示时间为准整体平移 frame(); check('跳变当帧数字还在旧值上(从这里开始滚)', shown() === before && text() === '03:00:01', `${text()} / ${shown() - before}`); advance(175); const mid = shown(); check('半程时数字落在两者之间', mid > before && mid < before + 30 * MIN, `${Math.round(mid - before)}ms`); check('半程时数字确实换过(不是干等)', text() !== '03:00:01' && text() !== '03:30:01', text()); advance(175); check('350ms 后落在 +30 分钟那一刻', shown() === before + 30 * MIN + 350, `${shown() - (before + 30 * MIN + 350)}ms`); check('落地后显示 03:30:01', text() === '03:30:01', text()); console.log('--- 小跳变直接跳 ---'); const smallBase = shown(); stateCb(snap(smallBase + 1000, fakeNow, 1)); frame(); check('+1 秒:当帧就直接到新值,不缓动', shown() === smallBase + 1000, `${shown() - smallBase}ms`); console.log('--- 改倍率不是跳变 ---'); const rateBase = shown(); stateCb(snap(rateBase, fakeNow, 60)); frame(); check('切 ×60:当帧显示时间不变(没有动画)', shown() === rateBase, `${shown() - rateBase}ms`); advance(100); check('切 ×60:之后立刻按新倍率走', shown() === rateBase + 6000, `${shown() - rateBase}ms`); console.log('--- 表盘:指针从同一个呈现时间扫过去 ---'); const T11 = new RealDate(2026, 8, 14, 11, 0, 0, 0).getTime(); const digitalBefore = shown(); stateCb(snap(T11, fakeNow, 1, 'analog')); frame(); check('切表盘 + 跳变当帧:指针仍指在旧时间上', Math.abs(rot(byId('hourHand')) - anglesFor(digitalBefore).hour) < 0.05, `${rot(byId('hourHand'))}° vs 目标 ${anglesFor(T11).hour}°`); check('当帧还没到 11 点', Math.abs(rot(byId('hourHand')) - 330) > 1, `${rot(byId('hourHand'))}°`); advance(175); check('半程时三根指针都由当前呈现时间推出', handsMatchPresented(), `时针 ${rot(byId('hourHand')).toFixed(2)}°`); advance(175); check('落地后时针指向 11 点整(330°)', Math.abs(rot(byId('hourHand')) - 330) < 0.05, `${rot(byId('hourHand'))}°`); check('落地后三根指针依然与呈现时间一致', handsMatchPresented()); console.log('--- 跨 12 点:往前扫,不倒着转一圈 ---'); // 11:00 → 次日 01:00(跨过 12 点),缓动过程中时间必须单调向前 const T01 = new RealDate(2026, 8, 15, 1, 0, 0, 0).getTime(); stateCb(snap(T01, fakeNow, 1, 'analog')); const samples = []; let handsAligned = true; for (let i = 0; i <= 7; i += 1) { if (i > 0) fakeNow += 50; rafCb(); samples.push(shown()); if (!handsMatchPresented()) handsAligned = false; } check('缓动全程三根指针都由当前呈现时间推出(含跨 12 点那几帧)', handsAligned); let monotonic = true; for (let i = 1; i < samples.length; i++) if (samples[i] <= samples[i - 1]) monotonic = false; check('缓动全程呈现时间单调向前(角度按公式跟随,所以不会倒着转一圈)', monotonic, `${samples[0]} → ${samples[samples.length - 1]}`); advance(350); check('跨 12 点后落在次日 01:00', Math.abs(rot(byId('hourHand')) % 360 - 30) < 1, `${rot(byId('hourHand'))}°`); console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---'); stateCb(snap(shown() + 30 * MIN, fakeNow, 1, 'digital')); advance(100); const midFlight = shown(); const retargetTo = shown() + MIN; // 主进程在模型目标上再 +1 分钟 stateCb(snap(retargetTo, fakeNow, 1, 'digital')); frame(); check('重定向当帧不弹回跳变前的旧值', shown() === midFlight, `${shown() - midFlight}ms`); advance(350); check('重定向后落在新目标上', shown() === targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow), `${shown()} vs ${targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow)}`); console.log('--- 暂停不缓动 ---'); const pausedAt = shown(); stateCb(snap(pausedAt, fakeNow, 0)); frame(); check('暂停当帧就是暂停时刻的准确时间', shown() === pausedAt, `${shown() - pausedAt}ms`); advance(5000); check('暂停后画面纹丝不动', shown() === pausedAt, `${shown() - pausedAt}ms`); // 缓动没结束时按暂停:不能挂着走不完的中间值 stateCb(snap(pausedAt + 30 * MIN, fakeNow, 1)); advance(100); const inFlight = shown(); check('恢复后先正常滚起来', inFlight > pausedAt && inFlight < pausedAt + 30 * MIN, `${Math.round(inFlight - pausedAt)}ms`); stateCb(snap(pausedAt + 30 * MIN, fakeNow, 0)); frame(); check('缓动中按暂停:立刻落到暂停时刻的准确值', shown() === pausedAt + 30 * MIN, `${shown() - (pausedAt + 30 * MIN)}ms`); advance(5000); check('暂停后继续保持该值', shown() === pausedAt + 30 * MIN); console.log(''); if (!failures.length) { console.log(`跳转动画链路:全部通过 ${passed}/${passed}`); process.exit(0); } console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1); }, 30);