jwz_test.go

  1package threading
  2
  3import (
  4	"reflect"
  5	"testing"
  6	"time"
  7)
  8
  9func TestBuildThreeMessageChain(t *testing.T) {
 10	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
 11	threads := Build([]EmailHeader{
 12		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"},
 13		{ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"},
 14		{ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, //nolint:dupword
 15	})
 16
 17	if len(threads) != 1 {
 18		t.Fatalf("got %d threads, want 1", len(threads))
 19	}
 20	if threads[0].Count != 3 {
 21		t.Fatalf("got count %d, want 3", threads[0].Count)
 22	}
 23	if got := threads[0].Root.Children[0].Children[0].EmailID; got != "3" {
 24		t.Fatalf("got chain leaf %q, want 3", got)
 25	}
 26}
 27
 28func TestBuildForkedThread(t *testing.T) {
 29	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
 30	threads := Build([]EmailHeader{
 31		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
 32		{ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
 33		{ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2"},
 34	})
 35
 36	if len(threads) != 1 {
 37		t.Fatalf("got %d threads, want 1", len(threads))
 38	}
 39	children := threads[0].Root.Children
 40	if len(children) != 2 {
 41		t.Fatalf("got %d children, want 2", len(children))
 42	}
 43	if children[0].EmailID != "2" || children[1].EmailID != "3" {
 44		t.Fatalf("got child order %q, %q; want 2, 3", children[0].EmailID, children[1].EmailID)
 45	}
 46}
 47
 48func TestBuildMissingParentPlaceholderRoot(t *testing.T) {
 49	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
 50	threads := Build([]EmailHeader{
 51		{ID: "<child@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base, EmailID: "child"},
 52		{ID: "<other@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "other"},
 53	})
 54
 55	if len(threads) != 1 {
 56		t.Fatalf("got %d threads, want 1", len(threads))
 57	}
 58	if threads[0].Root.EmailID != "" {
 59		t.Fatalf("got root EmailID %q, want placeholder", threads[0].Root.EmailID)
 60	}
 61	if len(threads[0].Root.Children) != 2 {
 62		t.Fatalf("got %d placeholder children, want 2", len(threads[0].Root.Children))
 63	}
 64}
 65
 66func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) {
 67	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
 68	threads := Build([]EmailHeader{
 69		{ID: "<a@example>", Subject: "Re: Foo", Date: base, EmailID: "1"},
 70		{ID: "<b@example>", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"},
 71		{ID: "<c@example>", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"},
 72	})
 73
 74	if len(threads) != 2 {
 75		t.Fatalf("got %d threads, want 2", len(threads))
 76	}
 77	var grouped Thread
 78	for _, thread := range threads {
 79		if thread.Subject == "foo" {
 80			grouped = thread
 81			break
 82		}
 83	}
 84	if grouped.Count != 2 {
 85		t.Fatalf("got grouped count %d, want 2", grouped.Count)
 86	}
 87}
 88
 89func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) {
 90	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
 91	threads := Build([]EmailHeader{
 92		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
 93		{ID: "<b@example>", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"},
 94		{ID: "<c@example>", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
 95		{ID: "<d@example>", Subject: "Antw: Foo", Date: base.Add(3 * time.Minute), EmailID: "4"},
 96	})
 97
 98	if len(threads) != 1 {
 99		t.Fatalf("got %d threads, want 1", len(threads))
100	}
101	if threads[0].Subject != "foo" {
102		t.Fatalf("got subject %q, want foo", threads[0].Subject)
103	}
104	if threads[0].Count != 4 {
105		t.Fatalf("got grouped count %d, want 4", threads[0].Count)
106	}
107}
108
109func TestBuildEmptyReferencesList(t *testing.T) {
110	threads := Build([]EmailHeader{
111		{ID: "<a@example>", References: nil, Subject: "Foo", Date: time.Now(), EmailID: "1"},
112	})
113
114	if len(threads) != 1 {
115		t.Fatalf("got %d threads, want 1", len(threads))
116	}
117	if threads[0].Root.EmailID != "1" {
118		t.Fatalf("got root %q, want 1", threads[0].Root.EmailID)
119	}
120}
121
122func TestBuildStableOrderingAcrossCalls(t *testing.T) {
123	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
124	headers := []EmailHeader{
125		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
126		{ID: "<b@example>", Subject: "Bar", Date: base, EmailID: "2"},
127		{ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base, EmailID: "3"},
128	}
129
130	first := Build(headers)
131	second := Build(headers)
132	if !reflect.DeepEqual(first, second) {
133		t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second)
134	}
135}
136
137func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) {
138	tests := map[string]string{
139		"Re: Re: Foo":     "foo", //nolint:dupword
140		"Fwd: FW: Foo":    "foo",
141		"AW: WG: Tr: Foo": "foo",
142		"Reé: Resp: Foo":  "foo",
143		"SV: VS: RV: Foo": "foo",
144		"ENC: Antw: Foo":  "foo",
145		"Odp: R: I: Foo":  "foo",
146		"  Foo  ":         "foo",
147	}
148
149	for in, want := range tests {
150		if got := canonicalSubject(in); got != want {
151			t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want)
152		}
153	}
154}