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

133 lines
8.8 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/control.js 跑一遍,重点验证「暂停/继续」按钮的状态文案
// 注意:桩不是真浏览器,只能证明接线与状态同步正确。
const fs = require('fs'); const vm = require('vm'); const path = require('path');
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 classes = new Set();
return {
id, textContent: '', value: '', checked: false, hidden: false, style: {}, dataset: {}, handlers: {}, children: [],
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), contains: (c) => classes.has(c) },
_classes: classes,
appendChild(c) { this.children.push(c); }, setAttribute() {}, getAttribute() {}, getBoundingClientRect: () => ({ width: 800, height: 600 }),
addEventListener(t, fn) { (this.handlers[t] ||= []).push(fn); },
fire(t, ev = {}) { (this.handlers[t] || []).forEach((fn) => fn({ target: this, ...ev })); }
};
}
const els = new Map();
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
const segButtons = ['digital', 'analog'].map((s) => { const el = makeEl(`seg-${s}`); el.dataset.style = s; return el; });
const styleGroup = makeEl('styleGroup');
styleGroup.querySelectorAll = () => segButtons;
const rateChips = ['-1','-2','0','0.5','1','2','10','60'].map((r) => { const el = makeEl(`chip-${r}`); el.dataset.rate = r; return el; });
const fastChips = ['5000','60000','300000','600000','1800000'].map((d) => { const el = makeEl(`fast-${d}`); el.dataset.delta = d; return el; });
const allChips = [...rateChips, ...fastChips];
const body = makeEl('body');
const calls = { setRate: [], fastForward: [], setTime: [], setStyle: [], aot: [], toggleClock: 0, togglePause: 0 };
let stateCb = null;
const sandbox = {
console,
requestAnimationFrame: () => 1,
document: {
body, activeElement: null,
getElementById: (id) => (id === 'styleGroup' ? styleGroup : byId(id)),
querySelectorAll: () => [], createElementNS: () => makeEl('x')
},
window: {}
};
sandbox.window = sandbox;
sandbox.window.clockApi = {
getState: () => Promise.resolve({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, lastRate: 1, style: 'digital', clockWindowOpen: false, alwaysOnTop: { clock: true, control: false } }),
onState: (cb) => { stateCb = cb; },
setRate: (r) => { calls.setRate.push(r); return Promise.resolve({}); },
fastForward: (d) => { calls.fastForward.push(d); return Promise.resolve({}); },
setTime: (h, m, s) => { calls.setTime.push([h, m, s]); return Promise.resolve({}); },
setStyle: (s) => { calls.setStyle.push(s); return Promise.resolve({}); },
setAlwaysOnTop: (t, v) => { calls.aot.push([t, v]); return Promise.resolve({}); },
toggleClockWindow: () => { calls.toggleClock++; return Promise.resolve({}); },
togglePause: () => { calls.togglePause++; return Promise.resolve({}); },
closeClockWindow: () => Promise.resolve({}), closeControlWindow: () => Promise.resolve({}),
getBoundsSync: () => null, setBounds: () => {}
};
const ctx = vm.createContext(sandbox);
try { vm.runInContext(read('renderer/control.js'), ctx, { filename: 'control.js' }); check('control.js 加载无异常', true); }
catch (e) { check('control.js 加载无异常', false, e.message); process.exit(1); }
setTimeout(() => {
console.log('--- 初始状态 ---');
check('时钟窗口按钮初始为「打开时钟窗口」', byId('toggleClockWindow').textContent === '打开时钟窗口', byId('toggleClockWindow').textContent);
check('暂停按钮初始文案为「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
check('时钟窗口置顶默认勾选', byId('aotClock').checked === true);
check('控制窗口置顶默认不勾选', byId('aotControl').checked === false);
console.log('--- 暂停 / 继续按钮 ---');
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 0, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } });
check('暂停后按钮变成「继续(回到 ×2)」', byId('togglePause').textContent === '继续(回到 ×2)', byId('togglePause').textContent);
check('暂停时按钮有高亮状态 is-on', byId('togglePause')._classes.has('is-on'));
check('暂停时倍率输入框显示 0', byId('rate').value === '0', byId('rate').value);
check('时钟窗口按钮反映已打开', byId('toggleClockWindow').textContent === '关闭时钟窗口', byId('toggleClockWindow').textContent);
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 2, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } });
check('继续后按钮文案回到「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
check('继续后高亮消失', !byId('togglePause')._classes.has('is-on'));
check('继续后倍率输入框显示 2', byId('rate').value === '2', byId('rate').value);
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: -1, lastRate: -1, style: 'analog', clockWindowOpen: true, alwaysOnTop: { clock: false, control: true } });
check('负倍率时按钮仍是「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
check('样式按钮高亮跟随状态(analog)', segButtons[1]._classes.has('is-active') && !segButtons[0]._classes.has('is-active'));
check('置顶勾选框跟随状态', byId('aotClock').checked === false && byId('aotControl').checked === true);
console.log('--- 点击行为 ---');
byId('togglePause').fire('click');
check('点暂停按钮调用 togglePause', calls.togglePause === 1);
byId('toggleClockWindow').fire('click');
check('点时钟窗口按钮调用 toggleClockWindow', calls.toggleClock === 1);
segButtons[0].fire('click');
check('点「纯数字」调用 setStyle(digital)', calls.setStyle.at(-1) === 'digital', String(calls.setStyle.at(-1)));
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '-1') });
check('点「-1 倒转」调用 setRate(-1)', calls.setRate.at(-1) === -1, String(calls.setRate.at(-1)));
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0.5') });
check('点「0.5」调用 setRate(0.5)', calls.setRate.at(-1) === 0.5, String(calls.setRate.at(-1)));
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0') });
check('点「0 暂停」调用 setRate(0)', calls.setRate.at(-1) === 0, String(calls.setRate.at(-1)));
for (const d of ['5000','60000','300000','600000','1800000']) body.fire('click', { target: fastChips.find((c) => c.dataset.delta === d) });
check('五个快进按钮分别调用 fastForward(5000/60000/300000/600000/1800000)',
JSON.stringify(calls.fastForward) === JSON.stringify([5000, 60000, 300000, 600000, 1800000]), JSON.stringify(calls.fastForward));
byId('rate').value = '-3.5';
byId('applyRate').fire('click');
check('手动输入倍率 -3.5 生效', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1)));
byId('rate').value = 'abc';
byId('applyRate').fire('click');
check('倍率输入非法时回退到当前值,不发送 NaN', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1)));
byId('hh').value = '25'; byId('mm').value = '70'; byId('ss').value = '-3';
byId('applyTime').fire('click');
check('时间输入越界被夹到 23:59:00', JSON.stringify(calls.setTime.at(-1)) === JSON.stringify([23, 59, 0]), JSON.stringify(calls.setTime.at(-1)));
check('夹紧后的值回填到输入框', byId('hh').value === '23' && byId('mm').value === '59' && byId('ss').value === '0');
byId('fillNow').fire('click');
check('「填入当前显示时间」把输入框填成合法时间',
/^\d{2}$/.test(byId('hh').value) && /^\d{2}$/.test(byId('mm').value) && /^\d{2}$/.test(byId('ss').value),
`${byId('hh').value}:${byId('mm').value}:${byId('ss').value}`);
byId('aotClock').checked = false; byId('aotClock').fire('change');
check('取消时钟置顶调用 setAlwaysOnTop(clock,false)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['clock', false]), JSON.stringify(calls.aot.at(-1)));
byId('aotControl').checked = true; byId('aotControl').fire('change');
check('打开控制窗口置顶调用 setAlwaysOnTop(control,true)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['control', true]), JSON.stringify(calls.aot.at(-1)));
console.log('');
if (!failures.length) { console.log(`控制窗口逻辑:全部通过 ${passed}/${passed}`); process.exit(0); }
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
}, 30);