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