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}