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