157 lines
9.1 KiB
JavaScript
157 lines
9.1 KiB
JavaScript
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
|
||
// 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);
|