Files
SubsTracker/tests/core/time.test.js
wangwangit f6bce4b7b3 refactor(time): 重写时区核心模块为单一真相源
修复 #91 / #52 / #166 类时区相关问题的根因:旧 getCurrentTimeInTimezone
只返回 new Date(),被调用方误以为是"用户本地时间对象"。

变化点:
- 新增 getNowInTimezone(tz, now?) 返回 {utc, parts, hourString, isoLocal}
  强制业务代码显式选择需要 UTC 时刻还是用户 TZ 字段
- 新增 getTimezoneHourString(date, tz) 调度器通知时段判断专用
- 新增 getDaysBetween(from, to, tz) 基于用户 TZ 各自零点的整天数差,
  修复"凌晨 0–8 点创建订阅默认日期变前一天"的 #166
- formatLocalDate 替代 formatTimeInTimezone(保留旧名作 alias)
- 旧 API(getCurrentTimeInTimezone / convertUTCToTimezone)保留为兼容
  wrapper,加显眼注释说明其语义陷阱
- 全文 JSDoc 标注 + 中文用途说明,启用 // @ts-check
- 40 条单测覆盖:UTC/北京/纽约 DST、跨日界、#166 边界、非法时区兜底

附带:/debug 页新增"时区诊断"区块,直观展示 UTC vs 用户 TZ 当前小时、
通知时段是否命中——这是用户自助排查"为什么没收到通知"的入口

Refs Task 2 of refactor/v3-product-grade plan.
2026-05-24 17:53:29 +08:00

317 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-check
/**
* 时区核心模块单元测试
*
* 覆盖范围:
* - getNowInTimezone注入式时间 + 各时区分量
* - getTimezoneHourString调度器通知时段判断主路径
* - getDaysBetween跨零点 / 跨夏令时 / #166 场景
* - getTimezoneMidnightTimestamp用户 TZ 零点反推
* - formatLocalDate4 种格式
* - 向后兼容 wrappergetCurrentTimeInTimezone / convertUTCToTimezone
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
MS_PER_DAY,
MS_PER_HOUR,
isValidTimezone,
getTimezoneDateParts,
getNowInTimezone,
getTimezoneHourString,
getDaysBetween,
getTimezoneMidnightTimestamp,
formatLocalDate,
formatTimeInTimezone,
formatBeijingTime,
formatTimezoneDisplay,
getTimezoneOffset,
getCurrentTimeInTimezone,
getTimestampInTimezone,
convertUTCToTimezone,
extractTimezone
} from '../../src/core/time.js';
describe('isValidTimezone', () => {
it('合法 IANA 时区返回 true', () => {
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Shanghai')).toBe(true);
expect(isValidTimezone('America/New_York')).toBe(true);
});
it('非法字符串返回 false', () => {
expect(isValidTimezone('FooBar/Baz')).toBe(false);
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone(/** @type {any} */ (null))).toBe(false);
});
});
describe('getTimezoneDateParts', () => {
it('UTC 时区下 UTC 时刻分量正确', () => {
const d = new Date('2026-05-24T03:30:45.000Z');
expect(getTimezoneDateParts(d, 'UTC')).toEqual({
year: 2026,
month: 5,
day: 24,
hour: 3,
minute: 30,
second: 45
});
});
it('Asia/Shanghai 比 UTC 快 8 小时(夏令时无影响)', () => {
const d = new Date('2026-05-24T16:00:00.000Z'); // UTC 16:00 = 北京 24:00 = 5/25 00:00
const parts = getTimezoneDateParts(d, 'Asia/Shanghai');
expect(parts.year).toBe(2026);
expect(parts.month).toBe(5);
expect(parts.day).toBe(25);
expect(parts.hour).toBe(0);
});
it('America/New_York 夏令时5 月)差 4 小时', () => {
const d = new Date('2026-05-24T16:00:00.000Z'); // UTC 16:00 = NYC 12:00 (DST)
const parts = getTimezoneDateParts(d, 'America/New_York');
expect(parts.day).toBe(24);
expect(parts.hour).toBe(12);
});
it('America/New_York 冬令时1 月)差 5 小时', () => {
const d = new Date('2026-01-15T16:00:00.000Z'); // UTC 16:00 = NYC 11:00 (no DST)
const parts = getTimezoneDateParts(d, 'America/New_York');
expect(parts.day).toBe(15);
expect(parts.hour).toBe(11);
});
it('非法时区回退 UTC 不抛异常', () => {
const d = new Date('2026-05-24T03:30:45.000Z');
const parts = getTimezoneDateParts(d, 'Foo/Bar');
expect(parts.year).toBe(2026);
expect(parts.hour).toBe(3); // 走兜底路径
});
});
describe('getNowInTimezone业务主入口', () => {
it('注入特定时刻UTC 0 点 + Asia/Shanghai → 北京 8 点', () => {
const fixed = new Date('2026-05-24T00:00:00.000Z');
const now = getNowInTimezone('Asia/Shanghai', fixed);
expect(now.utc.toISOString()).toBe('2026-05-24T00:00:00.000Z');
expect(now.parts).toEqual({ year: 2026, month: 5, day: 24, hour: 8, minute: 0, second: 0 });
expect(now.hourString).toBe('08');
expect(now.isoLocal).toBe('2026-05-24T08:00:00');
expect(now.timezone).toBe('Asia/Shanghai');
});
it('UTC 23:30 + Asia/Shanghai → 次日 07:30', () => {
const fixed = new Date('2026-05-24T23:30:00.000Z');
const now = getNowInTimezone('Asia/Shanghai', fixed);
expect(now.parts.day).toBe(25);
expect(now.hourString).toBe('07');
});
it('未注入时间时取 new Date()', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-12-31T16:00:00.000Z'));
try {
const now = getNowInTimezone('Asia/Shanghai');
expect(now.parts.year).toBe(2027);
expect(now.parts.month).toBe(1);
expect(now.parts.day).toBe(1);
expect(now.hourString).toBe('00');
} finally {
vi.useRealTimers();
}
});
});
describe('getTimezoneHourString调度器通知时段比对', () => {
it('始终返回两位字符串', () => {
const d = new Date('2026-05-24T01:00:00.000Z');
expect(getTimezoneHourString(d, 'UTC')).toBe('01');
expect(getTimezoneHourString(d, 'Asia/Shanghai')).toBe('09');
});
it('00 / 23 边界', () => {
expect(getTimezoneHourString(new Date('2026-05-24T00:00:00Z'), 'UTC')).toBe('00');
expect(getTimezoneHourString(new Date('2026-05-24T23:00:00Z'), 'UTC')).toBe('23');
});
});
describe('getTimezoneMidnightTimestamp', () => {
it('UTC 时区下:等价 Date.UTC(y,m-1,d)', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T15:00:00Z'), 'UTC');
expect(ts).toBe(Date.UTC(2026, 4, 24));
});
it('Asia/Shanghai北京零点 = UTC 前一天 16:00', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T15:00:00Z'), 'Asia/Shanghai');
expect(new Date(ts).toISOString()).toBe('2026-05-23T16:00:00.000Z');
});
it('Asia/Shanghai 跨 UTC 日界UTC 18:00 → 北京次日 02:00 → 当日零点 = 当日 UTC 16:00', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T18:00:00Z'), 'Asia/Shanghai');
expect(new Date(ts).toISOString()).toBe('2026-05-24T16:00:00.000Z');
});
});
describe('getDaysBetween剩余天数计算 / #166 修复)', () => {
it('同一 UTC 日的不同时刻 → 0 天', () => {
expect(
getDaysBetween('2026-05-24T01:00:00Z', '2026-05-24T23:59:00Z', 'UTC')
).toBe(0);
});
it('跨 UTC 日 → 1 天', () => {
expect(
getDaysBetween('2026-05-24T23:30:00Z', '2026-05-25T00:30:00Z', 'UTC')
).toBe(1);
});
it('Asia/ShanghaiUTC 17:00 与 UTC 23:00 同一北京日 → 0 天', () => {
expect(
getDaysBetween('2026-05-24T17:00:00Z', '2026-05-24T23:00:00Z', 'Asia/Shanghai')
).toBe(0);
});
it('Asia/ShanghaiUTC 15:00 与 UTC 17:00 跨北京日 → 1 天', () => {
// UTC 15:00 = 北京 23:00 (5/24)
// UTC 17:00 = 北京 01:00 (5/25)
expect(
getDaysBetween('2026-05-24T15:00:00Z', '2026-05-24T17:00:00Z', 'Asia/Shanghai')
).toBe(1);
});
it('#166 场景UTC 凌晨 02:00北京 10:00创建订阅期望"今天"是北京 5/24 而非 UTC 5/24', () => {
// 用户在北京时间 2026-05-24 10:00 创建订阅,开始日期取"今日"
// 此时 UTC 是 2026-05-24 02:00
const utcNow = new Date('2026-05-24T02:00:00Z');
const userTzNow = getNowInTimezone('Asia/Shanghai', utcNow);
expect(userTzNow.parts.day).toBe(24);
expect(userTzNow.parts.hour).toBe(10);
// 选一个明显落在北京 5/27 中段的到期时刻(北京 5/27 14:00 = UTC 06:00
const expiry = new Date('2026-05-27T06:00:00Z');
expect(getDaysBetween(utcNow, expiry, 'Asia/Shanghai')).toBe(3);
});
it('#166 边界场景UTC 23:30北京次日 07:30创建订阅"今日"应是次日北京日期', () => {
const utcNow = new Date('2026-05-23T23:30:00Z');
const userTzNow = getNowInTimezone('Asia/Shanghai', utcNow);
expect(userTzNow.parts.day).toBe(24); // 北京 5/24
expect(userTzNow.parts.hour).toBe(7);
});
it('to 早于 from 时返回负数', () => {
expect(
getDaysBetween('2026-05-25T00:00:00Z', '2026-05-23T00:00:00Z', 'UTC')
).toBe(-2);
});
});
describe('formatLocalDate', () => {
const utcInstant = '2026-05-24T03:30:45.000Z';
it('format=date 返回本地日期', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'date');
expect(s).toContain('2026');
expect(s).toContain('05'); // 11:30 北京 5/24
});
it('format=datetime 包含时分秒', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'datetime');
expect(s).toContain('11');
expect(s).toContain('30');
});
it('format=isoLocal 返回 YYYY-MM-DDTHH:mm:ss 无时区后缀', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'isoLocal');
expect(s).toBe('2026-05-24T11:30:45');
});
it('无效输入返回空串', () => {
expect(formatLocalDate('not a date', 'UTC', 'date')).toBe('');
});
it('formatTimeInTimezone 与 formatLocalDate 等价(兼容老调用)', () => {
expect(formatTimeInTimezone(utcInstant, 'UTC', 'isoLocal')).toBe('2026-05-24T03:30:45');
});
it('formatBeijingTime 走 Asia/Shanghai', () => {
expect(formatBeijingTime(utcInstant, 'isoLocal')).toBe('2026-05-24T11:30:45');
});
});
describe('getTimezoneOffset & formatTimezoneDisplay', () => {
it('UTC 偏移 0', () => {
expect(getTimezoneOffset('UTC')).toBe(0);
});
it('Asia/Shanghai 偏移 +8', () => {
expect(getTimezoneOffset('Asia/Shanghai')).toBe(8);
});
it('formatTimezoneDisplay 包含中文名 + 偏移', () => {
const s = formatTimezoneDisplay('Asia/Shanghai');
expect(s).toContain('中国');
expect(s).toContain('+8');
});
});
describe('extractTimezone', () => {
it('?timezone=Asia/Tokyo 优先级最高', () => {
const req = new Request('https://x/?timezone=Asia/Tokyo', {
headers: { 'X-Timezone': 'UTC', 'Accept-Language': 'en-US' }
});
expect(extractTimezone(req)).toBe('Asia/Tokyo');
});
it('Header X-Timezone 次之', () => {
const req = new Request('https://x/', {
headers: { 'X-Timezone': 'Asia/Tokyo', 'Accept-Language': 'en-US' }
});
expect(extractTimezone(req)).toBe('Asia/Tokyo');
});
it('Accept-Language zh → Asia/Shanghai', () => {
const req = new Request('https://x/', { headers: { 'Accept-Language': 'zh-CN' } });
expect(extractTimezone(req)).toBe('Asia/Shanghai');
});
it('无任何提示 → UTC', () => {
expect(extractTimezone(new Request('https://x/'))).toBe('UTC');
});
it('非法 ?timezone 被忽略,回退到 Header / Accept-Language', () => {
const req = new Request('https://x/?timezone=Foo/Bar', {
headers: { 'Accept-Language': 'zh' }
});
expect(extractTimezone(req)).toBe('Asia/Shanghai');
});
});
describe('向后兼容 wrapper', () => {
it('getCurrentTimeInTimezone 返回 Date 实例UTC 时刻)', () => {
const d = getCurrentTimeInTimezone('Asia/Shanghai');
expect(d).toBeInstanceOf(Date);
expect(Math.abs(d.getTime() - Date.now())).toBeLessThan(1000);
});
it('getTimestampInTimezone 返回数字时间戳', () => {
const t = getTimestampInTimezone('UTC');
expect(typeof t).toBe('number');
expect(Math.abs(t - Date.now())).toBeLessThan(1000);
});
it('convertUTCToTimezone 返回的 Date 与原始相同 UTC 时刻', () => {
const orig = new Date('2026-05-24T03:30:00Z');
const converted = convertUTCToTimezone(orig, 'Asia/Shanghai');
expect(converted.getTime()).toBe(orig.getTime());
expect(converted).not.toBe(orig); // 拷贝
});
});
describe('常量', () => {
it('MS_PER_HOUR 与 MS_PER_DAY', () => {
expect(MS_PER_HOUR).toBe(3600_000);
expect(MS_PER_DAY).toBe(86_400_000);
});
});