package memory import ( "testing" ) // 本文件逐条锁定 design/23 §5.1/§5.2 的内容判据(包含、时效、数值、来源), // 因为它们是「该合 / 不该合」的全部依据 —— 判据错了,整理就会丢信息。 // TestJudgeContainment子集与并列 覆盖 design/23 §1.3 的核心区分: // 子集(同一事实的碎片)判为包含;并列事实(红牛 vs 冰美式)判为互不包含。 func TestJudgeContainment子集与并列(t *testing.T) { t.Parallel() if c := judgeContainment(fixtureHabitShort, fixtureHabitLong); c.Kind != containASubsetB { t.Fatalf("碎片 ⊂ 完整 = %q, want %q", c.Kind, containASubsetB) } if c := judgeContainment(fixtureHabitLong, fixtureHabitShort); c.Kind != containBSubsetA { t.Fatalf("反向包含 = %q, want %q", c.Kind, containBSubsetA) } if c := judgeContainment(fixtureHabitShort, fixtureHabitShort); c.Kind != containBoth { t.Fatalf("同一内容 = %q, want %q", c.Kind, containBoth) } if c := judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced); c.Kind != containNone { t.Fatalf("并列偏好 = %q, want %q(相似不等于包含)", c.Kind, containNone) } if len(judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced).Missing) == 0 { t.Fatal("互不包含时必须给出缺失要素证据(diff 预览要用)") } } // TestJudgeContainmentEmoji要素 覆盖 design/23 §1.3 的 emoji 判据:emoji 是 // 关键要素,因此「多一个 emoji」的一方不可能是子集。 // // ⚠️ 与 design/23 §12.1 P4 的差异如实记录在这里:P4 把「[9] 有 🎁、[24] 无」 // 期望为 `link`(非包含)。但按设计稿 §1.3 的核心规则(A ⊆ B 才并入), // **少 🎁 的那条确实是子集** —— 它的每一个关键要素都在对方里,合并不会丢信息。 // 本实现跟随核心规则(产 merge 建议,且第一版全部交人工确认、不自动应用), // 并把「各有一个对方没有的 emoji」([4] 的 🎁 vs [20] 的 👀)判为互不包含 ⇒ link。 // 该不一致已在交付报告里列为「要登记的契约/设计变更点」。 func TestJudgeContainmentEmoji要素(t *testing.T) { t.Parallel() withGift := "主人送过助手礼物🎁,助手很开心" withoutGift := "主人送过助手礼物,助手很开心" if c := judgeContainment(withGift, withoutGift); c.Kind != containBSubsetA { t.Fatalf("含 🎁 的一方不是子集,无 🎁 的一方是:got %q, want %q", c.Kind, containBSubsetA) } if c := judgeContainment(withoutGift, withGift); c.Kind != containASubsetB { t.Fatalf("少 🎁 的一方是子集:got %q, want %q", c.Kind, containASubsetB) } if c := judgeContainment("主人送过助手礼物🎁", "主人送过助手礼物👀"); c.Kind != containNone { t.Fatalf("各有一个对方没有的 emoji ⇒ 互不包含,got %q", c.Kind) } } // TestHasAbsoluteTimestamp 覆盖 design/23 §5.1 T0-5:绝对日期/时刻是一次性 // 事件的信号,而「22点后 / 十点多」这类周期性表述不是。 func TestHasAbsoluteTimestamp(t *testing.T) { t.Parallel() cases := []struct { content string want bool }{ {fixtureOneOffEvent, true}, {"2026-09-08 的晚上", true}, {"国庆黄金周 10/3 假期第三天", true}, {"9月8日那晚聊到很晚", true}, {"周二晚上他还在加班", true}, {"主人常在晚上22点后保持活跃", false}, {"主人常熬夜到十点多", false}, {"每周二晚上一起看剧", false}, {fixtureHabitLong, false}, } for _, tc := range cases { if got := hasAbsoluteTimestamp(tc.content); got != tc.want { t.Errorf("hasAbsoluteTimestamp(%q) = %v, want %v", tc.content, got, tc.want) } } } // TestHasTemporalState 覆盖 design/23 §5.2-1:有期限的状态词(假期/本周…) // 命中即否决合并。 func TestHasTemporalState(t *testing.T) { t.Parallel() for _, content := range []string{ fixtureHabitVacation, "国庆假期期间处于休假状态", "本周在赶项目", "这几天暂时没空", } { if !hasTemporalState(content) { t.Errorf("hasTemporalState(%q) = false, want true", content) } } for _, content := range []string{ fixtureHabitLong, fixtureEnergyRedBull, "主人喜欢喝冰美式", } { if hasTemporalState(content) { t.Errorf("hasTemporalState(%q) = true, want false", content) } } } // TestSameNumbers 覆盖 design/23 §5.2-2:数值不同即不同事实。 func TestSameNumbers(t *testing.T) { t.Parallel() if !sameNumbers("服务跑在 8080 端口", "服务跑在 8080 端口上") { t.Fatal("同一数值应视为一致") } if sameNumbers("服务跑在 8080 端口", "服务跑在 9090 端口") { t.Fatal("数值不同必须判定不一致") } if sameNumbers("主人常在深夜加班", fixtureHabitLong) == false { t.Fatal("两边都无数值时视为一致(无数值可比)") } } // TestNonOwnerSource 覆盖 design/23 §5.2-9/§6:群聊/陌生人来源只读不写, // 而本进程的系统会话(backthink / 整理自身)与主人链路必须放行。 func TestNonOwnerSource(t *testing.T) { t.Parallel() cases := []struct { source string want bool }{ {FormatSourceConversation("sess-telegram:group1", "m1"), true}, {FormatSourceConversation("sess-kook:3bef", ""), true}, {FormatSourceConversation(backthinkSessionID, "m2"), false}, {FormatSourceConversation(TidySessionID, ""), false}, {FormatSourceConversation("web", "m3"), false}, {FormatSourceConversation("web:sub-1", ""), false}, {"主会话 2026-09-08 深夜", false}, // 裁决前的 LLM 自由文本:无法机器判定,放行 {"", false}, } for _, tc := range cases { if got := nonOwnerSource(tc.source); got != tc.want { t.Errorf("nonOwnerSource(%q) = %v, want %v", tc.source, got, tc.want) } } } // TestExtractTidyFacts 锁定事实要素抽取的两个关键形状:时刻是**一个**数值 // token(22:38),emoji 是词项。 func TestExtractTidyFacts(t *testing.T) { t.Parallel() facts := extractTidyFacts("2026年9月8日周二晚22:38,主人送了我🎁") for _, want := range []string{"22:38", "2026", "9"} { if _, ok := facts.nums[want]; !ok { t.Errorf("数值要素缺 %q:%v", want, facts.nums) } } if _, ok := facts.terms["🎁"]; !ok { t.Errorf("emoji 应是词项:%v", facts.terms) } if _, ok := facts.terms["熬夜"]; ok { t.Errorf("不存在的词项不该出现:%v", facts.terms) } } // TestTidyNegationConflictVetoed 覆盖 design/23 §5.2-3:X 与非X 是覆盖关系, // 不是重复,即使事实集合看起来包含也必须否决合并。 func TestTidyNegationConflictVetoed(t *testing.T) { t.Parallel() before := habitItem("主人喜欢喝红牛提神", tidyTestTime(t), 0.9) after := habitItem("主人不喜欢喝红牛提神", tidyTestTime(t), 0.9) verdict := judgePair(before, after, 0.98) if verdict.Veto != VetoConflict { t.Fatalf("冲突对 veto = %q, want %q", verdict.Veto, VetoConflict) } if verdict.Action != "" { t.Fatalf("冲突对不得带动作,got %q(冲突是覆盖,要走人工)", verdict.Action) } }