feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)

一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、
可拖动、可拖边缘改大小,由控制窗口开与关)。

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

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

测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
This commit is contained in:
AskaEth committed 2026-09-15 01:31:07 +08:00
1 parent 892b9190aa
commit 9c7081b21d
27 files changed
+5467 -2

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
// 用最小 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);