一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、 可拖动、可拖边缘改大小,由控制窗口开与关)。 功能 - 两个窗口各自的「始终置顶」开关 - 显示样式:纯数字 / 圆形表盘,切换立即生效 - 实时设定时间(含「填入当前显示时间」) - 倍率:支持正数、负数(倒转)、0(暂停)、小数 - 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍) - 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟 实现要点 - 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移); 倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架 - 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值, 所以永远同步,也不会跨 12 点倒着转一圈 - 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小 (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠) 测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
219 lines
9.9 KiB
JavaScript
219 lines
9.9 KiB
JavaScript
// 用最小 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);
|