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

+167
View File
@@ -0,0 +1,167 @@
'use strict';
// 纯逻辑自测(不需要 Electron、不需要显示器):
// node test/clock-model.test.js
// 走时模型是所有功能的地基,这里把它单独钉死,GUI 的问题就不会和算法问题混在一起。
const clockModel = require('../src/clock-model');
let passed = 0;
const failures = [];
function check(name, ok, detail) {
if (ok) {
passed += 1;
console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`);
} else {
failures.push(name);
console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`);
}
}
const T0 = 1_700_000_000_000; // 固定基准时刻,测试不依赖真实时钟
const SECOND = 1000;
console.log('--- 正常走时 ---');
{
const c = clockModel.create(T0);
check('初始倍率为 1', c.rate === 1);
check('走 1 秒真实时间 = 1 秒显示时间', clockModel.displayMs(c, T0 + SECOND) === T0 + SECOND);
check('走 1 小时真实时间 = 1 小时显示时间', clockModel.displayMs(c, T0 + 3600 * SECOND) === T0 + 3600 * SECOND);
}
{
// 分 3600 次推进,验证「基准 + 经过时间 × 倍率」不会像逐帧累加那样漂移
const c = clockModel.create(T0);
let now = T0;
for (let i = 0; i < 3600; i += 1) {
now += SECOND;
clockModel.displayMs(c, now);
}
check('一小时逐秒推进后零漂移', clockModel.displayMs(c, now) === T0 + 3600 * SECOND);
}
console.log('--- 倍率 ---');
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 2);
check('倍率 2:1 秒真实时间走 2 秒', clockModel.displayMs(c, T0 + SECOND) === T0 + 2 * SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 0.5);
check('倍率 0.5:半速', clockModel.displayMs(c, T0 + SECOND) === T0 + 500);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, -1);
check('倍率 -1:走 1 秒真实时间倒退 1 秒', clockModel.displayMs(c, T0 + SECOND) === T0 - SECOND);
check('倍率 -1:再走 5 秒仍在继续倒退(不是卡住)',
clockModel.displayMs(c, T0 + 6 * SECOND) === T0 - 6 * SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, -2.5);
check('倍率 -2.5:小数 + 负数同时成立', clockModel.displayMs(c, T0 + 4 * SECOND) === T0 - 10 * SECOND);
}
console.log('--- 切换倍率不跳变 ---');
{
const c = clockModel.create(T0);
const before = clockModel.displayMs(c, T0 + 3000);
clockModel.setRate(c, T0 + 3000, 7);
const after = clockModel.displayMs(c, T0 + 3000);
check('切倍率的瞬间显示时间连续(同一时刻前后相等)', before === after, `${before} → ${after}`);
check('切换后按新倍率走', clockModel.displayMs(c, T0 + 4000) === before + 7 * SECOND);
}
console.log('--- 暂停 / 继续 ---');
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 4);
clockModel.setRate(c, T0 + SECOND, 0);
const frozenAt = clockModel.displayMs(c, T0 + SECOND);
check('倍率 0:显示时间冻结', clockModel.displayMs(c, T0 + 60 * SECOND) === frozenAt);
check('暂停时记住了暂停前的倍率 4', c.lastRate === 4);
clockModel.setRate(c, T0 + 60 * SECOND, c.lastRate);
check('继续:从暂停处按原倍率接着走',
clockModel.displayMs(c, T0 + 61 * SECOND) === frozenAt + 4 * SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 0);
check('倍率一直是 0 时 lastRate 保持默认 1', c.lastRate === 1);
}
console.log('--- 快进 / 快退 ---');
{
const c = clockModel.create(T0);
const before = clockModel.displayMs(c, T0 + 2000);
clockModel.shift(c, T0 + 2000, 5 * SECOND);
check('+5 秒:显示时间立刻加 5 秒', clockModel.displayMs(c, T0 + 2000) === before + 5 * SECOND);
check('快进后基准确实变了', c.baseMs === before + 5 * SECOND && c.baseRealMs === T0 + 2000);
check('快进后继续按倍率走', clockModel.displayMs(c, T0 + 3000) === before + 6 * SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, -1);
const before = clockModel.displayMs(c, T0 + 10 * SECOND);
clockModel.shift(c, T0 + 10 * SECOND, 30 * 60 * SECOND);
check('负倍率下 +30 分钟:先固化显示时间再平移(不与倒转打架)',
clockModel.displayMs(c, T0 + 10 * SECOND) === before + 30 * 60 * SECOND);
check('平移后负倍率继续生效',
clockModel.displayMs(c, T0 + 11 * SECOND) === before + 30 * 60 * SECOND - SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 0);
const before = clockModel.displayMs(c, T0 + 1000);
clockModel.shift(c, T0 + 1000, 10 * 60 * SECOND);
check('暂停状态下快进仍然生效,且之后继续冻结',
clockModel.displayMs(c, T0 + 60 * SECOND) === before + 10 * 60 * SECOND);
}
{
// 五个快进按钮的档位
const deltas = [5 * SECOND, 60 * SECOND, 5 * 60 * SECOND, 10 * 60 * SECOND, 30 * 60 * SECOND];
const c = clockModel.create(T0);
const before = clockModel.displayMs(c, T0);
for (const d of deltas) clockModel.shift(c, T0, d);
const total = deltas.reduce((a, b) => a + b, 0);
check('五档快进累加正确(+5s/+1m/+5m/+10m/+30m)',
clockModel.displayMs(c, T0) === before + total, `总计 ${total / 1000} 秒`);
}
console.log('--- 设定时间 ---');
{
const c = clockModel.create(T0);
clockModel.setRate(c, T0, 60);
clockModel.setTimeOfDay(c, T0 + 5000, 8, 30, 15);
const d = new Date(clockModel.displayMs(c, T0 + 5000));
check('设定为 08:30:15 且毫秒归零',
d.getHours() === 8 && d.getMinutes() === 30 && d.getSeconds() === 15 && d.getMilliseconds() === 0,
`${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}.${d.getMilliseconds()}`);
check('设定后高倍率继续生效', clockModel.displayMs(c, T0 + 6000) === d.getTime() + 60 * SECOND);
}
{
const c = clockModel.create(T0);
clockModel.setTimeOfDay(c, T0, 0, 0, 0);
const d = new Date(clockModel.displayMs(c, T0));
check('设定为 00:00:00(边界)', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0);
}
{
const c = clockModel.create(T0);
clockModel.setTimeOfDay(c, T0, 23, 59, 59);
const d = new Date(clockModel.displayMs(c, T0 + 2000));
check('23:59:59 后跨零点进位', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 1,
`${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`);
}
console.log('');
if (failures.length === 0) {
console.log(`全部通过:${passed}/${passed}`);
process.exit(0);
} else {
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
process.exit(1);
}