Files
ClockLab/test/renderer-clock.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

186 lines
10 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 桩把 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);