package main import "testing" // TestResolveThreadRoot pins the conversation-routing gate — the single place that decides // whether a trigger continues a thread, roots a NEW conversation, or stays on the main // timeline. The load-bearing invariant is the LAST case: a group is NEVER auto-threaded, so // the threading feature can't change group behavior. Auto-threading in 1:1 DMs is always on // (no flag); the only gate is isDM. func TestResolveThreadRoot(t *testing.T) { inThread := &MessageContent{RelatesTo: &RelatesTo{RelType: "m.thread", EventID: "$root"}} topLevel := &MessageContent{} reply := &MessageContent{RelatesTo: &RelatesTo{RelType: "", EventID: "", InReplyTo: &InReplyTo{EventID: "$x"}}} ev := &Event{EventID: "$trigger"} cases := []struct { name string isDM bool mc *MessageContent want string }{ {"existing thread continues (DM)", true, inThread, "$root"}, {"existing thread continues (group)", false, inThread, "$root"}, {"DM top-level roots a new thread on the trigger", true, topLevel, "$trigger"}, {"GROUP top-level never auto-threads", false, topLevel, ""}, {"DM plain reply (no m.thread) roots a new thread", true, reply, "$trigger"}, {"GROUP plain reply never auto-threads", false, reply, ""}, } for _, c := range cases { b := &Bot{} if got := b.resolveThreadRoot(c.isDM, ev, c.mc); got != c.want { t.Errorf("%s: resolveThreadRoot = %q, want %q", c.name, got, c.want) } } } // TestBuildNoticeContentThreadRelation asserts the reply lands where resolveThreadRoot // decided: a non-empty threadRoot emits an m.thread relation (so the answer joins the // conversation), an empty one emits only m.in_reply_to (a plain top-level reply). func TestBuildNoticeContentThreadRelation(t *testing.T) { threaded := buildNoticeContent("$reply", "@u:vojo.chat", "$root", "hi") rel, ok := threaded["m.relates_to"].(map[string]any) if !ok { t.Fatalf("m.relates_to missing or wrong type: %T", threaded["m.relates_to"]) } if rel["rel_type"] != "m.thread" { t.Errorf("rel_type = %v, want m.thread", rel["rel_type"]) } if rel["event_id"] != "$root" { t.Errorf("event_id = %v, want $root", rel["event_id"]) } if rel["is_falling_back"] != true { t.Errorf("is_falling_back = %v, want true", rel["is_falling_back"]) } if inReply, _ := rel["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" { t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"]) } topLevel := buildNoticeContent("$reply", "@u:vojo.chat", "", "hi") rel2, ok := topLevel["m.relates_to"].(map[string]any) if !ok { t.Fatalf("m.relates_to missing or wrong type: %T", topLevel["m.relates_to"]) } if _, hasThread := rel2["rel_type"]; hasThread { t.Errorf("a top-level reply must not carry rel_type, got %v", rel2["rel_type"]) } if inReply, _ := rel2["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" { t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"]) } }