repos
/ orchard main

orchard

mirror

Every 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

9.0 KB · 304 lines · Go Raw History
  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}