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 "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}