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

+185
View File
@@ -0,0 +1,185 @@
// 用最小 DOM 桩把 renderer/clock.js + resize.js 真的跑一遍(不需要 Electron、不需要显示器)
// 注意:桩终究不是真浏览器,它能证明「接线和算法对」,证明不了「鼠标手感」。
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) {
const el = {
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() {},
fire(type, ev) { (this.handlers[type] || []).forEach((fn) => fn(ev)); }
};
return el;
}
const els = new Map();
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
const handles = ['n','s','w','e','nw','ne','sw','se'].map((dir) => { const el = makeEl(`rz-${dir}`); el.dataset.dir = dir; return el; });
const body = makeEl('body');
const calls = { setBounds: [], closed: 0, rates: [] };
let rafCb = null;
let stateCb = null;
const RealDate = Date;
let fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
class FakeDate extends RealDate {
constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); }
static now() { return fakeNow; }
}
const sandbox = {
console,
Date: FakeDate,
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
ResizeObserver: class { observe() {} },
getComputedStyle: () => ({ webkitAppRegion: 'drag' }),
addEventListener: () => {},
document: {
body,
getElementById: byId,
querySelectorAll: (sel) => (sel === '.rz' ? handles : []),
createElementNS: () => makeEl('svg-node')
},
window: {}
};
sandbox.window = sandbox;
sandbox.window.clockApi = {
getState: () => Promise.resolve({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' }),
onState: (cb) => { stateCb = cb; },
setRate: (r) => { calls.rates.push(r); return Promise.resolve({}); },
setBounds: (b) => calls.setBounds.push(b),
getBoundsSync: () => ({ x: 400, y: 300, width: 520, height: 280 }),
closeClockWindow: () => { calls.closed++; return Promise.resolve({}); },
togglePause: () => Promise.resolve({}), fastForward: () => Promise.resolve({}),
setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}),
setAlwaysOnTop: () => Promise.resolve({}), toggleClockWindow: () => Promise.resolve({}),
closeControlWindow: () => Promise.resolve({})
};
console.log('--- 加载 ---');
let ctx;
try {
ctx = vm.createContext(sandbox);
vm.runInContext(read('renderer/resize.js'), ctx, { filename: 'resize.js' });
vm.runInContext(read('renderer/presented-time.js'), ctx, { filename: 'presented-time.js' });
vm.runInContext(read('renderer/clock.js'), ctx, { filename: 'clock.js' });
check('resize.js + clock.js 在同一全局作用域下加载无异常(无重复声明冲突)', true);
} catch (e) {
check('resize.js + clock.js 在同一全局作用域下加载无异常', false, e.message);
console.log('\n致命:渲染进程脚本无法加载'); process.exit(1);
}
setTimeout(() => {
console.log('--- 走时显示 ---');
check('数字视图渲染 HH:MM:SS', byId('digitalTime').textContent === '03:00:00', byId('digitalTime').textContent);
const expectDate = new RealDate(fakeNow);
const wk = ['周日','周一','周二','周三','周四','周五','周六'][expectDate.getDay()];
check('日期行正确', byId('digitalDate').textContent === `2026-09-14 ${wk}`, byId('digitalDate').textContent);
fakeNow += 3661 * 1000; // 前进 1 小时 1 分 1 秒
rafCb();
check('走 1 小时后数字变成 04:01:01', byId('digitalTime').textContent === '04:01:01', byId('digitalTime').textContent);
check('画面读数与模型公式一致', Number(body.dataset.displayMs) === fakeNow, body.dataset.displayMs);
console.log('--- 倍率 / 倒转 / 暂停(通过 IPC 下发状态) ---');
fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 60, lastRate: 60, style: 'digital' });
fakeNow += 1000;
rafCb();
check('倍率 60:1 秒真实时间后画面走了 60 秒', byId('digitalTime').textContent === '03:01:00', byId('digitalTime').textContent);
check('倍率角标显示 ×60', byId('badge').hidden === false && byId('badge').textContent === '×60', byId('badge').textContent);
// 每次下发状态都会把「当前显示时间」重新锚成基准,所以期望值要用同一公式算
const revBase = fakeNow;
stateCb({ baseMs: revBase, baseRealMs: revBase, rate: -1, lastRate: -1, style: 'digital' });
const revText0 = byId('digitalTime').textContent;
fakeNow += 5000;
rafCb();
const expectRev = new RealDate(revBase - 5000);
const expectRevText = [expectRev.getHours(), expectRev.getMinutes(), expectRev.getSeconds()]
.map((n) => String(n).padStart(2, '0')).join(':');
check('倒转:画面时间往回走(5 秒真实时间 = 倒退 5 秒)',
byId('digitalTime').textContent === expectRevText && byId('digitalTime').textContent < revText0,
`${revText0} → ${byId('digitalTime').textContent}(期望 ${expectRevText})`);
check('倍率角标显示「倒转 ×-1」', byId('badge').textContent === '倒转 ×-1', byId('badge').textContent);
const pauseBase = fakeNow;
stateCb({ baseMs: pauseBase, baseRealMs: pauseBase, rate: 0, lastRate: -1, style: 'digital' });
rafCb();
const frozen = byId('digitalTime').textContent;
fakeNow += 9000; rafCb();
check('暂停:9 秒真实时间过去,画面完全不动', byId('digitalTime').textContent === frozen, `${frozen} → ${byId('digitalTime').textContent}`);
check('暂停角标显示「暂停」', byId('badge').textContent === '暂停', byId('badge').textContent);
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' });
rafCb();
check('倍率回到 1 时角标隐藏', byId('badge').hidden === true);
console.log('--- 样式切换 ---');
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' });
check('切到表盘:analog 显示、digital 隐藏',
byId('analogView').hidden === false && byId('digitalView').hidden === true);
fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' });
fakeNow += 0; rafCb();
check('03:00:00 时时针指向 90°(3 点方向)', byId('hourHand').attrs.transform === 'rotate(90 0 0)', byId('hourHand').attrs.transform);
check('分针指向 0°', byId('minuteHand').attrs.transform === 'rotate(0 0 0)', byId('minuteHand').attrs.transform);
check('秒针指向 0°', byId('secondHand').attrs.transform === 'rotate(0 0 0)', byId('secondHand').attrs.transform);
fakeNow += 15000; rafCb(); // 15 秒
check('15 秒后秒针指向 90°', byId('secondHand').attrs.transform === 'rotate(90 0 0)', byId('secondHand').attrs.transform);
check('秒针按毫秒连续扫(15.5 秒 ≈ 93°)', (() => { fakeNow += 500; rafCb(); return byId('secondHand').attrs.transform === 'rotate(93 0 0)'; })(), byId('secondHand').attrs.transform);
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' });
check('切回数字:digital 显示、analog 隐藏',
byId('digitalView').hidden === false && byId('analogView').hidden === true);
console.log('--- 拖边缘改大小(模拟真实鼠标事件序列) ---');
const east = handles.find((h) => h.dataset.dir === 'e');
east.fire('pointerdown', { button: 0, screenX: 916, screenY: 440, pointerId: 1, preventDefault() {} });
east.fire('pointermove', { screenX: 996, screenY: 440, pointerId: 1 });
east.fire('pointermove', { screenX: 1036, screenY: 440, pointerId: 1 });
east.fire('pointerup', { pointerId: 1 });
const b1 = calls.setBounds.at(-1);
check('拖东边:宽度 520 → 640,左上角不动',
b1.width === 640 && b1.x === 400 && b1.y === 300 && b1.height === 280, JSON.stringify(b1));
const south = handles.find((h) => h.dataset.dir === 's');
south.fire('pointerdown', { button: 0, screenX: 660, screenY: 576, pointerId: 1, preventDefault() {} });
south.fire('pointermove', { screenX: 660, screenY: 636, pointerId: 1 });
south.fire('pointerup', { pointerId: 1 });
const b2 = calls.setBounds.at(-1);
check('拖南边:高度 280 → 340', b2.height === 340 && b2.y === 300, JSON.stringify(b2));
const west = handles.find((h) => h.dataset.dir === 'w');
west.fire('pointerdown', { button: 0, screenX: 400, screenY: 440, pointerId: 1, preventDefault() {} });
west.fire('pointermove', { screenX: 900, screenY: 440, pointerId: 1 });
west.fire('pointerup', { pointerId: 1 });
const b3 = calls.setBounds.at(-1);
check('拖西边超过最小宽度:停在 260,右边界不动(窗口不会被推走)',
b3.width === 260 && b3.x + b3.width === 920, JSON.stringify(b3));
const before = calls.setBounds.length;
south.fire('pointermove', { screenX: 660, screenY: 700, pointerId: 1 });
check('抬起鼠标后再移动不会继续改大小', calls.setBounds.length === before);
console.log('--- 关闭按钮 ---');
byId('closeBtn').fire('click', {});
check('点 × 调用了 closeClockWindow', calls.closed === 1, String(calls.closed));
console.log('');
if (!failures.length) { console.log(`渲染进程逻辑:全部通过 ${passed}/${passed}`); process.exit(0); }
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
}, 30);