package comm import ( "testing" "time" ) // 周日在 Go 里 Weekday()==0、在 Dart 里 weekday==7。这套换算错了不会报错, // 只会让「每周日」的闹钟在周一响——所以单独守住。 func TestMemoryRepeatHits_WeekdayMapping(t *testing.T) { loc := time.UTC // 2026-09-06 是周日,2026-09-07 是周一 sunday := time.Date(2026, 9, 6, 0, 0, 0, 0, loc) monday := time.Date(2026, 9, 7, 0, 0, 0, 0, loc) if sunday.Weekday() != time.Sunday || monday.Weekday() != time.Monday { t.Fatalf("测试基准日期不对: %v %v", sunday.Weekday(), monday.Weekday()) } // weekly + weekday=7(Dart 口径的周日)应命中周日、不命中周一 if !MemoryRepeatHits(MemoryRepeatWeekly, 7, sunday, sunday) { t.Error("weekday=7 应命中周日") } if MemoryRepeatHits(MemoryRepeatWeekly, 7, monday, sunday) { t.Error("weekday=7 不应命中周一") } // weekday=1 是周一 if !MemoryRepeatHits(MemoryRepeatWeekly, 1, monday, sunday) { t.Error("weekday=1 应命中周一") } } func TestMemoryRepeatHits_WeekdaysAndWeekend(t *testing.T) { loc := time.UTC days := map[string]time.Time{ "周五": time.Date(2026, 9, 4, 0, 0, 0, 0, loc), "周六": time.Date(2026, 9, 5, 0, 0, 0, 0, loc), "周日": time.Date(2026, 9, 6, 0, 0, 0, 0, loc), "周一": time.Date(2026, 9, 7, 0, 0, 0, 0, loc), } anchor := days["周五"] cases := []struct { day string weekday bool weekend bool }{ {"周五", true, false}, {"周六", false, true}, {"周日", false, true}, // 周日必须算周末,不能因为 Go 的 0 被当成周一 {"周一", true, false}, } for _, c := range cases { if got := MemoryRepeatHits(MemoryRepeatWeekdays, 0, days[c.day], anchor); got != c.weekday { t.Errorf("%s weekdays 期望 %v 得到 %v", c.day, c.weekday, got) } if got := MemoryRepeatHits(MemoryRepeatWeekend, 0, days[c.day], anchor); got != c.weekend { t.Errorf("%s weekend 期望 %v 得到 %v", c.day, c.weekend, got) } } } // ISO 周口径:跨年那一周最容易出错 func TestMemoryWeekKeyAndRange(t *testing.T) { loc := time.UTC // 2026-01-01 是周四,按 ISO 属于 2026-W01,那一周的周一是 2025-12-29 d := time.Date(2026, 1, 1, 12, 0, 0, 0, loc) if k := MemoryWeekKey(d); k != "2026-W01" { t.Errorf("周键期望 2026-W01 得到 %s", k) } start, end := MemoryWeekRange(d) if FormatMemoryDate(start) != "2025-12-29" { t.Errorf("周起期望 2025-12-29 得到 %s", FormatMemoryDate(start)) } if FormatMemoryDate(end) != "2026-01-04" { t.Errorf("周止期望 2026-01-04 得到 %s", FormatMemoryDate(end)) } // 周日当天取到的必须还是本周(周一~周日),不能滑到下一周 sunday := time.Date(2026, 1, 4, 23, 0, 0, 0, loc) s2, e2 := MemoryWeekRange(sunday) if FormatMemoryDate(s2) != "2025-12-29" || FormatMemoryDate(e2) != "2026-01-04" { t.Errorf("周日取周范围错: %s ~ %s", FormatMemoryDate(s2), FormatMemoryDate(e2)) } } func TestMemoryMonthRange(t *testing.T) { loc := time.UTC // 闰年 2 月 d := time.Date(2028, 2, 10, 0, 0, 0, 0, loc) s, e := MemoryMonthRange(d) if FormatMemoryDate(s) != "2028-02-01" || FormatMemoryDate(e) != "2028-02-29" { t.Errorf("2028-02 范围错: %s ~ %s", FormatMemoryDate(s), FormatMemoryDate(e)) } } // 认不出的时区必须回退到本地时区,绝不能回退成 UTC—— // 那会让所有时刻静默偏移 8 小时。 func TestLoadMemoryLocation_FallbackIsLocalNotUTC(t *testing.T) { if got := LoadMemoryLocation("Nowhere/Nothing"); got != time.Local { t.Errorf("非法时区应回退 time.Local,得到 %v", got) } if got := LoadMemoryLocation(""); got != time.Local { t.Errorf("空时区应回退 time.Local,得到 %v", got) } if got := LoadMemoryLocation("Asia/Tokyo"); got.String() != "Asia/Tokyo" { t.Errorf("合法时区解析失败,得到 %v", got) } } func TestNewMemoryClientKey_NotEmptyAndUnique(t *testing.T) { seen := map[string]bool{} for i := 0; i < 1000; i++ { k := NewMemoryClientKey() if k == "" { t.Fatal("client_key 不能为空:它参与唯一索引,空串会让第二条记录插不进去") } if seen[k] { t.Fatalf("client_key 重复: %s", k) } seen[k] = true } } func TestParseMemoryClock(t *testing.T) { if h, m, ok := ParseMemoryClock("15:30"); !ok || h != 15 || m != 30 { t.Errorf("15:30 解析错: %d %d %v", h, m, ok) } if _, _, ok := ParseMemoryClock(""); ok { t.Error("空串不该解析成功") } if _, _, ok := ParseMemoryClock("25:00"); ok { t.Error("25:00 不该解析成功") } } // 时区解析:IANA 名是正道,固定偏移与 CST 是给存量数据兜底的。 // ⚠️ 认不出来必须退到服务器本地时区,**不能退成 UTC**——那会让所有时刻静默偏 8 小时。 func TestLoadMemoryLocation(t *testing.T) { sh, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Skip("镜像里没有 tzdata,跳过") } // 用同一个瞬间比偏移量,而不是比 Location 的名字: // FixedZone 造出来的 loc 名字是 "UTC+8" 之类,与 "Asia/Shanghai" 永远不相等, // 但它们在这一刻的偏移是一样的,那才是我们真正关心的东西。 at := time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC) offsetOf := func(loc *time.Location) int { _, off := at.In(loc).Zone() return off } wantSh := offsetOf(sh) cases := []struct { name string tz string want int }{ {"IANA 名", "Asia/Shanghai", wantSh}, {"东京", "Asia/Tokyo", 9 * 3600}, {"CST 缩写按上海解释(存量数据全是它)", "CST", wantSh}, {"cst 大小写不敏感", "cst", wantSh}, {"UTC+8", "UTC+8", 8 * 3600}, {"GMT+08:00", "GMT+08:00", 8 * 3600}, {"裸偏移 +08:00", "+08:00", 8 * 3600}, {"负偏移 -0530", "-0530", -(5*3600 + 30*60)}, {"空串 → 服务器本地", "", offsetOf(time.Local)}, {"认不出来 → 服务器本地,不是 UTC", "Mars/Olympus", offsetOf(time.Local)}, {"越界的偏移不认", "UTC+99", offsetOf(time.Local)}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := offsetOf(LoadMemoryLocation(c.tz)); got != c.want { t.Errorf("%q 的偏移应为 %d 秒,得到 %d 秒", c.tz, c.want, got) } }) } } // 重复项展开:返回的是「除库里那一行之外还发生了几次」,不是总次数。 // 数错的后果是周报里「这周被闹钟叫了多少次」要么恒为 0(不展开),要么多算一次(不扣锚点)。 func TestMemoryExtraOccurrences(t *testing.T) { d := func(s string) time.Time { v, ok := ParseMemoryDate(s) if !ok { t.Fatalf("测试日期写错了: %s", s) } return v } // 2026-09-07(一) ~ 2026-09-13(日),典型的一个周报周期 start, end := d("2026-09-07"), d("2026-09-13") cases := []struct { name string rule string weekday int32 anchor string want int }{ {"一次性项不展开", MemoryRepeatOnce, 0, "2026-09-08", 0}, {"空规则不展开", "", 0, "2026-09-08", 0}, {"每天、锚点在几个月前:7 天全算,那一行没被数过", MemoryRepeatDaily, 0, "2026-06-01", 7}, {"每天、锚点就在周期内:7 次里扣掉已数过的那一行", MemoryRepeatDaily, 0, "2026-09-07", 6}, {"每天、锚点在周期中间:只从锚点当天算起", MemoryRepeatDaily, 0, "2026-09-11", 2}, {"每周一、锚点在之前:本周命中 1 次且未被数过", MemoryRepeatWeekly, 1, "2026-06-01", 1}, {"工作日、锚点在之前:5 次", MemoryRepeatWeekdays, 0, "2026-06-01", 5}, {"周末、锚点在之前:2 次", MemoryRepeatWeekend, 0, "2026-06-01", 2}, {"每月 8 号、锚点在之前:本周含 8 号,1 次", MemoryRepeatMonthly, 0, "2026-06-08", 1}, {"每月 20 号:本周不含,0 次", MemoryRepeatMonthly, 0, "2026-06-20", 0}, // ⚠️ 这条守着不能返回 -1:规则在本周期一次都没命中、但那一行恰好落在周期内时, // 扣掉锚点会把调用方本来数对的那一行抹掉。 {"锚点在周期内但规则本周不命中:返回 0 而不是 -1", MemoryRepeatWeekly, 1, "2026-09-09", 0}, {"锚点在周期之后:一次都不算", MemoryRepeatDaily, 0, "2026-12-01", 0}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := MemoryExtraOccurrences(c.rule, c.weekday, d(c.anchor), start, end) if got != c.want { t.Errorf("期望 %d 次,得到 %d", c.want, got) } if got < 0 { t.Error("返回负数会把调用方已经数对的那一行抹掉") } }) } } // period_start/period_end 是 type:date 列 + parseTime=True,读回 Go 的 string 字段 // 是 RFC3339 而不是 YYYY-MM-DD。任何要在 Go 里解析这个值的地方不先归一就会静默失效 // ——2026-09-18 真机上重复闹钟的展开就是这么一次都没生效的。 func TestNormalizeMemoryDate(t *testing.T) { cases := []struct{ in, want string }{ {"2026-09-07", "2026-09-07"}, {" 2026-09-07 ", "2026-09-07"}, {"2026-09-07T00:00:00+08:00", "2026-09-07"}, // 库里读回来的真实形状 {"2026-09-07T00:00:00Z", "2026-09-07"}, {"2026-09-07 00:00:00", "2026-09-07"}, {"", ""}, {"不是日期", "不是日期"}, // 认不出来原样返回,行为与没有这个函数时一致 {"09/07/2026", "09/07/2026"}, } for _, c := range cases { if got := NormalizeMemoryDate(c.in); got != c.want { t.Errorf("NormalizeMemoryDate(%q) = %q,期望 %q", c.in, got, c.want) } } // 归一之后必须真的能解析出来——这才是加它的目的 if _, ok := ParseMemoryDate(NormalizeMemoryDate("2026-09-07T00:00:00+08:00")); !ok { t.Error("归一后仍解析不出日期,重复项展开会继续静默失效") } }