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 "math"
5 "strconv"
6 "strings"
7 "testing"
8 "time"
9)
10
11func TestBuildSparkNeedsTwoPoints(t *testing.T) {
12 for _, closes := range [][]float64{nil, {}, {1}} {
13 if s := buildSpark(closes, nil, 1, tradingAxis{}); s.Line != "" {
14 t.Errorf("%v drew a line, want nothing to draw", closes)
15 }
16 }
17}
18
19func TestBuildSparkKeepsEverythingInsideTheViewBox(t *testing.T) {
20 // A previous close well outside the day's range is the case that puts the
21 // baseline off the top of the box when it is not scaled in.
22 s := buildSpark([]float64{10, 12, 11, 14}, nil, 5, tradingAxis{})
23 if !s.HasBase {
24 t.Fatal("a previous close should give the card a baseline")
25 }
26 if s.Baseline < 0 || s.Baseline > sparkHeight {
27 t.Errorf("baseline at y=%v, outside 0..%v", s.Baseline, sparkHeight)
28 }
29
30 for _, y := range pathYs(t, s.Line) {
31 if y < 0 || y > sparkHeight {
32 t.Errorf("point at y=%v, outside 0..%v", y, sparkHeight)
33 }
34 }
35}
36
37func TestBuildSparkHandlesAFlatDay(t *testing.T) {
38 s := buildSpark([]float64{7, 7, 7, 7}, nil, 7, tradingAxis{})
39 if s.Line == "" {
40 t.Fatal("a flat day still has a line")
41 }
42 ys := pathYs(t, s.Line)
43 for _, y := range ys {
44 if math.IsNaN(y) || math.IsInf(y, 0) {
45 t.Fatalf("flat day produced %v, want a real number", y)
46 }
47 }
48 // Every point is the same price, so every point is at the same height.
49 for _, y := range ys[1:] {
50 if math.Abs(y-ys[0]) > 0.01 {
51 t.Errorf("flat day drew a slope: %v", ys)
52 break
53 }
54 }
55}
56
57func TestBuildSparkAreaClosesBackToTheFloor(t *testing.T) {
58 s := buildSpark([]float64{1, 2, 3}, nil, 1, tradingAxis{})
59 if !strings.HasSuffix(s.Area, "Z") {
60 t.Errorf("area path %q does not close", s.Area)
61 }
62 if !strings.Contains(s.Area, "L0,32") {
63 t.Errorf("area path %q does not return along the floor", s.Area)
64 }
65}
66
67// pathYs pulls the y of every point out of an SVG path built by buildSpark.
68func pathYs(t *testing.T, d string) []float64 {
69 t.Helper()
70
71 var ys []float64
72 for _, seg := range strings.Split(strings.NewReplacer("M", " ", "L", " ").Replace(d), " ") {
73 _, y, ok := strings.Cut(seg, ",")
74 if !ok {
75 continue
76 }
77 v, err := strconv.ParseFloat(y, 64)
78 if err != nil {
79 t.Fatalf("parsing y from %q: %v", seg, err)
80 }
81 ys = append(ys, v)
82 }
83 if len(ys) == 0 {
84 t.Fatalf("no points in %q", d)
85 }
86 return ys
87}
88
89func TestFormatNumberGroupsThousands(t *testing.T) {
90 cases := []struct {
91 in float64
92 decimals int
93 want string
94 }{
95 {7711.76, 2, "7,711.76"},
96 {53569.4, 2, "53,569.40"},
97 {77295.123, 0, "77,295"},
98 {14.43, 2, "14.43"},
99 {999, 0, "999"},
100 {1000, 0, "1,000"},
101 {-1234.5, 2, "-1,234.50"},
102 {0, 2, "0.00"},
103 }
104 for _, c := range cases {
105 if got := formatNumber(c.in, c.decimals); got != c.want {
106 t.Errorf("formatNumber(%v, %d) = %q, want %q", c.in, c.decimals, got, c.want)
107 }
108 }
109}
110
111// A card whose whole job is direction has to distinguish a zero move from an
112// unset one, so the sign is always written.
113func TestSignedAlwaysCarriesASign(t *testing.T) {
114 cases := []struct {
115 in float64
116 want string
117 }{
118 {1.5, "+1.50"},
119 {-1.5, "-1.50"},
120 {0, "+0.00"},
121 {-1234.5, "-1,234.50"},
122 }
123 for _, c := range cases {
124 if got := signed(c.in, 2); got != c.want {
125 t.Errorf("signed(%v) = %q, want %q", c.in, got, c.want)
126 }
127 }
128}
129
130func TestDirection(t *testing.T) {
131 for in, want := range map[float64]string{1: "up", -1: "down", 0: "flat"} {
132 if got := direction(in); got != want {
133 t.Errorf("direction(%v) = %q, want %q", in, got, want)
134 }
135 }
136}
137
138// The whole session is the axis, so a line drawn an hour into a six and a half
139// hour day covers about a sixth of the card. Spacing the points evenly instead
140// is what made a card at 9:35 look like a finished day.
141func TestBuildSparkDrawsAgainstTheWholeSession(t *testing.T) {
142 et := easternTime()
143 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
144
145 var closes []float64
146 var times []int64
147 for i := range 13 {
148 closes = append(closes, float64(100+i))
149 times = append(times, open.Add(time.Duration(i)*5*time.Minute).Unix())
150 }
151
152 s := buildSpark(closes, times, 100, sessionAxis(times))
153 if !s.Partial {
154 t.Error("an hour into the session is a partial day")
155 }
156 // One hour of a 390 minute session, so a bit over 15%.
157 if s.Span < 14 || s.Span > 17 {
158 t.Errorf("span = %v, want about 15 across the card", s.Span)
159 }
160}
161
162func TestBuildSparkFillsTheCardOnAFinishedSession(t *testing.T) {
163 et := easternTime()
164 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
165
166 var closes []float64
167 var times []int64
168 for i := range 79 {
169 closes = append(closes, float64(100+i%7))
170 times = append(times, open.Add(time.Duration(i)*5*time.Minute).Unix())
171 }
172
173 s := buildSpark(closes, times, 100, sessionAxis(times))
174 if s.Partial {
175 t.Errorf("a full session should fill the card, span = %v", s.Span)
176 }
177}
178
179// Every card is on the New York trading day now, so a future and bitcoin lay
180// their bars over the same 9:30 to 16:00 window a cash index does. They used to
181// take the full width, which made an hour of bitcoin look like a whole day
182// beside an hour of the S&P.
183func TestSessionAxisIsTheSameForEveryInstrument(t *testing.T) {
184 et := easternTime()
185 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
186
187 var times []int64
188 for i := range 13 {
189 times = append(times, open.Add(time.Duration(i)*5*time.Minute).Unix())
190 }
191
192 axis := sessionAxis(times)
193 if !axis.ok {
194 t.Fatal("a morning of bars should have a session to draw against")
195 }
196 if got := time.Unix(axis.start, 0).In(et); !got.Equal(open) {
197 t.Errorf("axis opens at %v, want %v", got, open)
198 }
199 if got := time.Unix(axis.end, 0).In(et); !got.Equal(open.Add(regularHours)) {
200 t.Errorf("axis shuts at %v, want 16:00", got)
201 }
202}
203
204// The trading day rolls at the open and not at midnight, so a bar printed at 2am
205// belongs to the session that started the morning before.
206func TestSessionStartRollsAtTheOpen(t *testing.T) {
207 et := easternTime()
208 cases := []struct{ at, want string }{
209 {"2026-08-31 09:29", "2026-08-30 09:30"},
210 {"2026-08-31 09:30", "2026-08-31 09:30"},
211 {"2026-08-31 23:59", "2026-08-31 09:30"},
212 {"2026-09-01 02:00", "2026-08-31 09:30"},
213 }
214 for _, c := range cases {
215 in, err := time.ParseInLocation("2006-01-02 15:04", c.at, et)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if got := sessionStart(in).Format("2006-01-02 15:04"); got != c.want {
220 t.Errorf("%s belongs to session %s, want %s", c.at, got, c.want)
221 }
222 }
223}
224
225// The VIX prints from 3:15am and those bars used to be clamped onto x=0, which
226// drew a vertical smear up the left of the card instead of a line.
227func TestSessionBarsDropsAnythingBeforeTheOpen(t *testing.T) {
228 et := easternTime()
229 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
230
231 var closes []float64
232 var times []int64
233 for i := range 10 {
234 closes = append(closes, float64(i))
235 times = append(times, open.Add(time.Duration(i-5)*30*time.Minute).Unix())
236 }
237
238 kept, kepts, axis := sessionBars(closes, times, sessionAxis(times))
239 if !axis.ok {
240 t.Fatal("bars inside the session should keep the window")
241 }
242 if len(kept) != 5 || len(kepts) != 5 {
243 t.Fatalf("kept %d bars, want the 5 from the open on", len(kept))
244 }
245 if kepts[0] != open.Unix() {
246 t.Errorf("first kept bar is %v, want the open", time.Unix(kepts[0], 0).In(et))
247 }
248}
249
250// Futures reopen at 6pm Sunday, hours before the Monday open their session
251// belongs to, so there is nothing to lay over Sunday's window and they take the
252// full width the way everything used to.
253func TestSessionBarsFallsBackWhenNothingPrintedInTheSession(t *testing.T) {
254 et := easternTime()
255 // 2026-08-30 is a Sunday.
256 reopen := time.Date(2026, 8, 30, 18, 0, 0, 0, et)
257
258 var closes []float64
259 var times []int64
260 for i := range 12 {
261 closes = append(closes, float64(i))
262 times = append(times, reopen.Add(time.Duration(i)*5*time.Minute).Unix())
263 }
264
265 if _, _, axis := sessionBars(closes, times, sessionAxis(times)); axis.ok {
266 t.Error("an evening reopen has no session box, so it should draw full width")
267 }
268}
269
270// Yahoo dates bitcoin's day by UTC and a future's by its contract, so the close
271// each card measures from has to come off the bars instead.
272func TestPreviousSessionCloseIsFourPMTheDayBefore(t *testing.T) {
273 et := easternTime()
274 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
275 prevClose := time.Date(2026, 8, 30, 16, 0, 0, 0, et)
276
277 var closes []float64
278 var times []int64
279 // Every half hour across the previous day and into the session.
280 for at := prevClose.Add(-3 * time.Hour); at.Before(open.Add(time.Hour)); at = at.Add(30 * time.Minute) {
281 closes = append(closes, float64(at.Unix()))
282 times = append(times, at.Unix())
283 }
284
285 got, found := previousSessionClose(closes, times, open)
286 if !found {
287 t.Fatal("a full previous day should have a close in it")
288 }
289 if int64(got) != prevClose.Unix() {
290 t.Errorf("previous close taken from %v, want %v",
291 time.Unix(int64(got), 0).In(et), prevClose)
292 }
293}
294
295func TestPreviousSessionCloseMissingIsReported(t *testing.T) {
296 et := easternTime()
297 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
298 times := []int64{open.Unix(), open.Add(time.Hour).Unix()}
299
300 if _, found := previousSessionClose([]float64{1, 2}, times, open); found {
301 t.Error("bars that all fall inside the session carry no previous close")
302 }
303}