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