Files
ClockLab/test/presented-time.test.js
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

189 lines
8.3 KiB
JavaScript
Raw Permalink 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.
'use strict';
// 呈现时间层的纯用例(不需要 Electron、不需要显示器):
// node test/presented-time.test.js
// 「什么时候该动画、动画到哪、被打断怎么办」是这次跳转动画最容易写错的地方,
// 所以把它从渲染代码里拆出来,先在 node 里钉死,Windows 上就只剩「观感」要试。
const presented = require('../renderer/presented-time');
const { JUMP_THRESHOLD_MS, EASE_MS } = presented;
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;
const MINUTE = 60 * SECOND;
// 走时参数就是主进程下发的那个快照,这里照原样造
const snap = (baseMs, baseRealMs, rate) => ({ baseMs, baseRealMs, rate });
console.log('--- 平时不动画 ---');
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
check('首个参数直接对齐(没有"上一个值"可以缓动)', presented.valueAt(layer, T0) === T0);
check('走 1 秒后呈现时间 = 目标时间,没有动画', presented.valueAt(layer, T0 + SECOND) === T0 + SECOND);
check('走 1 小时后依然相等(不会被动画层拖后腿)',
presented.valueAt(layer, T0 + 3600 * SECOND) === T0 + 3600 * SECOND);
}
{
// 首个参数哪怕离当前显示时间很远(例如时钟窗口后打开),也不该滚一次
const layer = presented.create();
presented.accept(layer, T0, snap(T0 + 10 * MINUTE, T0, 1));
check('首个参数:直接对齐,不做动画', presented.valueAt(layer, T0) === T0 + 10 * MINUTE);
}
console.log(`--- 大跳变(> ${JUMP_THRESHOLD_MS}ms)缓动 ${EASE_MS}ms ---`);
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
const target = T0 + 30 * MINUTE;
presented.accept(layer, T0, snap(target, T0, 1)); // 快进 +30 分钟
check('跳变当帧显示的还是跳变前的时间(数字从这里开始滚)',
presented.valueAt(layer, T0) === T0, String(presented.valueAt(layer, T0)));
const half = presented.valueAt(layer, T0 + EASE_MS / 2);
check('半程时停在两者之间', half > T0 && half < target, `${Math.round(half - T0)}ms`);
check('缓出:半程已经走完一大半路程(不是匀速)', half - T0 > (target - T0) / 2,
`${Math.round(((half - T0) / (target - T0)) * 100)}%`);
let previous = -Infinity;
let monotonic = true;
for (let t = 0; t <= EASE_MS; t += 25) {
const value = presented.valueAt(layer, T0 + t);
if (value < previous) monotonic = false;
previous = value;
}
check('整段缓动单调向前(不会中途倒退)', monotonic);
// 结束时要比的是「那一刻的目标时间」—— 目标本身还在按 ×1 继续走
const settled = target + EASE_MS;
check(`满 ${EASE_MS}ms 精确落在目标上(不留残余,否则数字永远差几百毫秒)`,
presented.valueAt(layer, T0 + EASE_MS) === settled, String(presented.valueAt(layer, T0 + EASE_MS) - settled));
check('之后一直等于目标时间', presented.valueAt(layer, T0 + 5000) === target + 5000);
}
{
// 往回跳(设定到更早的时间)同样缓动,只是方向相反
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
const target = T0 - 10 * MINUTE;
presented.accept(layer, T0, snap(target, T0, 1));
check('负向跳变当帧仍在原处', presented.valueAt(layer, T0) === T0);
let previous = Infinity;
let monotonic = true;
for (let t = 0; t <= EASE_MS; t += 25) {
const value = presented.valueAt(layer, T0 + t);
if (value > previous) monotonic = false;
previous = value;
}
check('负向跳变整段单调往回', monotonic);
check('负向跳变落在目标上', presented.valueAt(layer, T0 + EASE_MS) === target + EASE_MS);
}
console.log('--- 小跳变直接跳 ---');
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
presented.accept(layer, T0, snap(T0 + JUMP_THRESHOLD_MS, T0, 1));
check(`正好 ${JUMP_THRESHOLD_MS}ms:直接跳,不缓动`,
presented.valueAt(layer, T0) === T0 + JUMP_THRESHOLD_MS);
const other = presented.create();
presented.accept(other, T0, snap(T0, T0, 1));
presented.accept(other, T0, snap(T0 + JUMP_THRESHOLD_MS + 1, T0, 1));
check(`超过 ${JUMP_THRESHOLD_MS}ms:开始缓动(当帧还在原处)`, presented.valueAt(other, T0) === T0);
}
console.log('--- 改倍率不是跳变 ---');
{
const before = snap(T0, T0, 1);
const visible = presented.targetAt(before, T0 + 5000); // 改倍率那一刻的显示时间
const after = snap(visible, T0 + 5000, 60); // 模型先固化显示时间再换倍率
check('模型锚点式换倍率:同一时刻新旧参数算出的时间相等',
presented.jumpBetween(before, after) === 0, String(presented.jumpBetween(before, after)));
const layer = presented.create();
presented.accept(layer, T0, before);
presented.accept(layer, T0 + 5000, after);
check('换倍率后画面不缓动,当帧就是固化下来的那个值',
presented.valueAt(layer, T0 + 5000) === visible);
check('换倍率后立刻按新倍率走(不会被动画拦住)',
presented.valueAt(layer, T0 + 5016) === visible + 16 * 60, String(presented.valueAt(layer, T0 + 5016) - visible));
}
{
const before = snap(T0, T0, 1);
const after = snap(T0 + 5 * SECOND, T0, 1); // 一次真实的 +5 秒快进
check('快进 +5 秒:参数比较出来的跳变幅度就是 5 秒',
presented.jumpBetween(before, after) === 5 * SECOND, String(presented.jumpBetween(before, after)));
check('没有上一个参数时不判定跳变', presented.jumpBetween(null, after) === 0);
}
console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---');
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1));
const mid = presented.valueAt(layer, T0 + 200);
check('中途确实停在两者之间', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`);
// 还没滚完又点了一次 +1 分钟:模型的目标变成 31 分钟
presented.accept(layer, T0 + 200, snap(T0 + 31 * MINUTE, T0 + 200, 1));
check('重定向当帧接着当前呈现值,不跳回起点',
presented.valueAt(layer, T0 + 200) === mid, `${Math.round(presented.valueAt(layer, T0 + 200) - mid)}ms`);
check('重定向后不会倒退', presented.valueAt(layer, T0 + 201) >= mid);
check('重定向后仍然精确落在新目标上',
presented.valueAt(layer, T0 + 200 + EASE_MS) === T0 + 31 * MINUTE + EASE_MS);
}
console.log('--- 暂停不缓动 ---');
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1));
const mid = presented.valueAt(layer, T0 + 100);
check('跳变后 100ms 正在缓动', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`);
// 暂停:模型把显示时间固化在暂停那一刻(也就是刚跳到的目标)
presented.accept(layer, T0 + 100, snap(T0 + 30 * MINUTE, T0 + 100, 0));
check('暂停当帧就落到暂停时刻的准确时间(不会挂着走不完的中间值)',
presented.valueAt(layer, T0 + 100) === T0 + 30 * MINUTE);
check('暂停后画面纹丝不动',
presented.valueAt(layer, T0 + 9000) === presented.valueAt(layer, T0 + 100));
}
{
const layer = presented.create();
presented.accept(layer, T0, snap(T0, T0, 1));
presented.accept(layer, T0, snap(T0, T0, 0));
check('暂停状态下快进也是直接落到新值(暂停画面必须是精确的暂停时间)',
presented.valueAt(layer, T0) === T0);
presented.accept(layer, T0 + 5000, snap(T0 + 5 * MINUTE, T0 + 5000, 0));
check('暂停后快进:当帧就是新值,不滚',
presented.valueAt(layer, T0 + 5000) === T0 + 5 * MINUTE);
check('暂停后快进:之后继续定格在该值',
presented.valueAt(layer, T0 + 60 * SECOND) === T0 + 5 * MINUTE);
}
console.log('');
if (failures.length === 0) {
console.log(`全部通过:${passed}/${passed}`);
process.exit(0);
} else {
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
process.exit(1);
}