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 "encoding/json"
5 "testing"
6)
7
8func chartJSON(t *testing.T, price, prev float64, closes []any, stamps []int64) chartPayload {
9 t.Helper()
10 body := map[string]any{"chart": map[string]any{"result": []any{map[string]any{
11 "meta": map[string]any{"currency": "USD", "symbol": "VTI",
12 "regularMarketPrice": price, "chartPreviousClose": prev, "shortName": "Vanguard"},
13 "timestamp": stamps,
14 "indicators": map[string]any{"quote": []any{map[string]any{"close": closes}}},
15 }}}}
16 b, err := json.Marshal(body)
17 if err != nil {
18 t.Fatal(err)
19 }
20 var p chartPayload
21 if err := json.Unmarshal(b, &p); err != nil {
22 t.Fatal(err)
23 }
24 return p
25}
26
27// The intraday chart is the only one measured against yesterday's close. Every
28// longer span is measured against its own first bar, because "up 18% this year"
29// is a claim about a year ago and not about yesterday.
30func TestBuildSeriesBaseline(t *testing.T) {
31 closes := []any{100.0, 105.0, 110.0}
32 stamps := []int64{1, 2, 3}
33
34 day, err := buildSeries(chartJSON(t, 110, 50, closes, stamps), RangeByKey("1d"), "VTI")
35 if err != nil {
36 t.Fatal(err)
37 }
38 if day.Previous != 50 || day.Percent != 120 {
39 t.Fatalf("intraday should measure from the previous close: prev=%v pct=%v", day.Previous, day.Percent)
40 }
41
42 year, err := buildSeries(chartJSON(t, 110, 50, closes, stamps), RangeByKey("1y"), "VTI")
43 if err != nil {
44 t.Fatal(err)
45 }
46 if year.Previous != 100 || year.Percent != 10 {
47 t.Fatalf("a year should measure from its own first bar: prev=%v pct=%v", year.Previous, year.Percent)
48 }
49}
50
51// A null close is a bar the exchange never printed. Zeroing one draws a spike
52// to the floor of the chart, so it has to be dropped along with its timestamp.
53func TestBuildSeriesDropsNullBars(t *testing.T) {
54 s, err := buildSeries(
55 chartJSON(t, 0, 0, []any{100.0, nil, 102.0}, []int64{10, 20, 30}),
56 RangeByKey("1d"), "VTI")
57 if err != nil {
58 t.Fatal(err)
59 }
60 if len(s.Points) != 2 {
61 t.Fatalf("want 2 points, got %d: %+v", len(s.Points), s.Points)
62 }
63 if s.Points[1].T != 30 || s.Points[1].C != 102 {
64 t.Fatalf("the surviving bar kept the wrong timestamp: %+v", s.Points[1])
65 }
66 // An index carries no quote price, so the last bar has to become it.
67 if s.Price != 102 {
68 t.Fatalf("want the last bar as the price, got %v", s.Price)
69 }
70}
71
72func TestBuildSeriesNoPrices(t *testing.T) {
73 if _, err := buildSeries(chartJSON(t, 0, 0, []any{nil}, []int64{1}), RangeByKey("1d"), "VTI"); err == nil {
74 t.Fatal("a chart with no printed bar should be an error, not an empty chart")
75 }
76}
77
78func TestRangeByKeyFallsBackToTheDay(t *testing.T) {
79 if got := RangeByKey("wat"); got.Key != "1d" {
80 t.Fatalf("an unknown range should fall back to the day, got %q", got.Key)
81 }
82 for _, k := range []string{"1d", "1w", "1m", "1y"} {
83 if RangeByKey(k).Key != k {
84 t.Fatalf("%s did not resolve to itself", k)
85 }
86 }
87}
88
89// The bands are what the tile prints, and an off-by-one at a breakpoint is the
90// difference between "good" and "moderate" on the same reading.
91func TestBands(t *testing.T) {
92 for _, c := range []struct {
93 v float64
94 want string
95 }{{50, "good"}, {51, "moderate"}, {100, "moderate"}, {101, "unhealthy for some"},
96 {301, "hazardous"}} {
97 if got := aqiBand(c.v); got != c.want {
98 t.Errorf("aqiBand(%v) = %q, want %q", c.v, got, c.want)
99 }
100 }
101 for _, c := range []struct {
102 v float64
103 want string
104 }{{0, "low"}, {2.4, "low-medium"}, {4.8, "medium"}, {7.2, "medium-high"}, {9.7, "high"}} {
105 if got := pollenBand(c.v); got != c.want {
106 t.Errorf("pollenBand(%v) = %q, want %q", c.v, got, c.want)
107 }
108 }
109}
110
111// A sink is per turn and a model that asks for the same symbol twice should not
112// stack two identical charts.
113func TestSinkIgnoresARepeat(t *testing.T) {
114 s := NewSink()
115 s.Add(Widget{Kind: "ticker", Symbol: "VTI"})
116 s.Add(Widget{Kind: "ticker", Symbol: "VTI"})
117 s.Add(Widget{Kind: "ticker", Symbol: "VOO"})
118 s.Add(Widget{Kind: "weather", Place: "Boone"})
119 if got := len(s.List()); got != 3 {
120 t.Fatalf("want 3 widgets, got %d: %+v", got, s.List())
121 }
122}
123
124// A nil sink is what a tool sees outside a turn, and it must not panic.
125func TestNilSinkIsSafe(t *testing.T) {
126 var s *Sink
127 s.Add(Widget{Kind: "ticker", Symbol: "VTI"})
128 if s.List() != nil {
129 t.Fatal("a nil sink should list nothing")
130 }
131}