抽出日期计算纯函数模块,并搭建后端测试基建
- 新增 server/src/occurrence.js:startOfDay/getNextOccurrence/daysBetween,today 可注入 - reminder.js 改为调用新模块,删除内联的 getThisYearDate/daysBetween - 引入 node:test 作为测试框架,加 npm test 脚本 - 新增 test/helper.js(临时库辅助,供后续任务用)和 test/occurrence.test.js(7 个用例,覆盖农历跨年等边界)
This commit is contained in:
+2
-1
@@ -5,7 +5,8 @@
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test test/"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 纪念日日期计算(纯函数,today 可注入以便测试)
|
||||
*
|
||||
* 从 reminder.js 抽出,供定时发送与「风险预警」计算共用,避免两处各写一套算错。
|
||||
*/
|
||||
|
||||
const lunar = require('./lunar')
|
||||
|
||||
// 把日期归到当天 00:00:00,消除时分秒对比较和天数差的干扰
|
||||
function startOfDay(date) {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出某条纪念日的下一次发生日(今天或以后最近的一次)
|
||||
*
|
||||
* 农历分支以「农历年」为基准递推:公历年和农历年不对齐,农历腊月通常落在下一个公历年,
|
||||
* 直接拿公历年当农历年传会整整错一年。
|
||||
* isLunar 为真但缺 lunarMonth/lunarDay 的老数据,安全降级按公历算。
|
||||
*/
|
||||
function getNextOccurrence(anniv, today = new Date()) {
|
||||
const base = startOfDay(today)
|
||||
|
||||
if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) {
|
||||
const wantLeap = !!anniv.isLeapMonth
|
||||
const todayLunar = lunar.solarToLunar(base)
|
||||
let target = startOfDay(lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap))
|
||||
if (target < base) {
|
||||
target = startOfDay(lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap))
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
let target = startOfDay(new Date(base.getFullYear(), anniv.solarMonth - 1, anniv.solarDay))
|
||||
if (target < base) {
|
||||
target = startOfDay(new Date(base.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay))
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// 距离目标日还有多少天(负数表示已过)
|
||||
function daysBetween(target, today = new Date()) {
|
||||
return Math.round((startOfDay(target) - startOfDay(today)) / 86400000)
|
||||
}
|
||||
|
||||
module.exports = { startOfDay, getNextOccurrence, daysBetween }
|
||||
+3
-36
@@ -2,6 +2,7 @@ const cron = require('node-cron')
|
||||
const db = require('./db')
|
||||
const wx = require('./wx')
|
||||
const lunar = require('./lunar')
|
||||
const occurrence = require('./occurrence')
|
||||
|
||||
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
|
||||
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
|
||||
@@ -27,40 +28,6 @@ function formatDate(date) {
|
||||
return `${y}年${m}月${d}日`
|
||||
}
|
||||
|
||||
// 算出"距离今年(或明年)这个纪念日还有多少天"
|
||||
// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay
|
||||
function getThisYearDate(anniv) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) {
|
||||
// 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算
|
||||
const todayLunar = lunar.solarToLunar(today)
|
||||
const wantLeap = !!anniv.isLeapMonth
|
||||
let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
if (target < today) {
|
||||
target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
if (target < today) {
|
||||
target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
function daysBetween(target) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return Math.round((target - today) / 86400000)
|
||||
}
|
||||
|
||||
// 检查今天是否已经给这条纪念日发过提醒
|
||||
function alreadySentToday(anniversaryId) {
|
||||
const start = new Date()
|
||||
@@ -87,8 +54,8 @@ async function runOnce() {
|
||||
|
||||
for (const anniv of list) {
|
||||
try {
|
||||
const target = getThisYearDate(anniv)
|
||||
const daysUntil = daysBetween(target)
|
||||
const target = occurrence.getNextOccurrence(anniv)
|
||||
const daysUntil = occurrence.daysBetween(target)
|
||||
|
||||
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0))
|
||||
if (!shouldRemind) continue
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
// 把 DB_PATH 指向临时目录,避免测试碰到真实库。
|
||||
// 必须在 require('../src/db') 之前调用,因为 db.js 在 require 时就读取 DB_PATH。
|
||||
function useTempDb() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-test-'))
|
||||
process.env.DB_PATH = path.join(dir, 'test.db')
|
||||
return dir
|
||||
}
|
||||
|
||||
module.exports = { useTempDb }
|
||||
@@ -0,0 +1,45 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const { getNextOccurrence, daysBetween, startOfDay } = require('../src/occurrence')
|
||||
|
||||
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
||||
test('公历纪念日:今年还没到就取今年', () => {
|
||||
const anniv = { isLunar: false, solarMonth: 12, solarDay: 25 }
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2026-12-25')
|
||||
})
|
||||
|
||||
test('公历纪念日:今年已过就取明年', () => {
|
||||
const anniv = { isLunar: false, solarMonth: 5, solarDay: 20 }
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-20')
|
||||
})
|
||||
|
||||
test('农历腊月十五:跨公历年不会算错一年', () => {
|
||||
const anniv = { isLunar: true, lunarMonth: 12, lunarDay: 15, isLeapMonth: false }
|
||||
// 2027-01-20 时,下一次应是 2 天后的 2027-01-22,而不是 2028 年
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 20))), '2027-01-22')
|
||||
// 过了之后应跳到下一个农历年
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 23))), '2028-01-11')
|
||||
})
|
||||
|
||||
test('农历四月十七', () => {
|
||||
const anniv = { isLunar: true, lunarMonth: 4, lunarDay: 17, isLeapMonth: false }
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-22')
|
||||
})
|
||||
|
||||
test('isLunar 但缺农历字段时降级按公历算,不抛错', () => {
|
||||
const anniv = { isLunar: true, lunarMonth: null, lunarDay: null, solarMonth: 6, solarDay: 5 }
|
||||
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-06-05')
|
||||
})
|
||||
|
||||
test('daysBetween 忽略时分秒', () => {
|
||||
const today = new Date(2026, 7, 15, 23, 59, 0)
|
||||
const target = new Date(2026, 7, 17, 0, 1, 0)
|
||||
assert.strictEqual(daysBetween(target, today), 2)
|
||||
})
|
||||
|
||||
test('startOfDay 归零时分秒', () => {
|
||||
const d = startOfDay(new Date(2026, 7, 15, 13, 45, 30))
|
||||
assert.strictEqual(d.getHours(), 0)
|
||||
assert.strictEqual(d.getMinutes(), 0)
|
||||
})
|
||||
Reference in New Issue
Block a user