Files
alibaba/scripts/verify-chat-range.mjs
T
2026-08-27 16:12:37 +08:00

157 lines
9.1 KiB
JavaScript
Raw 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.
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
// content.js/popup.js 为 IIFE/非 module,无法被 vitest import —— 本文件是判定逻辑的
// 基准实现+用例锁定;实现侧照搬函数体。⚠️ 改任一侧必须同步另一侧。
let pass = 0, fail = 0;
const ok = (name, cond) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); cond ? pass++ : fail++; };
// ---- 基准实现(实现照搬区)----
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
function minusMonths(base, n) {
const d = new Date(base.getFullYear(), base.getMonth(), 1,
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
d.setMonth(d.getMonth() - n);
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(base.getDate(), lastDay));
return d;
}
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
function computeChatRange(mode, sinceStr, untilStr) {
if (mode === 'all') return { since: null, until: null };
if (mode === '1m' || mode === '2m' || mode === '3m') {
// 整天边界:since=起始日 00:00:00(完整自然月天数),until=今日 23:59:59.999("至今"取今天整天,与 custom 止=今天一致)
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(); dayEnd.setHours(23, 59, 59, 999);
return { since: minusMonths(dayStart, Number(mode[0])).toISOString(), until: dayEnd.toISOString() };
}
if (mode === 'custom') {
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
const u = new Date(untilStr + 'T23:59:59.999');
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
const uDay = new Date(untilStr + 'T00:00:00.000');
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
return { since: s.toISOString(), until: u.toISOString() };
}
return { error: '未知时间范围模式' };
}
// 范围过滤(null sendTime 防御性保留 —— 宁多勿漏)
function inChatRange(sendTime, sinceMs, untilMs) {
if (sendTime == null) return true;
if (sinceMs != null && sendTime < sinceMs) return false;
if (untilMs != null && sendTime > untilMs) return false;
return true;
}
// 水位读取兼容:旧=纯 string(sendTime 未知) / 新={id,sendTime}
function parseWatermark(v) {
if (v == null || v === '') return null;
try {
const o = JSON.parse(v);
if (o && typeof o === 'object' && o.id != null) return { id: String(o.id), sendTime: Number(o.sendTime) || null };
} catch (e) { /* 旧格式继续 */ }
return { id: String(v), sendTime: null };
}
// 水位只前进写入判定
function shouldWriteWatermark(oldWm, candId, candSendTime) {
if (candId == null || candSendTime == null) return false;
if (oldWm == null) return true;
if (oldWm.sendTime == null) return true; // legacy 升级,允许覆盖一次
return candSendTime > oldWm.sendTime;
}
// probe 轮断言分类:'fail'(非对象/code≠200/data=null) | 'empty'(空list) | null(正常)。
// non-JSON/HTTP/关键词由 postForm 抛 RISK,不经此函数(真风控照抛,不降级)
function probeFailKind(json) {
if (!json || typeof json !== 'object') return 'fail';
if (json.code !== undefined && Number(json.code) !== 200) return 'fail';
if (json.data == null) return 'fail';
const list = json.data && json.data.list;
if (!Array.isArray(list) || list.length === 0) return 'empty';
return null;
}
// ISO → 毫秒,parse 容错(spec §5:非法/空 → null = 降级不限)
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; }
// ---- 用例 ----
ok('3-31 − 1月 = 2-28(月末钳制,setMonth 溢出防护)', (() => {
const r = minusMonths(new Date(2026, 2, 31, 15, 30), 1);
return r.getMonth() === 1 && r.getDate() === 28 && r.getHours() === 15;
})());
ok('8-25 − 1月 = 7-25(普通日期)', (() => {
const r = minusMonths(new Date(2026, 7, 25), 1);
return r.getMonth() === 6 && r.getDate() === 25;
})());
ok('12-31 − 2月 = 10-31(同年跨月)', (() => {
const r = minusMonths(new Date(2026, 11, 31), 2);
return r.getMonth() === 9 && r.getDate() === 31;
})());
ok('1-31 − 2月 = 上年 11-30(跨年+钳制)', (() => {
const r = minusMonths(new Date(2026, 0, 31), 2);
return r.getFullYear() === 2025 && r.getMonth() === 10 && r.getDate() === 30;
})());
ok('闰年:2028-03-31 − 1月 = 2028-02-29', (() => {
const r = minusMonths(new Date(2028, 2, 31), 1);
return r.getMonth() === 1 && r.getDate() === 29;
})());
ok('all → 双 null(完全同现状)', computeChatRange('all').since === null && computeChatRange('all').until === null);
ok('1m → since=起始日 00:00:00.000(25~32天前),until=今日 23:59:59.999(整天边界)', (() => {
const r = computeChatRange('1m');
const s = new Date(r.since), u = new Date(r.until);
const ageDays = (Date.now() - s.getTime()) / 86400e3;
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
return ageDays > 25 && ageDays < 32
&& s.getHours() === 0 && s.getMinutes() === 0 && s.getSeconds() === 0 && s.getMilliseconds() === 0
&& u.getTime() - t0.getTime() === 86399999; // = 今日 0 点 + 全天末毫秒
})());
ok('custom 本地时区(since=本地0点,非 UTC)', (() => {
const r = computeChatRange('custom', '2026-08-25', '2026-08-25');
const s = new Date(r.since);
return s.getMonth() === 7 && s.getDate() === 25 && s.getHours() === 0 && s.getMinutes() === 0;
})());
ok('custom until=止日 23:59:59.999 本地(含全天)', (() => {
const r = computeChatRange('custom', '2026-08-01', '2026-08-25');
const u = new Date(r.until);
return u.getDate() === 25 && u.getHours() === 23 && u.getMinutes() === 59 && u.getMilliseconds() === 999;
})());
ok('custom 起>止 → error', !!computeChatRange('custom', '2026-08-10', '2026-08-01').error);
ok('custom 起为空 → error', !!computeChatRange('custom', '', '2026-08-01').error);
ok('custom 止>今天 → error', !!computeChatRange('custom', '2026-01-01', '2099-01-01').error);
ok('custom 垃圾日期 → error', !!computeChatRange('custom', 'not-a-date', '2026-08-01').error);
ok('未知模式 → error', !!computeChatRange('6m').error);
const SINCE = Date.parse('2026-07-25T00:00:00'), UNTIL = Date.parse('2026-08-25T23:59:59');
ok('since 当天消息收(含边界)', inChatRange(SINCE, SINCE, null));
ok('since 前一天不收', !inChatRange(SINCE - 86400e3, SINCE, null));
ok('until 末毫秒收(含边界)', inChatRange(UNTIL, null, UNTIL));
ok('until 次日不收', !inChatRange(UNTIL + 1, null, UNTIL));
ok('sendTime=null 防御性保留', inChatRange(null, SINCE, UNTIL));
ok('范围不限(null)全收', inChatRange(1, null, null));
ok('新格式水位解析', (() => { const w = parseWatermark('{"id":"a1","sendTime":1787626659384}'); return w.id === 'a1' && w.sendTime === 1787626659384; })());
ok('旧格式(纯string)→ legacy,sendTime 未知', (() => { const w = parseWatermark('abc123'); return w.id === 'abc123' && w.sendTime === null; })());
ok('空值(null/undefined/空串)→ null(首次抓取必走)', parseWatermark(null) === null && parseWatermark(undefined) === null && parseWatermark('') === null);
ok('只前进:候选6-30 不覆盖 7-28', !shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-06-30')));
ok('只前进:候选8-25 覆盖 7-28', shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-08-25')));
ok('legacy 允许覆盖一次(升级)', shouldWriteWatermark({ id: 'o', sendTime: null }, 'n', Date.parse('2026-06-30')));
ok('无旧水位 → 写入', shouldWriteWatermark(null, 'n', 1));
ok('候选缺 sendTime → 不写', !shouldWriteWatermark({ id: 'o', sendTime: 5 }, 'n', null));
ok('probe 正常(200+非空) → null', probeFailKind({ code: '200', data: { list: [{}] } }) === null);
ok('probe code≠200 → fail 降级', probeFailKind({ code: '500' }) === 'fail');
ok('probe data=null → fail 降级', probeFailKind({ code: '200', data: null }) === 'fail');
ok('probe 非对象 → fail 降级', probeFailKind('ok') === 'fail');
ok('probe 空 list → empty 降级(实测形态)', probeFailKind({ code: '200', data: { list: [] } }) === 'empty');
ok('probe list 缺失 → empty 降级', probeFailKind({ code: '200', data: {} }) === 'empty');
ok('toMs 非法/空 → null,合法 → 毫秒', toMs('garbage') === null && toMs(null) === null && toMs('2026-08-25T00:00:00Z') === Date.parse('2026-08-25T00:00:00Z'));
// (文档性说明,非行为锁定)until 游标 = untilMs+1:附录B实测语义=返回 sendTime < cursor,恰好覆盖 <= untilMs;真实验证靠 Task 8 #4 SW Network
ok('文档性:until 游标 +1ms 覆盖含止日末毫秒(真实验证靠 Task 8 #4)', (() => {
const untilMs = Date.parse('2026-06-30T23:59:59.999');
const cursor = untilMs + 1;
return (untilMs < cursor) && !(untilMs + 1 < cursor);
})());
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);