170 lines
7.0 KiB
Go
170 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|