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 main
2
3import (
4 "strings"
5 "testing"
6 "time"
7)
8
9func day(y int, m time.Month, d int) time.Time {
10 return time.Date(y, m, d, 12, 0, 0, 0, time.UTC)
11}
12
13// The answer that started this: every fixture in it had already been played,
14// every sentence in it was true, and none of it was what was asked for.
15const playedFixtures = `On September 4, 2026, Liverpool FC played Ipswich Town in a match that ended in a draw [2].
16
17- Liverpool FC played Ipswich Town on September 4, 2026 [2].
18- Liverpool FC played Newcastle United on August 23, 2026 [4].
19- Liverpool FC played Brentford FC on May 24, 2026 [7].`
20
21func TestLatestDate(t *testing.T) {
22 now := day(2026, time.September, 5)
23 cases := []struct {
24 text string
25 want string
26 }{
27 {playedFixtures, "2026-09-04"},
28 {"Liverpool play Fulham at Anfield on 12 September 2026.", "2026-09-12"},
29 {"The next one is September 12, 2026, at 3pm.", "2026-09-12"},
30 {"Published 2026-09-12 by the club.", "2026-09-12"},
31 {"They play on 12 September, kick off at 12:30.", "2026-09-12"},
32 {"The window closes in September 2026.", "2026-09-01"},
33 {"No date anywhere in this sentence.", ""},
34 }
35 for _, c := range cases {
36 got, ok := latestDate(c.text, now)
37 if c.want == "" {
38 if ok {
39 t.Errorf("%q: found %s, wanted nothing", c.text, got.Format("2006-01-02"))
40 }
41 continue
42 }
43 if !ok {
44 t.Errorf("%q: found no date, wanted %s", c.text, c.want)
45 continue
46 }
47 if got.Format("2006-01-02") != c.want {
48 t.Errorf("%q: got %s, want %s", c.text, got.Format("2006-01-02"), c.want)
49 }
50 }
51}
52
53// A date with no year written in December is next January, not ten months ago.
54func TestLatestDateRollsTheYear(t *testing.T) {
55 got, ok := latestDate("They are back on 3 January.", day(2026, time.December, 20))
56 if !ok || got.Format("2006-01-02") != "2027-01-03" {
57 t.Errorf("got %s ok=%v, want 2027-01-03", got.Format("2006-01-02"), ok)
58 }
59}
60
61func TestNoFutureDate(t *testing.T) {
62 now := day(2026, time.September, 5)
63 if !noFutureDate(playedFixtures, now) {
64 t.Error("an answer of played fixtures should not count as answering what is next")
65 }
66 if !noFutureDate("They have a game soon.", now) {
67 t.Error("an answer with no date at all does not answer when")
68 }
69 // Today counts, since a match this evening is the next one.
70 if noFutureDate("They play Fulham on 5 September 2026.", now) {
71 t.Error("today is not in the past")
72 }
73 if noFutureDate("They played on 4 September and play Fulham on 12 September 2026.", now) {
74 t.Error("one future date is enough, background about the last match is fine")
75 }
76}
77
78func TestPastOnly(t *testing.T) {
79 now := day(2026, time.September, 5)
80 got := pastOnly(playedFixtures, now)
81 if len(got) != 1 || !strings.Contains(got[0], "4 September 2026") {
82 t.Fatalf("want one warning naming the latest date, got %q", got)
83 }
84 if got := pastOnly("They play Fulham on 12 September 2026.", now); len(got) != 0 {
85 t.Errorf("a dated future answer needs no warning, got %q", got)
86 }
87 if got := pastOnly("There is a game coming up.", now); len(got) != 1 {
88 t.Errorf("an answer with no date should warn, got %q", got)
89 }
90 if got := pastOnly("", now); len(got) != 0 {
91 t.Errorf("an empty answer is handled elsewhere, got %q", got)
92 }
93}
94
95func TestGuessShapeUpcoming(t *testing.T) {
96 for _, q := range []string{
97 "when is the next liverpool game",
98 "when do liverpool play next",
99 "upcoming premier league fixtures",
100 "when is the next spacex launch",
101 "gta 6 release date",
102 } {
103 if got := guessShape(q); got != ShapeUpcoming {
104 t.Errorf("%q: got %s, want upcoming", q, got)
105 }
106 }
107 // The other half of the same question still reads as news.
108 for _, q := range []string{"who won the liverpool game", "did liverpool win last night"} {
109 if got := guessShape(q); got != ShapeNews {
110 t.Errorf("%q: got %s, want news", q, got)
111 }
112 }
113}
114
115// A shape the planner can pick and the contract map does not hold falls back to
116// prose, which is the wrong format quietly.
117func TestEveryShapeHasAContract(t *testing.T) {
118 for _, s := range shapeEnum {
119 c, ok := contracts[s]
120 if !ok {
121 t.Errorf("%s is in the plan enum with no contract", s)
122 continue
123 }
124 if c.Shape != s || c.Instruction == "" || c.MaxTokens == 0 {
125 t.Errorf("%s has an incomplete contract: %+v", s, c)
126 }
127 }
128 // The shapes the planner is never offered still need a contract, since
129 // something else picks them.
130 for _, s := range offEnum {
131 if _, ok := contracts[s]; !ok {
132 t.Errorf("%s has no contract", s)
133 }
134 }
135 if want := len(shapeEnum) + len(offEnum); want != len(contracts) {
136 t.Errorf("%d shapes against %d contracts, so one of them is unreachable", want, len(contracts))
137 }
138}
139
140// A fixture list writes the month short, and the page that carries the cup tie
141// is not always the page the answer came off.
142func TestSoonerAcrossPassages(t *testing.T) {
143 now := day(2026, time.September, 5)
144 passages := []Passage{
145 {ID: 1, Text: "Next match\n\n12 Sept 2026, 14:00/Liverpool FC vs Fulham FC"},
146 {ID: 2, Text: "## Upcoming fixtures\n- Liverpool vs Atleti Wed, 9 Sept 2026 - 19:00 - Champions League"},
147 }
148 answer := "The next Liverpool game is on **12 September 2026** against Fulham FC at Anfield [1]."
149 got := sooner(answer, passages, now)
150 if len(got) != 1 || !strings.Contains(got[0], "9 September") {
151 t.Fatalf("want a warning naming the earlier date, got %q", got)
152 }
153 if got := sooner("The next one is 9 September 2026 [2].", passages, now); len(got) != 0 {
154 t.Errorf("leading with the soonest needs no warning, got %q", got)
155 }
156 // Nothing sooner in the passages, so nothing to say.
157 if got := sooner(answer, passages[:1], now); len(got) != 0 {
158 t.Errorf("got %q", got)
159 }
160 // A page stamping itself with today is not a match today.
161 stamped := []Passage{{ID: 1, Text: "**Last updated:** Sep 5, 2026\n\n- 12 Sept 2026, 14:00/Liverpool FC vs Fulham FC"}}
162 if got := sooner(answer, stamped, now); len(got) != 0 {
163 t.Errorf("a page stamp is not a fixture, got %q", got)
164 }
165}
166
167func TestShortMonths(t *testing.T) {
168 now := day(2026, time.September, 5)
169 for text, want := range map[string]string{
170 "Wed, 9 Sept 2026 - 19:00": "2026-09-09",
171 "12 Sept 2026, 14:00": "2026-09-12",
172 "Sat Nov 28, 2026 at Goodison": "2026-11-28",
173 "31 Feb 2027 is not a day": "",
174 } {
175 got, ok := latestDate(text, now)
176 if want == "" {
177 if ok {
178 t.Errorf("%q: read %s off a date that does not exist", text, got.Format("2006-01-02"))
179 }
180 continue
181 }
182 if !ok || got.Format("2006-01-02") != want {
183 t.Errorf("%q: got %s ok=%v, want %s", text, got.Format("2006-01-02"), ok, want)
184 }
185 }
186}
187
188func TestScheduleAge(t *testing.T) {
189 now := day(2026, time.September, 5)
190 fresh := []Source{{Published: "2026-06-19"}, {Published: "2026-09-02"}}
191 if got := scheduleAge(fresh, now); len(got) != 0 {
192 t.Errorf("a page from three days ago is current enough, got %q", got)
193 }
194 old := []Source{{Published: "2026-06-19"}, {Published: "2026-07-05"}}
195 if got := scheduleAge(old, now); len(got) != 1 || !strings.Contains(got[0], "62 days ago") {
196 t.Errorf("want one warning naming the age, got %q", got)
197 }
198 if got := scheduleAge([]Source{{URL: "https://example.com"}}, now); len(got) != 1 {
199 t.Errorf("an undated page should say so, got %q", got)
200 }
201}