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:
1 parent
892b9190aa
commit
9c7081b21d
27 files changed
+5467
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'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);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// 用最小 DOM 桩把「跳转动画」这条链路真的跑一遍:主进程下发参数 → 呈现时间层 → 数字 / 表盘
|
||||
// node test/renderer-clock-anim.dom-test.js
|
||||
// 桩终究不是真浏览器:它能证明「缓动从哪里出发、落在哪里、被打断时连不连续」,
|
||||
// 证明不了「滚起来顺不顺眼」——那部分只能在 Windows 上肉眼确认。
|
||||
|
||||
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) {
|
||||
return {
|
||||
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() {}
|
||||
};
|
||||
}
|
||||
|
||||
const els = new Map();
|
||||
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
||||
const body = makeEl('body');
|
||||
|
||||
const RealDate = Date;
|
||||
const T0 = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); // 03:00:00.000
|
||||
const MIN = 60 * 1000;
|
||||
let fakeNow = T0;
|
||||
|
||||
class FakeDate extends RealDate {
|
||||
constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); }
|
||||
static now() { return fakeNow; }
|
||||
}
|
||||
|
||||
// 主进程下发的参数就是这个形状;这里照原样造
|
||||
const snap = (baseMs, baseRealMs, rate, style = 'digital') => ({
|
||||
baseMs, baseRealMs, rate, lastRate: rate, style, paused: rate === 0,
|
||||
clockWindowOpen: true, alwaysOnTop: { clock: true, control: false }
|
||||
});
|
||||
|
||||
let rafCb = null;
|
||||
let stateCb = null;
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
Date: FakeDate,
|
||||
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
|
||||
ResizeObserver: class { observe() {} },
|
||||
getComputedStyle: () => ({}),
|
||||
addEventListener: () => {},
|
||||
document: {
|
||||
body,
|
||||
getElementById: byId,
|
||||
querySelectorAll: () => [],
|
||||
createElementNS: () => makeEl('svg-node')
|
||||
},
|
||||
window: {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.clockApi = {
|
||||
getState: () => Promise.resolve(snap(T0, T0, 1)),
|
||||
onState: (cb) => { stateCb = cb; },
|
||||
setRate: () => Promise.resolve({}), fastForward: () => Promise.resolve({}),
|
||||
setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}),
|
||||
togglePause: () => Promise.resolve({}), setAlwaysOnTop: () => Promise.resolve({}),
|
||||
toggleClockWindow: () => Promise.resolve({}), closeClockWindow: () => Promise.resolve({}),
|
||||
closeControlWindow: () => Promise.resolve({}), getBoundsSync: () => null, setBounds: () => {}
|
||||
};
|
||||
|
||||
const ctx = vm.createContext(sandbox);
|
||||
try {
|
||||
for (const file of ['renderer/resize.js', 'renderer/presented-time.js', 'renderer/clock.js']) {
|
||||
vm.runInContext(read(file), ctx, { filename: file });
|
||||
}
|
||||
check('渲染进程脚本加载无异常', true);
|
||||
} catch (e) {
|
||||
check('渲染进程脚本加载无异常', false, e.message);
|
||||
console.log('\n致命:渲染进程脚本无法加载'); process.exit(1);
|
||||
}
|
||||
|
||||
// —— 读数小工具 ——
|
||||
const frame = () => rafCb(); // 渲染一帧(真实时间就是 fakeNow)
|
||||
const advance = (ms) => { fakeNow += ms; rafCb(); };
|
||||
const shown = () => Number(body.dataset.displayMs);
|
||||
const text = () => byId('digitalTime').textContent;
|
||||
const rot = (el) => Number(/rotate\(([-\d.eE]+)/.exec(el.attrs.transform)[1]);
|
||||
const targetOf = (s, now) => s.baseMs + (now - s.baseRealMs) * s.rate;
|
||||
|
||||
// 与 clock.js 同一套角度公式:秒针按毫秒连续走
|
||||
function anglesFor(ms) {
|
||||
const d = new RealDate(ms);
|
||||
const seconds = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const minutes = d.getMinutes() + seconds / 60;
|
||||
const hours = (d.getHours() % 12) + minutes / 60;
|
||||
return { hour: hours * 30, minute: minutes * 6, second: seconds * 6 };
|
||||
}
|
||||
|
||||
// 三根指针是否都严格由「当前呈现时间」推出来(差 0.05° 以内 ≈ 0.1ms 的误差)
|
||||
function handsMatchPresented() {
|
||||
const expect = anglesFor(shown());
|
||||
return Math.abs(rot(byId('hourHand')) - expect.hour) < 0.05 &&
|
||||
Math.abs(rot(byId('minuteHand')) - expect.minute) < 0.05 &&
|
||||
Math.abs(rot(byId('secondHand')) - expect.second) < 0.05;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('--- 平时不动画 ---');
|
||||
frame();
|
||||
check('初始显示 03:00:00', text() === '03:00:00', text());
|
||||
advance(1000);
|
||||
check('走 1 秒就是 1 秒(动画层不拖后腿)', text() === '03:00:01', text());
|
||||
|
||||
console.log('--- 快进 +30 分钟:数字滚过去 ---');
|
||||
const before = shown();
|
||||
stateCb(snap(before + 30 * MIN, fakeNow, 1)); // 主进程 shift:以此刻的显示时间为准整体平移
|
||||
frame();
|
||||
check('跳变当帧数字还在旧值上(从这里开始滚)',
|
||||
shown() === before && text() === '03:00:01', `${text()} / ${shown() - before}`);
|
||||
advance(175);
|
||||
const mid = shown();
|
||||
check('半程时数字落在两者之间', mid > before && mid < before + 30 * MIN, `${Math.round(mid - before)}ms`);
|
||||
check('半程时数字确实换过(不是干等)', text() !== '03:00:01' && text() !== '03:30:01', text());
|
||||
advance(175);
|
||||
check('350ms 后落在 +30 分钟那一刻', shown() === before + 30 * MIN + 350,
|
||||
`${shown() - (before + 30 * MIN + 350)}ms`);
|
||||
check('落地后显示 03:30:01', text() === '03:30:01', text());
|
||||
|
||||
console.log('--- 小跳变直接跳 ---');
|
||||
const smallBase = shown();
|
||||
stateCb(snap(smallBase + 1000, fakeNow, 1));
|
||||
frame();
|
||||
check('+1 秒:当帧就直接到新值,不缓动', shown() === smallBase + 1000, `${shown() - smallBase}ms`);
|
||||
|
||||
console.log('--- 改倍率不是跳变 ---');
|
||||
const rateBase = shown();
|
||||
stateCb(snap(rateBase, fakeNow, 60));
|
||||
frame();
|
||||
check('切 ×60:当帧显示时间不变(没有动画)', shown() === rateBase, `${shown() - rateBase}ms`);
|
||||
advance(100);
|
||||
check('切 ×60:之后立刻按新倍率走', shown() === rateBase + 6000, `${shown() - rateBase}ms`);
|
||||
|
||||
console.log('--- 表盘:指针从同一个呈现时间扫过去 ---');
|
||||
const T11 = new RealDate(2026, 8, 14, 11, 0, 0, 0).getTime();
|
||||
const digitalBefore = shown();
|
||||
stateCb(snap(T11, fakeNow, 1, 'analog'));
|
||||
frame();
|
||||
check('切表盘 + 跳变当帧:指针仍指在旧时间上', Math.abs(rot(byId('hourHand')) - anglesFor(digitalBefore).hour) < 0.05,
|
||||
`${rot(byId('hourHand'))}° vs 目标 ${anglesFor(T11).hour}°`);
|
||||
check('当帧还没到 11 点', Math.abs(rot(byId('hourHand')) - 330) > 1, `${rot(byId('hourHand'))}°`);
|
||||
advance(175);
|
||||
check('半程时三根指针都由当前呈现时间推出', handsMatchPresented(),
|
||||
`时针 ${rot(byId('hourHand')).toFixed(2)}°`);
|
||||
advance(175);
|
||||
check('落地后时针指向 11 点整(330°)', Math.abs(rot(byId('hourHand')) - 330) < 0.05, `${rot(byId('hourHand'))}°`);
|
||||
check('落地后三根指针依然与呈现时间一致', handsMatchPresented());
|
||||
|
||||
console.log('--- 跨 12 点:往前扫,不倒着转一圈 ---');
|
||||
// 11:00 → 次日 01:00(跨过 12 点),缓动过程中时间必须单调向前
|
||||
const T01 = new RealDate(2026, 8, 15, 1, 0, 0, 0).getTime();
|
||||
stateCb(snap(T01, fakeNow, 1, 'analog'));
|
||||
const samples = [];
|
||||
let handsAligned = true;
|
||||
for (let i = 0; i <= 7; i += 1) {
|
||||
if (i > 0) fakeNow += 50;
|
||||
rafCb();
|
||||
samples.push(shown());
|
||||
if (!handsMatchPresented()) handsAligned = false;
|
||||
}
|
||||
check('缓动全程三根指针都由当前呈现时间推出(含跨 12 点那几帧)', handsAligned);
|
||||
let monotonic = true;
|
||||
for (let i = 1; i < samples.length; i++) if (samples[i] <= samples[i - 1]) monotonic = false;
|
||||
check('缓动全程呈现时间单调向前(角度按公式跟随,所以不会倒着转一圈)', monotonic,
|
||||
`${samples[0]} → ${samples[samples.length - 1]}`);
|
||||
advance(350);
|
||||
check('跨 12 点后落在次日 01:00', Math.abs(rot(byId('hourHand')) % 360 - 30) < 1, `${rot(byId('hourHand'))}°`);
|
||||
|
||||
console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---');
|
||||
stateCb(snap(shown() + 30 * MIN, fakeNow, 1, 'digital'));
|
||||
advance(100);
|
||||
const midFlight = shown();
|
||||
const retargetTo = shown() + MIN; // 主进程在模型目标上再 +1 分钟
|
||||
stateCb(snap(retargetTo, fakeNow, 1, 'digital'));
|
||||
frame();
|
||||
check('重定向当帧不弹回跳变前的旧值', shown() === midFlight, `${shown() - midFlight}ms`);
|
||||
advance(350);
|
||||
check('重定向后落在新目标上', shown() === targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow),
|
||||
`${shown()} vs ${targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow)}`);
|
||||
|
||||
console.log('--- 暂停不缓动 ---');
|
||||
const pausedAt = shown();
|
||||
stateCb(snap(pausedAt, fakeNow, 0));
|
||||
frame();
|
||||
check('暂停当帧就是暂停时刻的准确时间', shown() === pausedAt, `${shown() - pausedAt}ms`);
|
||||
advance(5000);
|
||||
check('暂停后画面纹丝不动', shown() === pausedAt, `${shown() - pausedAt}ms`);
|
||||
|
||||
// 缓动没结束时按暂停:不能挂着走不完的中间值
|
||||
stateCb(snap(pausedAt + 30 * MIN, fakeNow, 1));
|
||||
advance(100);
|
||||
const inFlight = shown();
|
||||
check('恢复后先正常滚起来', inFlight > pausedAt && inFlight < pausedAt + 30 * MIN, `${Math.round(inFlight - pausedAt)}ms`);
|
||||
stateCb(snap(pausedAt + 30 * MIN, fakeNow, 0));
|
||||
frame();
|
||||
check('缓动中按暂停:立刻落到暂停时刻的准确值', shown() === pausedAt + 30 * MIN, `${shown() - (pausedAt + 30 * MIN)}ms`);
|
||||
advance(5000);
|
||||
check('暂停后继续保持该值', shown() === pausedAt + 30 * MIN);
|
||||
|
||||
console.log('');
|
||||
if (!failures.length) { console.log(`跳转动画链路:全部通过 ${passed}/${passed}`); process.exit(0); }
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
|
||||
}, 30);
|
||||
@@ -0,0 +1,185 @@
|
||||
// 用最小 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);
|
||||
@@ -0,0 +1,132 @@
|
||||
// 用最小 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);
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
// 拖边缘改大小的几何用例(纯计算,不需要 Electron):
|
||||
// node test/resize.test.js
|
||||
// 无边框窗口里这段代码只有真鼠标拖拽才能触发,先用纯函数把它钉死,Windows 上就只剩「手感」要试。
|
||||
|
||||
const { computeBounds, MIN_SIZE } = require('../renderer/resize');
|
||||
|
||||
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 start = { x: 400, y: 300, width: 520, height: 280 };
|
||||
const same = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
|
||||
console.log('--- 单边 ---');
|
||||
check('东边 +120:只有宽度变,左上角不动',
|
||||
same(computeBounds(start, 'e', 120, 0), { x: 400, y: 300, width: 640, height: 280 }));
|
||||
check('南边 +60:只有高度变',
|
||||
same(computeBounds(start, 's', 0, 60), { x: 400, y: 300, width: 520, height: 340 }));
|
||||
check('西边 -80:宽度增加 80,左边界左移 80',
|
||||
same(computeBounds(start, 'w', -80, 0), { x: 320, y: 300, width: 600, height: 280 }));
|
||||
check('北边 -50:高度增加 50,上边界上移 50',
|
||||
same(computeBounds(start, 'n', 0, -50), { x: 400, y: 250, width: 520, height: 330 }));
|
||||
check('东边往回拖 -100:窗口变窄,左上角不动',
|
||||
same(computeBounds(start, 'e', -100, 0), { x: 400, y: 300, width: 420, height: 280 }));
|
||||
|
||||
console.log('--- 四角 ---');
|
||||
check('右下角 +40/+30:宽高同时增加',
|
||||
same(computeBounds(start, 'se', 40, 30), { x: 400, y: 300, width: 560, height: 310 }));
|
||||
check('左上角 -40/-30:宽高增加且整体左上移',
|
||||
same(computeBounds(start, 'nw', -40, -30), { x: 360, y: 270, width: 560, height: 310 }));
|
||||
check('右上角 +40/-30:宽度增加、上边界上移',
|
||||
same(computeBounds(start, 'ne', 40, -30), { x: 400, y: 270, width: 560, height: 310 }));
|
||||
check('左下角 -40/+30:左边界左移、高度增加',
|
||||
same(computeBounds(start, 'sw', -40, 30), { x: 360, y: 300, width: 560, height: 310 }));
|
||||
|
||||
console.log('--- 最小尺寸 ---');
|
||||
check('东边往死里拖:宽度停在 260', computeBounds(start, 'e', -9999, 0).width === MIN_SIZE.width);
|
||||
check('南边往死里拖:高度停在 150', computeBounds(start, 's', 0, -9999).height === MIN_SIZE.height);
|
||||
{
|
||||
const r = computeBounds(start, 'w', 9999, 0);
|
||||
check('西边往死里拖:宽度停在 260,且右边界不动(窗口不会被推走)',
|
||||
r.width === MIN_SIZE.width && r.x + r.width === start.x + start.width, JSON.stringify(r));
|
||||
}
|
||||
{
|
||||
const r = computeBounds(start, 'n', 0, 9999);
|
||||
check('北边往死里拖:高度停在 150,且下边界不动',
|
||||
r.height === MIN_SIZE.height && r.y + r.height === start.y + start.height, JSON.stringify(r));
|
||||
}
|
||||
{
|
||||
const r = computeBounds(start, 'nw', 9999, 9999);
|
||||
check('左上角往死里拖:宽高都停在最小值,右下角不动',
|
||||
r.width === MIN_SIZE.width && r.height === MIN_SIZE.height &&
|
||||
r.x + r.width === start.x + start.width && r.y + r.height === start.y + start.height, JSON.stringify(r));
|
||||
}
|
||||
|
||||
console.log('--- 边界 ---');
|
||||
check('位移为 0 时原样返回', same(computeBounds(start, 'e', 0, 0), start));
|
||||
check('结果都是整数(避免 setBounds 收到小数后抖动)',
|
||||
Number.isInteger(computeBounds(start, 'e', 12.4, 0).width));
|
||||
check('负坐标(窗口在主屏左侧)也能算',
|
||||
same(computeBounds({ x: -900, y: -200, width: 520, height: 280 }, 'w', -100, 0),
|
||||
{ x: -1000, y: -200, width: 620, height: 280 }));
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`全部通过:${passed}/${passed}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in new issue
Block a user