【寻迹校园 HarmonyOS NEXT 实战 19】“今天/近3天/近7天”为什么容易出错:自然日边界测试实战

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 19 篇。本文结合 matchesHomeTimeRange() 与固定时钟测试,说明为什么不能直接用两个时间戳相减判断“近 3 天”,以及如何严格解析 YYYY-MM-DD、按本机自然日计算边界、拒绝未来日期和非法日期。

HarmonyOS 自然日时间筛选原创封面图

上图为原创生成的技术插画,不是项目截图。时间筛选比较的是日历格,而不是“当前时刻向前滚动 72 小时”的连续窗口;这两个定义在每天的大部分时间里并不相同。

一、“近 3 天”首先是产品定义问题

假设现在是 2026-08-12 23:30,一条记录发生在 2026-08-10 00:10。

按自然日理解,它属于今天、昨天、前天,应进入“近 3 天”。但两个时间戳相差超过 71 小时,若边界稍有偏差就可能被误排除。

反过来,一条发生在 8 月 9 日 23:40 的记录,距当前不足 72 小时,却已经跨过 8 月 9、10、11、12 四个日期,不应进入“近 3 天自然日”。

因此项目明确采用:

  • 今天:日期差为 0;
  • 近 3 天:今天 + 前 2 个自然日;
  • 近 7 天:今天 + 前 6 个自然日;
  • 未来日期:不命中;
  • 非法日期:不命中;
  • 全部:不施加时间过滤。

先写清业务语义,代码才有可验证目标。

二、为什么直接除以 24 小时不可靠

常见错误写法是:

const days = Math.floor((Date.now() - new Date(eventDate).getTime()) / 86400000);
return days <= 2;

它有四类风险:

  1. 比较的是时刻差,不是自然日差;
  2. new Date('YYYY-MM-DD') 的解析语义容易受平台和时区理解影响;
  3. 夏令时地区某些自然日不是严格 24 小时;
  4. 非法输入和未来日期可能被静默转换后继续计算。

项目不依赖字符串构造 Date 的隐式规则,而是显式拆年、月、日,再验证 Date 没有自动溢出。

三、严格解析 YYYY-MM-DD

calendarDayNumber() 先执行 trim(),再用正则检查固定的四位年、两位月、两位日,并显式拆出 year、month、day。

这会拒绝 2026/08/122026-8-12、空字符串和带额外内容的值。

格式正确仍不代表日期存在。JavaScript 的 Date 会把 2026-02-30 自动滚动到 3 月,因此还要回读年、月、日;只要和输入不一致,就返回 -1

只要发生溢出,原输入就被判为非法。

四、为什么转成 UTC day number

解析时使用本地 Date 验证用户选择的本地年月日;比较时则用 Date.UTC(year, month - 1, day) / MILLISECONDS_PER_DAY 把年月日映射为统一的日序号。

这里的 UTC 不是把事件强行解释成“UTC 时刻”,而是用 Date.UTC(year, month, day) 为一个日历日期生成稳定编号。

只比较编号差,就不会把本地时分秒或夏令时某天的 23/25 小时带入自然日计算。

五、今天也要先取本地年月日

当前时间来自毫秒值,但“今天”必须按本机日历理解:先用 getFullYear/getMonth/getDate 取出本机年月日,再映射到同一种 day number。

先用本地 getFullYear/getMonth/getDate 取出今天,再映射到 day number。若直接用 Math.floor(nowMs / 86400000),得到的是 UTC 日期边界,北京时间凌晨的数小时会被归到前一个 UTC 日。

这一前一后的组合很重要:业务日历使用本机日期,差值计算使用稳定的 UTC 编号。

六、三个范围的边界写法

下面把严格解析、日序号和三个边界放在一个连续实现中展示:

const MILLISECONDS_PER_DAY: number = 24 * 60 * 60 * 1000;

function calendarDayNumber(value: string): number {
  const normalized = value.trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return -1;
  const year = Number(normalized.substring(0, 4));
  const month = Number(normalized.substring(5, 7));
  const day = Number(normalized.substring(8, 10));
  const selected = new Date(year, month - 1, day);
  if (Number.isNaN(selected.getTime()) || selected.getFullYear() !== year ||
    selected.getMonth() !== month - 1 || selected.getDate() !== day) {
    return -1;
  }
  return Math.floor(Date.UTC(year, month - 1, day) / MILLISECONDS_PER_DAY);
}

export function matchesHomeTimeRange(
  eventDate: string,
  timeRange: HomeTimeRange,
  nowMs: number = Date.now()
): boolean {
  if (timeRange === HomeTimeRange.ALL) return true;
  const selectedDay = calendarDayNumber(eventDate);
  if (selectedDay < 0) return false;
  const now = new Date(nowMs);
  const today = Math.floor(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) /
    MILLISECONDS_PER_DAY);
  const difference = today - selectedDay;
  if (difference < 0) return false;
  if (timeRange === HomeTimeRange.TODAY) return difference === 0;
  if (timeRange === HomeTimeRange.THREE_DAYS) return difference <= 2;
  if (timeRange === HomeTimeRange.SEVEN_DAYS) return difference <= 6;
  return true;
}

为什么近 3 天是 <= 2 而不是 <= 3?因为今天已经占一天:差值 0、1、2 一共三个日期。

同理,近 7 天对应 0 到 6。把 UI 文案、代码边界和测试用例放在同一张表里最不容易误解:

筛选 允许的 difference 以 2026-08-12 为今天时最早日期
今天 0 2026-08-12
近 3 天 0~2 2026-08-10
近 7 天 0~6 2026-08-06

七、未来日期必须单独拒绝

difference < 0 表示事件日在今天之后。即使未来日期绝对差很小,也不应进入任何活动时间范围。

发布表单已经限制事件日期不晚于今天,但数据可能来自旧版本、迁移、手工测试或未来远端同步。筛选 Policy 仍要自我保护,不能假设所有调用方永远正确。

这体现了两层校验:Service 在写入前保护数据质量,Policy 在读取时保护结果语义。

八、ALL 为什么允许空日期

函数一开始对 HomeTimeRange.ALL 返回 true

因此旧记录即使没有 eventDate,在“全部时间”下仍可展示;只有用户启用活动时间筛选时,非法或缺失日期才被排除。

这是兼容策略,而不是认为空日期有效。若产品要求所有公开记录必须有合法事件日期,应在迁移和写入层完成修复,再决定是否收紧 ALL 的行为。

过早在列表层隐藏旧记录,会让用户误以为数据丢失。

今天近3天近7天自然日边界原创时间轴

上图以固定日期展示三个包含区间,并把未来日期和非法日期放在拒绝区域。测试边界时,最有价值的不是区间中间值,而是最早允许日的前后两天。

九、测试必须注入固定时钟

如果测试直接调用 Date.now(),今天一变,固定样例就会失效。项目把当前时间作为可选参数:生产环境默认使用 Date.now(),测试则传入 new Date(2026, 7, 12, 12, 0, 0, 0).getTime() 作为固定 NOW。

选择中午而不是临近午夜,也能减少测试运行环境在日期边界附近产生的偶然干扰。

十、边界用例比普通用例更重要

项目测试包含:

输入 范围 预期
2026-08-12 今天 true
2026-08-11 今天 false
2026-08-10 近 3 天 true
2026-08-09 近 3 天 false
2026-08-06 近 7 天 true
2026-08-05 近 7 天 false
2026-08-13 近 7 天 false
2026-02-30 近 7 天 false
空字符串 近 7 天 false
空字符串 全部 true

这组用例同时固定了包含边界、排除边界、未来日期、非法日期和兼容语义。仅测“昨天在近 3 天内”无法发现 off-by-one。

十一、跨时区同步会改变问题

当前项目是单机本地应用,eventDate 表示用户在本设备选择的校园自然日,算法以本机时区为准。

未来接入远端同步时,需要先回答:权威时区是什么?

  • 事件发生地所在校园时区;
  • 发布者设备时区;
  • 服务器统一时区;
  • 查看者设备时区。

对于校园失物,通常应以校园所在地自然日作为业务日期,并把 eventDate 当成日期值而非 UTC 时间戳。若跨校跨时区,就需要在数据合约中保存校园时区或业务时区,不能只依赖查看设备。

十二、日期值与时间戳不要混用

项目同时有 eventDatecreatedAt

  • eventDate:用户选择的事件自然日,格式 YYYY-MM-DD
  • createdAt:记录创建的时间点,用于排序和审计。

筛选“事件发生在近 3 天”应该使用 eventDate,而不是 createdAt。用户今天补发三天前丢失的物品,两者可能相差很大。

迁移旧记录时可以临时从 createdAt 推导 eventDate,但那只是兼容回填,不应改变两个字段长期承担的不同语义。

十三、页面层还要处理哪些状态

纯函数正确不代表交互已经正确。ArkUI 页面还应检查:

  • 筛选标签显示“近 3 天”时传入的是 THREE_DAYS
  • 清空筛选恢复 ALL
  • 应用跨过午夜后重新查询,而不是继续使用旧结果;
  • 从后台返回时是否需要刷新“今天”;
  • 日期为空的历史记录是否在 ALL 下正常显示;
  • 小屏下三个时间选项是否可点击且不挤压。

如果页面长期缓存 nowMs,自然日切换后会出现“昨天仍被当作今天”的问题。当前纯函数默认每次调用读取 Date.now(),页面刷新策略仍需要设备验证。

十四、运行真实规则测试

本地测试命令:

powershell -ExecutionPolicy Bypass -File .\scripts\test-report-filter.ps1

该测试能证明固定时钟下的自然日边界与非法输入处理。它不能证明设备时区切换、跨午夜生命周期、系统日期异常、远端同步和 ArkUI 交互。

更完整的设备测试应至少覆盖本地 23:59 到次日 00:01、修改系统时区、后台恢复、旧记录空日期和日期选择器最大值。

十五、维护日期代码的原则

  1. 先写清“自然日”还是“滚动小时窗口”;
  2. 日期值使用稳定格式,时间点使用时间戳;
  3. 不依赖字符串 Date 的隐式解析;
  4. 验证自动溢出的非法日期;
  5. 用本地年月日确定业务今天;
  6. 用稳定日序号计算差值;
  7. 测试固定时钟和边界前后值;
  8. 跨时区前先定义权威业务时区。

这些原则比记住某个 Date API 更能避免线上边界错误。

十六、日期规则变更前的影响分析

把“近 3 天”改成“过去 72 小时”,看起来只是替换一个表达式,实际上会改变边界记录、产品文案、测试数据和用户预期。改动前应先确认业务采用自然日还是滚动窗口、权威时区在哪里、未来日期是否允许,以及旧客户端产生的日期字符串如何解释。

日期规则还会影响首页筛选、匹配候选、统计报表与消息提醒。若多个入口各自计算边界,同一条记录可能在首页可见却不进入匹配。集中到纯策略并注入时钟,才能让所有消费者共享同一合同。

十七、异常时间与回滚边界

系统时间被手动修改、设备跨时区、夏令时切换或远端数据缺少时区时,程序都不应猜测成“今天”。非法输入应返回明确的不匹配结果并记录来源;若业务必须兼容旧格式,应通过版本化解析器处理,而不是放宽正则到任何字符串都能通过。

上线新规则前要保留旧算法和固定样本,比较两版命中差异。若差异超出产品确认范围,应回滚策略实现而不是修改历史日期。远端同步场景还应保存事件发生地时区或统一业务时区,否则客户端本地时间会让同一记录在不同设备得到不同答案。

十八、验收证据如何覆盖午夜边界

自动化至少需要固定在某一天的中午、23:59、次日 00:00 和时区切换前后执行,分别记录 TODAY、LAST_3_DAYS、LAST_7_DAYS 的命中集合。设备层则应验证应用进入后台跨过午夜再恢复时是否重新计算,而不是继续使用页面创建时缓存的“今天”。

证据记录要包含系统时区、固定时钟、输入日期、预期日差与实际结果。普通 Node 用例可以证明纯算法,但不能证明系统日期选择器、生命周期刷新或设备时区通知已经接通;这些未运行项必须单独保留。

十九、本文小结

“今天/近 3 天/近 7 天”看似只是毫秒比较,实际同时包含产品语义、日期解析、时区边界、非法输入和测试时钟。

“寻迹校园”严格解析 YYYY-MM-DD,用本地年月日确定业务日期,再映射为 UTC day number 计算自然日差;近 3 天使用差值 0~2,近 7 天使用 0~6,并明确拒绝未来和非法日期。固定时钟测试把这些边界变成可重复契约。

系列导航:第 19 篇 / 共 50 篇。上一篇:《六维 AND 组合筛选》;下一篇:《标准值与历史文本共存》。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐