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

28.9 KB · 1000 lines · Go Raw History
  1package main
  2
  3import (
  4	"encoding/json"
  5	"io/fs"
  6	"math"
  7	"net/http"
  8	"net/http/httptest"
  9	"net/url"
 10	"os"
 11	"strings"
 12	"testing"
 13	"time"
 14
 15	"dash.bythewood.me/web"
 16)
 17
 18func TestHostOf(t *testing.T) {
 19	cases := map[string]string{
 20		"https://www.example.com/a/b?c=d": "example.com",
 21		"https://example.com:8443/x":      "example.com",
 22		"https://news.ycombinator.com/":   "news.ycombinator.com",
 23		"not a url":                       "",
 24		"":                                "",
 25	}
 26	for in, want := range cases {
 27		if got := hostOf(in); got != want {
 28			t.Errorf("hostOf(%q) = %q, want %q", in, got, want)
 29		}
 30	}
 31}
 32
 33func TestHumanAge(t *testing.T) {
 34	now := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
 35	cases := []struct {
 36		ago  time.Duration
 37		want string
 38	}{
 39		{10 * time.Second, "just now"},
 40		{5 * time.Minute, "5m"},
 41		{3 * time.Hour, "3h"},
 42		{50 * time.Hour, "2d"},
 43	}
 44	for _, c := range cases {
 45		if got := humanAge(now.Add(-c.ago), now); got != c.want {
 46			t.Errorf("%s ago gave %q, want %q", c.ago, got, c.want)
 47		}
 48	}
 49}
 50
 51// The WMO code ranges are contiguous, so an off-by-one at a boundary is the
 52// failure mode and every boundary is checked rather than a sample.
 53func TestDescribeWeatherBoundaries(t *testing.T) {
 54	cases := map[int]string{
 55		0: "Clear", 1: "Partly cloudy", 2: "Partly cloudy", 3: "Overcast",
 56		45: "Fog", 48: "Fog",
 57		51: "Drizzle", 57: "Drizzle",
 58		61: "Rain", 67: "Rain",
 59		71: "Snow", 77: "Snow",
 60		80: "Showers", 82: "Showers",
 61		85: "Snow showers", 86: "Snow showers",
 62		95: "Thunderstorms", 99: "Thunderstorms",
 63		4: "Unknown", 44: "Unknown",
 64	}
 65	for code, want := range cases {
 66		if got := describeWeather(code); got != want {
 67			t.Errorf("code %d = %q, want %q", code, got, want)
 68		}
 69	}
 70}
 71
 72// A cached edge response is not evidence the origin is up, which is the whole
 73// reason the strip asks the bridge first.
 74func TestCacheHit(t *testing.T) {
 75	for _, s := range []string{"HIT", "hit", "STALE", "UPDATING", "REVALIDATED"} {
 76		if !cacheHit(s) {
 77			t.Errorf("%q should count as served from cache", s)
 78		}
 79	}
 80	for _, s := range []string{"MISS", "DYNAMIC", "EXPIRED", "BYPASS", ""} {
 81		if cacheHit(s) {
 82			t.Errorf("%q should not count as served from cache", s)
 83		}
 84	}
 85}
 86
 87// A probe that the guard refused measured nothing, so the row has to stay
 88// unknown. Reporting it as down is what made the strip claim six live sites
 89// were dead.
 90func TestProbeReportsUnknownWhenTheGuardRefuses(t *testing.T) {
 91	g := NewGuard(t.TempDir())
 92	g.Fail("uptime", http.StatusTooManyRequests, 0)
 93
 94	row := probe(t.Context(), g, Monitored{Label: "Blog", Source: "blog", Host: "blog.invalid"})
 95	if row.State != "unknown" {
 96		t.Errorf("state %q, want unknown", row.State)
 97	}
 98}
 99
100func newTestSite(t *testing.T) *site {
101	t.Helper()
102
103	dist := os.DirFS("build/dist")
104	if _, err := fs.Stat(dist, ".vite/manifest.json"); err != nil {
105		t.Skip("no vite bundle; run `make frontend` first")
106	}
107	assets, err := web.LoadAssets(dist)
108	if err != nil {
109		t.Fatal(err)
110	}
111	templates, err := fs.Sub(templateFS, "templates")
112	if err != nil {
113		t.Fatal(err)
114	}
115	renderer, err := web.NewRenderer(templates, templateFuncs,
116		[]string{"base.html", "partials.html"},
117		[]string{"home.html", "notfound.html"})
118	if err != nil {
119		t.Fatal(err)
120	}
121
122	hub := NewHub()
123	return &site{
124		renderer: renderer,
125		store:    NewStore(hub),
126		hub:      hub,
127		guard:    NewGuard(t.TempDir()),
128		script:   assets.Script("index.js"),
129		styles:   assets.Styles("index.js"),
130	}
131}
132
133// html/template resolves a missing field at execute time rather than at parse
134// time, so a page nothing renders is a page nothing checks.
135func TestHomeRendersWithAnEmptyState(t *testing.T) {
136	s := newTestSite(t)
137
138	rec := httptest.NewRecorder()
139	s.home(rec, httptest.NewRequest(http.MethodGet, "/", nil))
140
141	if rec.Code != http.StatusOK {
142		t.Fatalf("status %d, want 200", rec.Code)
143	}
144	body := rec.Body.String()
145	for _, want := range []string{"MARKETS", "CONDITIONS", "RATES", "SECTORS", "EARNINGS", "WIRE",
146		"HACKER NEWS", "LOBSTERS", "ATMOS", "SYSTEMS", "UPLINK", "STEAM"} {
147		if !strings.Contains(body, want) {
148			t.Errorf("the page is missing the %s panel", want)
149		}
150	}
151}
152
153func TestHomeRendersAFullState(t *testing.T) {
154	s := newTestSite(t)
155
156	now := time.Date(2026, 8, 27, 14, 0, 0, 0, time.UTC)
157	s.store.update(func(st *State) {
158		st.Market = buildMarket(map[string]Quote{
159			"^GSPC": {Price: 100, Previous: 99, High52: 110, AsOf: now, Closes: []float64{99, 100, 101}},
160		}, now)
161		st.HN = []Story{{Title: "A story", URL: "https://example.com/a", Host: "example.com", Comments: "https://news.ycombinator.com/item?id=1", Points: 10, Count: 2, Age: "1h"}}
162		st.Lobsters = []Story{{Title: "Another", URL: "https://example.com/b", Host: "example.com", Comments: "https://lobste.rs/s/x", Points: 5, Count: 1, Age: "2h"}}
163		st.Weather = Weather{Place: "Yadkin Valley, NC", Temperature: "81", Feels: "86", High: "86", Low: "70", Rain: "3%", Condition: "Clear", Wind: "4 mph"}
164		st.Systems = Systems{Rows: []SystemRow{{Label: "Blog", State: "up", Response: "12ms", Errors: 3, KnowError: true}}, Up: 1, Total: 1, Window: 24}
165	})
166
167	rec := httptest.NewRecorder()
168	s.home(rec, httptest.NewRequest(http.MethodGet, "/", nil))
169
170	if rec.Code != http.StatusOK {
171		t.Fatalf("status %d, want 200", rec.Code)
172	}
173	body := rec.Body.String()
174	for _, want := range []string{"A story", "Another", "Yadkin Valley", "S&P 500", "spark-line"} {
175		if !strings.Contains(body, want) {
176			t.Errorf("the rendered page is missing %q", want)
177		}
178	}
179}
180
181func TestNotFoundRenders(t *testing.T) {
182	s := newTestSite(t)
183
184	rec := httptest.NewRecorder()
185	s.notFound(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
186
187	if rec.Code != http.StatusNotFound {
188		t.Errorf("status %d, want 404", rec.Code)
189	}
190}
191
192// The page is public, so the JSON behind it is too, and it must not grow a
193// field that carries anything but numbers and public feed content.
194func TestStateJSONCarriesNoInternalDetail(t *testing.T) {
195	s := newTestSite(t)
196	s.store.update(func(st *State) {
197		st.Systems = Systems{Rows: []SystemRow{{Label: "Blog", Host: "blog.bythewood.me", State: "up", Response: "12ms", Errors: 3, KnowError: true}}}
198	})
199
200	rec := httptest.NewRecorder()
201	s.state(rec, httptest.NewRequest(http.MethodGet, "/api/state", nil))
202
203	var row map[string]any
204	var body struct {
205		Systems struct {
206			Rows []map[string]any `json:"rows"`
207		} `json:"systems"`
208	}
209	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
210		t.Fatal(err)
211	}
212	row = body.Systems.Rows[0]
213
214	// Each of these was looked at before being added. label, host and url are
215	// public hostnames that are already links on the page. state, response,
216	// errors and the traffic figures are counts about sites that are on the
217	// internet, and Isaac asked for them on a page with no login. The baseline
218	// logging computes the trend from is not among them: the derived direction
219	// is published and the underlying week is not.
220	allowed := map[string]bool{
221		"label": true, "host": true, "url": true, "state": true,
222		"response": true, "errors": true, "know_error": true,
223		"requests": true, "know_traf": true, "level": true, "trend": true,
224	}
225	for k := range row {
226		if !allowed[k] {
227			t.Errorf("a system row publishes %q, which was not reviewed as safe for a public page", k)
228		}
229	}
230}
231
232func TestHubReplaysTheLatestFrameToANewSubscriber(t *testing.T) {
233	h := NewHub()
234	h.Broadcast([]byte("first"))
235
236	frames, unsubscribe := h.Subscribe()
237	defer unsubscribe()
238
239	select {
240	case got := <-frames:
241		if string(got) != "first" {
242			t.Errorf("replayed %q, want first", got)
243		}
244	default:
245		t.Error("a new subscriber got nothing, so a reconnect would show an empty page until the next poll")
246	}
247}
248
249// A browser that cannot keep up must not hold the broadcast for everyone else.
250func TestHubDropsRatherThanBlocking(t *testing.T) {
251	h := NewHub()
252	_, unsubscribe := h.Subscribe()
253	defer unsubscribe()
254
255	done := make(chan struct{})
256	go func() {
257		for i := 0; i < 100; i++ {
258			h.Broadcast([]byte("frame"))
259		}
260		close(done)
261	}()
262
263	select {
264	case <-done:
265	case <-time.After(2 * time.Second):
266		t.Fatal("Broadcast blocked on a subscriber that was not reading")
267	}
268}
269
270func TestHubUnsubscribeIsIdempotent(t *testing.T) {
271	h := NewHub()
272	_, unsubscribe := h.Subscribe()
273
274	unsubscribe()
275	unsubscribe()
276
277	if n := h.Watching(); n != 0 {
278		t.Errorf("%d watchers after unsubscribing, want 0", n)
279	}
280}
281
282func TestIsLoopback(t *testing.T) {
283	for _, ip := range []string{"127.0.0.1", "::1"} {
284		if !isLoopback(ip) {
285			t.Errorf("%s should be loopback", ip)
286		}
287	}
288	for _, ip := range []string{"8.8.8.8", "192.168.1.5", "", "garbage"} {
289		if isLoopback(ip) {
290			t.Errorf("%s should not be loopback", ip)
291		}
292	}
293}
294
295// A visitor arriving during a quiet spell must not wait out the rest of the
296// idle interval on a page that says it is live.
297func TestHubWakesOnTheFirstSubscriber(t *testing.T) {
298	h := NewHub()
299
300	frames, unsubscribe := h.Subscribe()
301	_ = frames
302	select {
303	case <-h.wake:
304	default:
305		t.Fatal("the first subscriber did not wake the poller")
306	}
307
308	// A second viewer while one is already connected is not a quiet spell
309	// ending, so it must not force another fetch.
310	_, unsubscribe2 := h.Subscribe()
311	select {
312	case <-h.wake:
313		t.Error("a second concurrent subscriber woke the poller as well")
314	default:
315	}
316
317	unsubscribe()
318	unsubscribe2()
319
320	// Back to nobody, so the next arrival is a fresh wake.
321	_, unsubscribe3 := h.Subscribe()
322	defer unsubscribe3()
323	select {
324	case <-h.wake:
325	default:
326		t.Error("the first subscriber after everyone left did not wake the poller")
327	}
328}
329
330// The wake send must never block the browser that is connecting.
331func TestHubWakeDoesNotBlockAConnectingClient(t *testing.T) {
332	h := NewHub()
333
334	done := make(chan struct{})
335	go func() {
336		for i := 0; i < 50; i++ {
337			_, unsubscribe := h.Subscribe()
338			unsubscribe()
339		}
340		close(done)
341	}()
342
343	select {
344	case <-done:
345	case <-time.After(2 * time.Second):
346		t.Fatal("Subscribe blocked on the wake channel")
347	}
348}
349
350// The panel says where every figure on the page came from, so the order has to
351// be stable: a map range would reshuffle it on every poll.
352func TestGuardFeedsAreStableAndComplete(t *testing.T) {
353	g := NewGuard(t.TempDir())
354	now := time.Now()
355
356	first := g.Feeds(now)
357	if len(first) != len(feedOrder) {
358		t.Fatalf("%d feeds, want %d", len(first), len(feedOrder))
359	}
360	for i := 0; i < 20; i++ {
361		next := g.Feeds(now)
362		for j := range next {
363			if next[j].Name != first[j].Name {
364				t.Fatalf("feed order moved at %d: %q then %q", j, first[j].Name, next[j].Name)
365			}
366		}
367	}
368
369	// Nothing has been called yet, so every row reads idle rather than ok.
370	for _, f := range first {
371		if f.State != "idle" {
372			t.Errorf("%s reads %q before any call, want idle", f.Name, f.State)
373		}
374	}
375}
376
377func TestGuardFeedsReportBreakerState(t *testing.T) {
378	g := NewGuard(t.TempDir())
379	now := time.Now()
380
381	if err := g.Allow("yahoo"); err != nil {
382		t.Fatal(err)
383	}
384	g.Succeed("yahoo")
385	g.Fail("lobsters", http.StatusTooManyRequests, 0)
386
387	byName := map[string]Feed{}
388	for _, f := range g.Feeds(now) {
389		byName[f.Name] = f
390	}
391
392	if got := byName["YAHOO"].State; got != "ok" {
393		t.Errorf("yahoo reads %q after a success, want ok", got)
394	}
395	if got := byName["LOBSTERS"].State; got != "open" {
396		t.Errorf("lobsters reads %q with the breaker open, want open", got)
397	}
398}
399
400func TestShortAge(t *testing.T) {
401	cases := map[time.Duration]string{
402		5 * time.Second:  "5s",
403		90 * time.Second: "1m",
404		3 * time.Hour:    "3h",
405	}
406	for d, want := range cases {
407		if got := shortAge(d); got != want {
408			t.Errorf("shortAge(%s) = %q, want %q", d, got, want)
409		}
410	}
411}
412
413func TestVisitURLCarriesTheCampaign(t *testing.T) {
414	got := visitURL("blog.bythewood.me")
415
416	u, err := url.Parse(got)
417	if err != nil {
418		t.Fatalf("visitURL produced %q: %v", got, err)
419	}
420	if u.Scheme != "https" || u.Host != "blog.bythewood.me" {
421		t.Errorf("points at %s://%s, want https://blog.bythewood.me", u.Scheme, u.Host)
422	}
423	for k, want := range map[string]string{
424		"utm_source": "dash.bythewood.me",
425		"utm_medium": "referral",
426	} {
427		if got := u.Query().Get(k); got != want {
428			t.Errorf("%s = %q, want %q", k, got, want)
429		}
430	}
431}
432
433// Busy is relative to the busiest thing on this machine, not to an absolute.
434func TestTrafficLevel(t *testing.T) {
435	cases := []struct {
436		requests, busiest int64
437		want              int
438	}{
439		{0, 1000, 0},
440		{10, 0, 0},
441		{1000, 1000, 4},
442		{600, 1000, 4},
443		{400, 1000, 3},
444		{150, 1000, 2},
445		{10, 1000, 1},
446	}
447	for _, c := range cases {
448		if got := trafficLevel(c.requests, c.busiest); got != c.want {
449			t.Errorf("trafficLevel(%d, %d) = %d, want %d", c.requests, c.busiest, got, c.want)
450		}
451	}
452}
453func TestSystemRowDoesNotCarryTheBaseline(t *testing.T) {
454	b, err := json.Marshal(SystemRow{})
455	if err != nil {
456		t.Fatal(err)
457	}
458	if strings.Contains(string(b), "baseline") {
459		t.Errorf("a system row publishes a baseline field: %s", b)
460	}
461}
462
463// Caddy is on the strip because it is the one edge component that ships its
464// access log, so it has an error count to show. It has no public hostname and
465// no health endpoint, and both of those change how it is probed.
466func TestCaddyIsMonitoredWithoutAHostname(t *testing.T) {
467	var caddy *Monitored
468	for i := range monitored {
469		if monitored[i].Source == "caddy" {
470			caddy = &monitored[i]
471		}
472	}
473	if caddy == nil {
474		t.Fatal("caddy is not on the strip")
475	}
476	if caddy.Host != "" {
477		t.Errorf("caddy has host %q, but it serves every hostname and owns none", caddy.Host)
478	}
479	if !caddy.AnyStatus {
480		t.Error("caddy answers its catch-all with 404, so any status has to count as serving")
481	}
482	if caddy.Bridge == "" {
483		t.Error("caddy has no /healthz, so it needs an explicit bridge URL")
484	}
485}
486
487// A row with no hostname must not be given a link, and must not fall back to a
488// public probe it has no address for.
489func TestProbeWithoutAHostnameStaysUnknown(t *testing.T) {
490	g := NewGuard(t.TempDir())
491
492	row := probe(t.Context(), g, Monitored{
493		Label:  "Edge",
494		Source: "nowhere",
495		Bridge: "http://orchard-nowhere.invalid:80/",
496	})
497
498	if row.URL != "" {
499		t.Errorf("URL = %q, want none for a row with no hostname", row.URL)
500	}
501	if row.State != "unknown" {
502		t.Errorf("state = %q, want unknown with no route and no fallback", row.State)
503	}
504}
505
506// Every source on the strip has to match what logging files its records under,
507// or the error and traffic columns are silently blank for that row.
508func TestMonitoredSourcesAreDistinct(t *testing.T) {
509	seen := map[string]bool{}
510	for _, m := range monitored {
511		if m.Source == "" {
512			t.Errorf("%s has no source, so it can never be matched to its logs", m.Label)
513		}
514		if seen[m.Source] {
515			t.Errorf("%q appears twice, so one row would take the other's counts", m.Source)
516		}
517		seen[m.Source] = true
518	}
519}
520
521// The verdict is worded as an observation about the market, never as an
522// instruction. A dashboard that tells someone to buy will eventually do it at
523// the worst possible moment.
524func TestSignalVerdictNeverInstructs(t *testing.T) {
525	for worst := 0; worst <= 3; worst++ {
526		level, headline := verdict(worst)
527		if level == "" || headline == "" {
528			t.Fatalf("worst %d gave %q / %q", worst, level, headline)
529		}
530		for _, word := range []string{"BUY ", "SELL", "SHOULD", "MUST", "NOW IS"} {
531			if strings.Contains(headline, word) {
532				t.Errorf("worst %d says %q, which reads as advice", worst, headline)
533			}
534		}
535	}
536}
537
538func TestSignalGradesTheConditions(t *testing.T) {
539	// 200 closes falling from 100 to 90, so the trend and the drawdown both
540	// have something to say.
541	closes := make([]float64, 220)
542	for i := range closes {
543		closes[i] = 100 - float64(i)*0.05
544	}
545	h := &history{closes: closes, high52: 100}
546
547	quotes := map[string]Quote{
548		"^GSPC": {Price: 78, Previous: 80},
549		"^VIX":  {Price: 31, Previous: 24},
550	}
551
552	sig := buildSignal(h, quotes)
553	if sig.Level != "deep" && sig.Level != "stress" {
554		t.Errorf("level = %q with a 22%% drawdown and a VIX of 31", sig.Level)
555	}
556	if len(sig.Conditions) < 3 {
557		t.Errorf("%d conditions, want drawdown, five day, vix and trend", len(sig.Conditions))
558	}
559
560	byLabel := map[string]Condition{}
561	for _, c := range sig.Conditions {
562		byLabel[c.Label] = c
563	}
564	if byLabel["VIX"].Note != "STRESSED" {
565		t.Errorf("a VIX of 31 reads %q", byLabel["VIX"].Note)
566	}
567	if byLabel["TREND"].Note != "BELOW 200DMA" {
568		t.Errorf("a falling series reads %q", byLabel["TREND"].Note)
569	}
570}
571
572// A calm market must not be dressed up as an opportunity.
573func TestSignalStaysCalmInAQuietMarket(t *testing.T) {
574	closes := make([]float64, 220)
575	for i := range closes {
576		closes[i] = 100 + float64(i)*0.02
577	}
578	h := &history{closes: closes, high52: 105}
579
580	sig := buildSignal(h, map[string]Quote{
581		"^GSPC": {Price: 104.4, Previous: 104.3},
582		"^VIX":  {Price: 13, Previous: 13.2},
583	})
584	if sig.Level != "calm" {
585		t.Errorf("level = %q just below the high with a VIX of 13", sig.Level)
586	}
587}
588
589func TestSignalSurvivesNoHistory(t *testing.T) {
590	sig := buildSignal(nil, map[string]Quote{"^VIX": {Price: 14}})
591	if sig.Level == "" || sig.Headline == "" {
592		t.Error("the panel has to say something with no daily series")
593	}
594}
595
596// Yahoo quoted these at ten times the yield for years and now quotes the yield.
597func TestNormaliseYield(t *testing.T) {
598	for in, want := range map[float64]float64{4.25: 4.25, 42.5: 4.25, 0.5: 0.5, 21: 2.1} {
599		if got := normaliseYield(in); got != want {
600			t.Errorf("normaliseYield(%v) = %v, want %v", in, got, want)
601		}
602	}
603}
604
605func TestBuildRatesAndCurve(t *testing.T) {
606	r := buildRates(map[string]Quote{
607		"^IRX": {Price: 4.50, Previous: 4.48},
608		"^FVX": {Price: 4.10, Previous: 4.12},
609		"^TNX": {Price: 4.30, Previous: 4.25},
610		"^TYX": {Price: 4.80, Previous: 4.79},
611	})
612	if len(r.Rows) != 4 {
613		t.Fatalf("%d rows, want 4", len(r.Rows))
614	}
615	// 4.30 minus 4.50 is a 20 basis point inversion.
616	if r.CurveState != "inverted" {
617		t.Errorf("curve state = %q with the 10Y below the 3M", r.CurveState)
618	}
619	if r.Curve != "-20bp" {
620		t.Errorf("curve = %q, want -20bp", r.Curve)
621	}
622}
623
624// The board is read best to worst, so the order is the information.
625func TestBuildSectorsSortsByMove(t *testing.T) {
626	cells := buildSectors(map[string]Quote{
627		"XLK": {Price: 101, Previous: 100},
628		"XLE": {Price: 97, Previous: 100},
629		"XLF": {Price: 100.5, Previous: 100},
630	})
631
632	var seen []float64
633	for _, c := range cells {
634		if !c.Unavailable {
635			seen = append(seen, c.Raw)
636		}
637	}
638	for i := 1; i < len(seen); i++ {
639		if seen[i] > seen[i-1] {
640			t.Errorf("out of order at %d: %v", i, seen)
641			break
642		}
643	}
644	if cells[len(cells)-1].Unavailable != true {
645		t.Error("the funds with no quote should sort to the end")
646	}
647}
648
649func TestEarningsHelpers(t *testing.T) {
650	if got := parseMoney("$1,767,631,360,000"); got != 1767631360000 {
651		t.Errorf("parseMoney = %v", got)
652	}
653	if got := parseMoney("N/A"); got != 0 {
654		t.Errorf("parseMoney(N/A) = %v, want 0", got)
655	}
656	if got, ok := parseEPS("($0.35)"); !ok || got != -0.35 {
657		t.Errorf("parseEPS(loss) = %v %v, want -0.35 true", got, ok)
658	}
659	if _, ok := parseEPS(""); ok {
660		t.Error("an unreported quarter should not parse")
661	}
662	if got := trimCompany("Broadcom Inc."); got != "Broadcom" {
663		t.Errorf("trimCompany = %q", got)
664	}
665	if got := whenLabel("time-after-hours"); got != "POST" {
666		t.Errorf("whenLabel = %q, want POST", got)
667	}
668}
669
670func TestBands(t *testing.T) {
671	if got := aqiBand(49); got != "GOOD" {
672		t.Errorf("aqiBand(49) = %q", got)
673	}
674	if got := aqiBand(160); got != "UNHEALTHY" {
675		t.Errorf("aqiBand(160) = %q", got)
676	}
677	if got := pollenBand(8.8); got != "MED-HIGH" {
678		t.Errorf("pollenBand(8.8) = %q", got)
679	}
680	if got := uvBand(7.15); got != "HIGH" {
681		t.Errorf("uvBand(7.15) = %q", got)
682	}
683}
684
685func TestDayProgressClamps(t *testing.T) {
686	rise := time.Date(2026, 8, 30, 6, 0, 0, 0, time.UTC)
687	set := rise.Add(12 * time.Hour)
688
689	if got := dayProgress(rise.Add(-time.Hour), rise, set); got != 0 {
690		t.Errorf("before dawn = %d, want 0", got)
691	}
692	if got := dayProgress(set.Add(time.Hour), rise, set); got != 100 {
693		t.Errorf("after dusk = %d, want 100", got)
694	}
695	if got := dayProgress(rise.Add(6*time.Hour), rise, set); got != 50 {
696		t.Errorf("midday = %d, want 50", got)
697	}
698}
699
700// Go's time layout is the reference time and it is case sensitive, so an
701// uppercased layout is matched literally. Every earnings row read "MON 1 JAN".
702func TestDayLabelFormatsRealDates(t *testing.T) {
703	today := time.Date(2026, 8, 31, 9, 0, 0, 0, time.UTC)
704
705	if got := dayLabel(today, today); got != "TODAY" {
706		t.Errorf("today = %q", got)
707	}
708	if got := dayLabel(today.AddDate(0, 0, 1), today); got != "TOMORROW" {
709		t.Errorf("tomorrow = %q", got)
710	}
711
712	got := dayLabel(today.AddDate(0, 0, 3), today)
713	if got == "MON 1 JAN" || !strings.Contains(got, "SEP") {
714		t.Errorf("three days out = %q, want a real date in September", got)
715	}
716}
717
718// A player count has to fit a narrow column, and nobody needs the last three
719// digits of 431,908.
720func TestCompactCount(t *testing.T) {
721	for in, want := range map[int]string{
722		431908: "432K", 4601: "4.6K", 1460000: "1.5M", 812: "812",
723	} {
724		if got := compactCount(in); got != want {
725			t.Errorf("compactCount(%d) = %q, want %q", in, got, want)
726		}
727	}
728}
729
730// JustWatch returns every way to watch a title, including a dozen resold
731// channels, so a title on Netflix and on three resellers has to read as Netflix
732// and "with Ads" must not become its own service.
733func TestPickProvider(t *testing.T) {
734	cases := []struct {
735		names []string
736		want  string
737	}{
738		{[]string{"Netflix", "Netflix basic with Ads"}, "NETFLIX"},
739		{[]string{"Amazon Prime Video", "Amazon Prime Video with Ads"}, "PRIME"},
740		{[]string{"HBO Max", "HBO Max Amazon Channel"}, "HBO MAX"},
741		{[]string{"Paramount Plus Apple TV Channel"}, "PARAMOUNT+"},
742		{[]string{"Apple TV Amazon Channel", "Apple TV"}, "APPLE TV+"},
743		{[]string{"Hulu"}, "HULU"},
744		// Nothing Isaac would be subscribed to, so the row is dropped.
745		{[]string{"Some Obscure Channel"}, ""},
746		{nil, ""},
747	}
748	for _, c := range cases {
749		if got := pickProvider(c.names); got != c.want {
750			t.Errorf("pickProvider(%v) = %q, want %q", c.names, got, c.want)
751		}
752	}
753}
754
755// The answer must not depend on the order the offers came back in.
756func TestPickProviderIgnoresOfferOrder(t *testing.T) {
757	a := pickProvider([]string{"Hulu", "Netflix"})
758	b := pickProvider([]string{"Netflix", "Hulu"})
759	if a != b {
760		t.Errorf("order changed the answer: %q then %q", a, b)
761	}
762}
763
764// Friday counts, because that is when a weekend trip leaves.
765func TestOutlookCoversFridayThroughSunday(t *testing.T) {
766	if outlookDays != 3 {
767		t.Errorf("outlookDays = %d, want 3", outlookDays)
768	}
769}
770
771// A row labelled IMDB has to open IMDb or nothing, since a link that says one
772// thing and opens another is worse than no link.
773func TestIMDbURL(t *testing.T) {
774	cases := map[string]string{
775		"tt10986410": "https://www.imdb.com/title/tt10986410/",
776		" tt9288030": "https://www.imdb.com/title/tt9288030/",
777		"":           "",
778		"nm0000001":  "",
779		"tt":         "",
780		"ttnotanid":  "",
781		"10986410":   "",
782	}
783	for in, want := range cases {
784		if got := imdbURL(in); got != want {
785			t.Errorf("imdbURL(%q) = %q, want %q", in, got, want)
786		}
787	}
788}
789
790// A missing tomatometer has to leave the IMDb score alone rather than average
791// it against nothing, which would read as terrible for a film nobody reviewed.
792func TestCombineScores(t *testing.T) {
793	for _, tc := range []struct {
794		imdb   float64
795		tomato int
796		want   int
797		from   string
798	}{
799		{8.2, 96, 89, "IMDB+RT"},
800		{7.5, 85, 80, "IMDB+RT"},
801		{6.1, 43, 52, "IMDB+RT"},
802		{8.4, 0, 84, "IMDB ONLY"},
803		{5.0, 0, 50, "IMDB ONLY"},
804	} {
805		got, from := combineScores(tc.imdb, tc.tomato)
806		if got != tc.want || from != tc.from {
807			t.Errorf("combineScores(%v, %d) = %d %q, want %d %q", tc.imdb, tc.tomato, got, from, tc.want, tc.from)
808		}
809	}
810}
811
812func TestGradeScore(t *testing.T) {
813	for _, tc := range []struct {
814		pct  int
815		want string
816	}{
817		{100, "good"},
818		{80, "good"},
819		{79, "fair"},
820		{65, "fair"},
821		{64, "poor"},
822		{0, "poor"},
823	} {
824		if got := gradeScore(tc.pct); got != tc.want {
825			t.Errorf("gradeScore(%d) = %q, want %q", tc.pct, got, tc.want)
826		}
827	}
828}
829
830func TestSteamAppIDComesOffTheCapsuleURL(t *testing.T) {
831	for _, tc := range []struct{ logo, want string }{
832		{"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/3751260/abc/capsule_sm_120.jpg?t=1", "3751260"},
833		{"https://cdn.akamai.steamstatic.com/steam/apps/730/capsule_sm_120.jpg", "730"},
834		{"https://example.invalid/nothing.jpg", ""},
835	} {
836		got := ""
837		if m := steamAppID.FindStringSubmatch(tc.logo); m != nil {
838			got = m[1]
839		}
840		if got != tc.want {
841			t.Errorf("%s gave %q, want %q", tc.logo, got, tc.want)
842		}
843	}
844}
845
846func TestSteamPriceCoversFreeAndUnreleased(t *testing.T) {
847	free := &appDetails{}
848	free.Data.IsFree = true
849	if got := steamPrice(free); got != "FREE" {
850		t.Errorf("a free game priced %q", got)
851	}
852
853	// A preorder carries no price_overview at all, and the top sellers chart
854	// is full of them.
855	if got := steamPrice(&appDetails{}); got != "TBA" {
856		t.Errorf("an unreleased game priced %q", got)
857	}
858
859	paid := &appDetails{}
860	paid.Data.Price = &struct {
861		Final           int `json:"final"`
862		DiscountPercent int `json:"discount_percent"`
863	}{Final: 1674, DiscountPercent: 33}
864	if got := steamPrice(paid); got != "$16.74" {
865		t.Errorf("priced %q, want $16.74", got)
866	}
867	if got := discount(paid); got != 33 {
868		t.Errorf("discount %d, want 33", got)
869	}
870}
871
872func TestKeepSteamWillNotShrinkAFullPanel(t *testing.T) {
873	full := make([]Game, steamShown)
874	for _, tc := range []struct {
875		name         string
876		fresh, shown []Game
877		want         bool
878	}{
879		{"a full poll always lands", full, full, true},
880		{"two rows do not replace six", make([]Game, 2), full, false},
881		{"two rows do replace one", make([]Game, 2), make([]Game, 1), true},
882		{"the first poll lands short", make([]Game, 2), nil, true},
883	} {
884		if got := keepSteam(tc.fresh, tc.shown); got != tc.want {
885			t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
886		}
887	}
888}
889
890// The two sessions around a report, and Nasdaq no longer says which one carried
891// it, so the one that moved is the one that heard the news.
892func TestReactionPicksTheSessionThatMoved(t *testing.T) {
893	report := time.Date(2026, 9, 3, 0, 0, 0, 0, time.UTC)
894	days := []dailyClose{
895		{"2026-09-02", 120.07},
896		{"2026-09-03", 121.77},
897		{"2026-09-04", 100.61},
898	}
899
900	pct, ok := reaction(days, report)
901	if !ok {
902		t.Fatal("no reaction from three closes")
903	}
904	if pct > -17 || pct < -18 {
905		t.Errorf("after hours print = %.2f%%, want about -17.4", pct)
906	}
907
908	// The same shape with the move on the report day itself, which is a company
909	// that reported before the bell.
910	pct, ok = reaction([]dailyClose{
911		{"2026-09-02", 354.16},
912		{"2026-09-03", 317.46},
913		{"2026-09-04", 321.00},
914	}, report)
915	if !ok {
916		t.Fatal("no reaction from three closes")
917	}
918	if pct > -10 || pct < -11 {
919		t.Errorf("pre-market print = %.2f%%, want about -10.4", pct)
920	}
921}
922
923// A company that reported this morning has no session after it yet, so the
924// window is the one that is still open.
925func TestReactionOnAnUnfinishedSession(t *testing.T) {
926	report := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)
927	pct, ok := reaction([]dailyClose{{"2026-09-03", 100}, {"2026-09-04", 105}}, report)
928	if !ok || math.Abs(pct-5) > 0.001 {
929		t.Errorf("reaction = %v %v, want 5 true", pct, ok)
930	}
931	if _, ok := reaction([]dailyClose{{"2026-09-04", 105}}, report); ok {
932		t.Error("one close cannot be a reaction")
933	}
934}
935
936// A beat the market sold is the only thing this panel can honestly say about
937// guidance, so it has to fire on that shape and not on an ordinary result.
938func TestEarningsNote(t *testing.T) {
939	if got := earningsNote("BEAT", -9.3); got != "SOLD THE BEAT" {
940		t.Errorf("beat sold off = %q", got)
941	}
942	if got := earningsNote("MISS", 4.1); got != "BOUGHT THE MISS" {
943		t.Errorf("miss bought = %q", got)
944	}
945	if got := earningsNote("BEAT", 15.8); got != "" {
946		t.Errorf("beat and rallied = %q, want nothing", got)
947	}
948	if got := earningsNote("BEAT", -0.4); got != "" {
949		t.Errorf("beat and drifted = %q, want nothing", got)
950	}
951}
952
953// A penny estimate makes the percentage wild either way, so the band is what
954// keeps a two cent difference from reading as a blowout.
955func TestEPSVerdict(t *testing.T) {
956	if got := epsVerdict(2.06, 1.79, "15.08"); got != "BEAT" {
957		t.Errorf("beat = %q", got)
958	}
959	if got := epsVerdict(0.36, 0.44, "-18.18"); got != "MISS" {
960		t.Errorf("miss = %q", got)
961	}
962	if got := epsVerdict(1.33, 1.32, "0.76"); got != "MET" {
963		t.Errorf("inline = %q", got)
964	}
965	// No surprise figure, which Nasdaq returns when last year had no estimate.
966	if got := epsVerdict(1.20, 1.00, "N/A"); got != "BEAT" {
967		t.Errorf("computed surprise = %q", got)
968	}
969}
970
971// The panel is the top of the index and not the top of the market, which is the
972// whole reason the membership list is carried at all.
973func TestIndexMembership(t *testing.T) {
974	for _, in := range []string{"AAPL", "aapl", "BRK/B", "BRK.B"} {
975		if !inIndex(in) {
976			t.Errorf("%q should be in the index", in)
977		}
978	}
979	for _, out := range []string{"TSM", "ASML", "ARM", "PLTR-NOT-REAL"} {
980		if inIndex(out) {
981			t.Errorf("%q is not in the S&P 500", out)
982		}
983	}
984	if len(sp500Symbols) < 490 || len(sp500Symbols) > 515 {
985		t.Errorf("the index list holds %d names", len(sp500Symbols))
986	}
987}
988
989// reportDate has to turn every label the walk can produce back into a date, or
990// a row silently loses its reaction.
991func TestReportDateRoundTrips(t *testing.T) {
992	today := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
993	for i := -earningsBackDays; i <= 0; i++ {
994		day := today.AddDate(0, 0, i)
995		if got := reportDate(dayLabel(day, today), today); got != day.Format("2006-01-02") {
996			t.Errorf("%d days back round tripped to %q", i, got)
997		}
998	}
999}