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