Files
ClockLab/test/renderer-clock-anim.dom-test.js
T
AskaEth 9c7081b21d feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)
一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、
可拖动、可拖边缘改大小,由控制窗口开与关)。

功能
- 两个窗口各自的「始终置顶」开关
- 显示样式:纯数字 / 圆形表盘,切换立即生效
- 实时设定时间(含「填入当前显示时间」)
- 倍率:支持正数、负数(倒转)、0(暂停)、小数
- 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍)
- 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟

实现要点
- 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移);
  倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架
- 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值,
  所以永远同步,也不会跨 12 点倒着转一圈
- 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小
  (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠)

测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
2026-09-15 01:31:07 +08:00

219 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 用最小 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);