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);
}
+188
View File
@@ -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);
}
+218
View File
@@ -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);
+185
View File
@@ -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);
+132
View File
@@ -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);
+82
View File
@@ -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);
}