orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package tools
2
3import (
4 "testing"
5 "time"
6)
7
8func at(s string) time.Time {
9 t, err := time.ParseInLocation("2006-01-02 15:04", s, newYork())
10 if err != nil {
11 panic(err)
12 }
13 return t
14}
15
16// The windows are the whole point of this tool: if Isaac says today he means
17// today, so each one is pinned against a fixed clock rather than trusted.
18func TestNewsWindowsAreTakenLiterally(t *testing.T) {
19 // A Monday afternoon, so "weekend" is the two days just gone.
20 now := at("2026-09-07 16:20")
21
22 for _, tc := range []struct {
23 window, from, to string
24 }{
25 {"today", "2026-09-07 00:00", "2026-09-07 16:20"},
26 {"yesterday", "2026-09-06 00:00", "2026-09-07 00:00"},
27 {"weekend", "2026-09-05 00:00", "2026-09-07 00:00"},
28 // "over the weekend including today" has no answer in either of the
29 // other two, and the plain weekend window excludes today by definition.
30 {"weekend-and-today", "2026-09-05 00:00", "2026-09-07 16:20"},
31 {"week", "2026-09-01 00:00", "2026-09-07 16:20"},
32 {"month", "2026-08-09 00:00", "2026-09-07 16:20"},
33 } {
34 since, until, label := resolveWindow(now, tc.window)
35 if !since.Equal(at(tc.from)) {
36 t.Errorf("%s: from = %s, want %s", tc.window, since, at(tc.from))
37 }
38 if !until.Equal(at(tc.to)) {
39 t.Errorf("%s: to = %s, want %s", tc.window, until, at(tc.to))
40 }
41 if label == "" {
42 t.Errorf("%s: no label, so the answer cannot say which days it read", tc.window)
43 }
44 }
45}
46
47// Asked on a Saturday, the weekend is the one being had and not the one before,
48// and it cannot run past now.
49func TestTheWeekendAskedForOnTheWeekendIsThisOne(t *testing.T) {
50 now := at("2026-09-05 11:00")
51 since, until, _ := resolveWindow(now, "weekend")
52 if !since.Equal(at("2026-09-05 00:00")) {
53 t.Errorf("from = %s, want Saturday morning", since)
54 }
55 if !until.Equal(now) {
56 t.Errorf("to = %s, want now rather than a future Sunday night", until)
57 }
58}
59
60// An unknown word is today rather than an error, since the model picking
61// something outside the enum should not cost the turn.
62func TestAnUnknownWindowIsToday(t *testing.T) {
63 now := at("2026-09-07 16:20")
64 since, _, _ := resolveWindow(now, "fortnight")
65 if !since.Equal(at("2026-09-07 00:00")) {
66 t.Errorf("from = %s, want this morning", since)
67 }
68}
69
70func TestFeedTimesParse(t *testing.T) {
71 for _, s := range []string{
72 "Mon, 07 Sep 2026 15:04:05 +0000",
73 "Mon, 7 Sep 2026 15:04:05 GMT",
74 "2026-09-07T15:04:05Z",
75 "2026-09-07T11:04:05-04:00",
76 } {
77 if _, ok := parseFeedTime(s); !ok {
78 t.Errorf("did not parse %q", s)
79 }
80 }
81 if _, ok := parseFeedTime("last tuesday"); ok {
82 t.Error("parsed something that is not a date")
83 }
84}
85
86// A feed description arrives with markup often enough that leaving it in spends
87// the model's window on span tags.
88func TestSummariesLoseTheirMarkup(t *testing.T) {
89 got := trimSummary(`<p>Five dead after a <a href="x">crash</a>.</p>`)
90 if got != "Five dead after a crash." {
91 t.Errorf("summary = %q", got)
92 }
93}
94
95// Two publishers carrying one story is worth knowing. One publisher's own item
96// arriving twice is not.
97func TestDuplicatesGoAndSeparatePublishersStay(t *testing.T) {
98 in := []NewsItem{
99 {Source: "BBC", Headline: "Plane crash at Miami", URL: "https://bbc/1"},
100 {Source: "BBC", Headline: "Plane crash at Miami", URL: "https://bbc/1"},
101 {Source: "NPR", Headline: "Plane crash at Miami", URL: "https://npr/1"},
102 }
103 if got := dedupeNews(in); len(got) != 2 {
104 t.Errorf("kept %d items, want the two publishers", len(got))
105 }
106}
107
108// On general news a scored post about timestamps must not outrank a fatal
109// crash, and on tech the score is exactly what should lead.
110func TestWhatLeadsDependsOnTheTopic(t *testing.T) {
111 desk := NewsItem{Source: "NPR", Headline: "Five dead in crash", rank: 0}
112 scored := NewsItem{Source: "Hacker News", Headline: "Timestamp conversion", Points: 900}
113
114 general := []NewsItem{scored, desk}
115 sortNews(general, false)
116 if general[0].Source != "NPR" {
117 t.Errorf("general led with %q", general[0].Source)
118 }
119
120 tech := []NewsItem{desk, scored}
121 sortNews(tech, true)
122 if tech[0].Source != "Hacker News" {
123 t.Errorf("tech led with %q", tech[0].Source)
124 }
125}
126
127// The bug that started this: a flat cap over everything, sorted desks first,
128// took 28 of 45 items and left all fourteen Hacker News stories and both
129// Lobsters ones on the floor. A section holding both has to split its slots.
130func TestASectionHoldingBothGivesTheAggregatorsSlots(t *testing.T) {
131 // A plain taker, so this tests the sharing and not the sorting.
132 take := func(items []NewsItem, slots int, _ bool) []NewsItem {
133 if len(items) > slots {
134 return items[:slots]
135 }
136 return items
137 }
138 desks := make([]NewsItem, 12)
139 for i := range desks {
140 desks[i] = NewsItem{Source: "BBC"}
141 }
142 scored := make([]NewsItem, 14)
143 for i := range scored {
144 scored[i] = NewsItem{Source: "Hacker News", Points: 400}
145 }
146
147 got := fillSection(section{slots: 9, aggregators: true,
148 feeds: []feed{{"BBC", "u"}}}, desks, scored, take)
149 if len(got) != 9 {
150 t.Fatalf("filled %d of 9 slots", len(got))
151 }
152 counts := map[string]int{}
153 for _, it := range got {
154 counts[it.Source]++
155 }
156 if counts["Hacker News"] == 0 {
157 t.Errorf("the aggregators were starved again: %v", counts)
158 }
159 if counts["BBC"] == 0 {
160 t.Errorf("the newsrooms were starved: %v", counts)
161 }
162}
163
164// A section with nothing from one side still fills up from the other rather
165// than coming back half empty.
166func TestASectionFillsUpWhenOneSideIsEmpty(t *testing.T) {
167 take := func(items []NewsItem, slots int, _ bool) []NewsItem {
168 if len(items) > slots {
169 return items[:slots]
170 }
171 return items
172 }
173 scored := make([]NewsItem, 10)
174 for i := range scored {
175 scored[i] = NewsItem{Source: "Hacker News", Points: 300}
176 }
177 got := fillSection(section{slots: 6, aggregators: true, feeds: []feed{{"BBC", "u"}}}, nil, scored, take)
178 if len(got) != 6 {
179 t.Errorf("filled %d of 6 slots with only aggregators available", len(got))
180 }
181}
182
183// The rundown carried the same NPR story twice on 2026-09-08 because the two
184// copies came off different feeds with different tracking parameters, and the
185// url was the only key that got looked at.
186func TestDedupeNewsIgnoresTrackingParameters(t *testing.T) {
187 in := []NewsItem{
188 {Source: "NPR Technology", Headline: "Voters are fed up with data centers", URL: "https://www.npr.org/2026/09/08/data-centers"},
189 {Source: "NPR Technology", Headline: "Voters are fed up with data centers", URL: "https://npr.org/2026/09/08/data-centers/?utm_source=rss"},
190 {Source: "BBC Technology", Headline: "Voters are fed up with data centers", URL: "https://bbc.co.uk/news/tech-1"},
191 }
192 out := dedupeNews(in)
193 if len(out) != 2 {
194 t.Fatalf("dedupe left %d items, want the NPR pair collapsed and the BBC one kept", len(out))
195 }
196 // Two publishers carrying one story is worth knowing, so the BBC copy stays.
197 if out[1].Source != "BBC Technology" {
198 t.Errorf("the second publisher was dropped: %#v", out)
199 }
200}
201
202// One story written up under two headlines is still one story, which an exact
203// key cannot see. The Navier-Stokes paper led the tech section twice.
204func TestSameStoryCatchesARewrittenHeadline(t *testing.T) {
205 a := headlineWords("On the Navier-Stokes Millennium Prize Problem")
206 b := headlineWords("Navier-Stokes Millennium Prize Problem, by Tristan Buckmaster")
207 if !sameStory(a, b) {
208 t.Error("two headlines about one paper were treated as two stories")
209 }
210
211 // And two genuinely different stories are not merged, which would lose one.
212 c := headlineWords("Mistral raises 3B euros for sovereign open-weight AI")
213 if sameStory(a, c) {
214 t.Error("two unrelated stories were merged")
215 }
216 // A short headline has too little in it to judge, so it is left alone.
217 if sameStory(headlineWords("Keep Our Servers Running"), headlineWords("Servers Running Hot")) {
218 t.Error("two short headlines were merged on a couple of shared words")
219 }
220}