package memory import ( "testing" "time" "yunyan/comm" "yunyan/pb" ) func mkItem(cat, date, clock, rule string, weekday int32, ahead int32) *pb.DBMemoryItem { return &pb.DBMemoryItem{ Id: 1, Category: cat, HappenDate: date, HappenTime: clock, RepeatRule: rule, Weekday: weekday, RemindAhead: ahead, State: pb.MemoryState_MemoryState_Pending, Tz: "Asia/Shanghai", } } // 一次性项:只在那一天产生一个时刻,且要扣掉提前量 func TestExpandItem_Once(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) it := mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5) slots := expandItem(it, from, 7, loc) if len(slots) != 1 { t.Fatalf("一次性项应产生 1 个时刻,得到 %d", len(slots)) } want := time.Date(2026, 9, 6, 8, 55, 0, 0, loc).Unix() if slots[0].RemindAt != want { t.Errorf("提醒时刻应为 08:55(09:00 提前5分钟),得到 %s", time.Unix(slots[0].RemindAt, 0).In(loc).Format("2006-01-02 15:04")) } } // 已经过去的一次性项不该再排。排了就是 App 一打开立刻弹一堆过期提醒。 func TestExpandItem_SkipsPast(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 10, 8, 0, 0, 0, loc) it := mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5) if slots := expandItem(it, from, 7, loc); len(slots) != 0 { t.Errorf("过期项不该产生时刻,得到 %d 个", len(slots)) } } // 重复项是 v0.1 设计的坑:一个 remind_at 字段表达不了「每天」。 // 展开必须给出窗口内的每一天。 func TestExpandItem_Daily(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) it := mkItem(comm.MemoryCatAlarm, "2026-09-01", "09:00", comm.MemoryRepeatDaily, 0, 5) slots := expandItem(it, from, 7, loc) if len(slots) != 7 { t.Fatalf("每天重复在 7 天窗口内应有 7 个时刻,得到 %d", len(slots)) } // 今天 09:00 还没到(from 是 08:00),所以第一个应该是今天 first := time.Unix(slots[0].RemindAt, 0).In(loc) if first.Format("2006-01-02") != "2026-09-05" { t.Errorf("第一个时刻应是今天 2026-09-05,得到 %s", first.Format("2006-01-02")) } } // 每周:锚点日远在过去时也必须能算出未来的时刻, // 否则去年设的闹钟今年就不响了。 func TestExpandItem_WeeklyWithOldAnchor(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) // 周六 // 锚点在一年前,规则是每周一 it := mkItem(comm.MemoryCatAlarm, "2025-09-01", "09:00", comm.MemoryRepeatWeekly, 1, 5) slots := expandItem(it, from, 14, loc) if len(slots) != 2 { t.Fatalf("14 天窗口内每周一应有 2 个时刻,得到 %d", len(slots)) } for _, s := range slots { d := time.Unix(s.HappenAt, 0).In(loc) if d.Weekday() != time.Monday { t.Errorf("每周一的时刻落在了 %v (%s)", d.Weekday(), d.Format("2006-01-02")) } } } // 灵感与花销是「记下来」不是「提醒我」,不该排提醒 func TestExpandItem_IdeaAndExpenseNeverRemind(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) for _, cat := range []string{comm.MemoryCatIdea, comm.MemoryCatExpense} { it := mkItem(cat, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5) if slots := expandItem(it, from, 7, loc); len(slots) != 0 { t.Errorf("%s 不该产生提醒,得到 %d 个", cat, len(slots)) } } } // remind_ahead=0 表示不提醒 func TestExpandItem_NoRemindWhenAheadZero(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) it := mkItem(comm.MemoryCatTodo, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 0) if slots := expandItem(it, from, 7, loc); len(slots) != 0 { t.Errorf("remind_ahead=0 不该产生提醒,得到 %d 个", len(slots)) } } // 已完成的项不再提醒 func TestExpandItem_DoneNeverRemind(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 8, 0, 0, 0, loc) it := mkItem(comm.MemoryCatTodo, "2026-09-06", "09:00", comm.MemoryRepeatOnce, 0, 5) it.State = pb.MemoryState_MemoryState_Done if slots := expandItem(it, from, 7, loc); len(slots) != 0 { t.Errorf("已完成项不该产生提醒,得到 %d 个", len(slots)) } } // 没有 happen_time 的全天项按 9:00 计,不能落在 0:00 半夜吵人 func TestExpandItem_AllDayUsesNineAM(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 5, 0, 30, 0, 0, loc) it := mkItem(comm.MemoryCatTodo, "2026-09-05", "", comm.MemoryRepeatOnce, 0, 5) slots := expandItem(it, from, 7, loc) if len(slots) != 1 { t.Fatalf("应有 1 个时刻,得到 %d", len(slots)) } h := time.Unix(slots[0].HappenAt, 0).In(loc) if h.Hour() != 9 || h.Minute() != 0 { t.Errorf("全天项应按 09:00 计,得到 %02d:%02d", h.Hour(), h.Minute()) } } // expandAll 必须按时间升序:客户端要按 iOS 的 64 条上限截断, // 顺序错了会把明天的排掉、留下一周后的 func TestExpandAll_SortedAscending(t *testing.T) { items := []*pb.DBMemoryItem{ mkItem(comm.MemoryCatAlarm, "2026-09-30", "09:00", comm.MemoryRepeatOnce, 0, 5), mkItem(comm.MemoryCatAlarm, "2026-09-06", "09:00", comm.MemoryRepeatDaily, 0, 5), } items[0].Id, items[1].Id = 10, 20 slots := expandAll(items, 30, "Asia/Shanghai") for i := 1; i < len(slots); i++ { if slots[i-1].RemindAt > slots[i].RemindAt { t.Fatalf("展开结果未按时间升序:第 %d 个 %d > 第 %d 个 %d", i-1, slots[i-1].RemindAt, i, slots[i].RemindAt) } } } // 单条项按它自己的 tz 算,不跟着请求方的时区走: // 「明早 8 点」不该因为用户落地伦敦就变成伦敦时间 8 点 func TestExpandItem_UsesItemOwnTimezone(t *testing.T) { // ⚠️ 日期必须相对「现在」算。expandAll 内部以 time.Now() 为起点, // 写死一个日期的话这条测试会在那天之后变成「过期的一次性项」而恒返回 0 个时刻 // ——原来写的 2026-09-06 就是这么在 09-18 集体变红的,且红得毫无提示性。 sh, _ := time.LoadLocation("Asia/Shanghai") tomorrow := time.Now().In(sh).AddDate(0, 0, 1).Format("2006-01-02") it := mkItem(comm.MemoryCatAlarm, tomorrow, "09:00", comm.MemoryRepeatOnce, 0, 0) it.RemindAhead = 5 it.Tz = "Asia/Shanghai" // 请求方声称在伦敦,但项自己带了上海时区 slots := expandAll([]*pb.DBMemoryItem{it}, 30, "Europe/London") if len(slots) != 1 { t.Fatalf("应有 1 个时刻,得到 %d", len(slots)) } got := time.Unix(slots[0].HappenAt, 0).In(sh) if got.Hour() != 9 { t.Errorf("应按项自己的上海时区算出 09:00,得到 %02d:%02d", got.Hour(), got.Minute()) } } // 从库里读出来的 happen_date 是 `2026-09-07T00:00:00+08:00`(type:date 列 + parseTime=True), // 不是 YYYY-MM-DD。expandItem 必须扛得住这个形状 —— // 扛不住的后果不是报错,而是 memory_upcoming 恒返回 0 个提醒时刻, // 整个拾忆提醒在服务端空转,2026-09-18 真机上就是这样。 func TestExpandItem_AcceptsDbDateFormat(t *testing.T) { loc, _ := time.LoadLocation("Asia/Shanghai") from := time.Date(2026, 9, 7, 6, 0, 0, 0, loc) plain := mkItem(comm.MemoryCatAlarm, "2026-09-08", "09:00", comm.MemoryRepeatOnce, 0, 5) fromDb := mkItem(comm.MemoryCatAlarm, "2026-09-08T00:00:00+08:00", "09:00", comm.MemoryRepeatOnce, 0, 5) a := expandItem(plain, from, 7, loc) b := expandItem(fromDb, from, 7, loc) if len(a) != 1 { t.Fatalf("干净日期应产生 1 个时刻,得到 %d", len(a)) } if len(b) != len(a) || (len(b) == 1 && b[0].RemindAt != a[0].RemindAt) { t.Errorf("库里读出来的日期格式应与干净格式等价:得到 %d 个时刻(期望 %d 个且时刻相同)", len(b), len(a)) } }