package web
import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"testing"
)
// The people axis is a punch card per person. These tests read the
// structure the writer promises — cards, the weekday row and hour
// column a mark lands on, the classes that say weekend and off-scale —
// through data hooks and numbers, never the words on the plot.
// 2026-08-05 is a Wednesday: a window that does not start on a Monday,
// so the weekday has to come from the date and not from the day index.
const peopleSince = "2026-08-05"
func peopleAuthors(n int) []activityAuthor {
out := make([]activityAuthor, n)
for i := range out {
out[i] = activityAuthor{Name: fmt.Sprintf("p%02d", i), Hue: (i * 25) % 360, Chroma: 6}
}
return out
}
// manyPeople is n people, person i with n-i check-ins at distinct slots,
// so the order by check-ins is the order of the author list.
func manyPeople(n int) activityOut {
act := activityOut{Since: peopleSince, Until: "2026-08-25", Authors: peopleAuthors(n)}
for a := 0; a < n; a++ {
for k := 0; k < n-a; k++ {
act.Rows = append(act.Rows, activityRow{Day: k % 14, Hour: (k * 5) % 24, Author: a, Files: 2})
}
}
return act
}
var (
peopleMarkRe = regexp.MustCompile(`<circle class="(dot[^"]*)" data-d="(\d)" data-h="(\d+)" cx="([\d.]+)" cy="([\d.]+)" r="([\d.]+)"`)
peopleCardRe = regexp.MustCompile(`<g class="card[^"]*" data-card="([a-z]+)"(?: data-author="(\d+)")?`)
)
// cardOf is the markup of one card, from its opening tag to the next
// card's (or the end of the svg).
func cardOf(t *testing.T, svg, card, author string) string {
t.Helper()
open := `data-card="` + card + `"`
if author != "" {
open += ` data-author="` + author + `"`
}
i := strings.Index(svg, open)
if i < 0 {
t.Fatalf("FAIL: no card %s", open)
}
rest := svg[i+len(open):]
if j := strings.Index(rest, `data-card="`); j >= 0 {
rest = rest[:j]
}
return rest
}
func TestPeopleAxisOneCardPerTopPersonPlusEveryone(t *testing.T) {
cases := []struct{ people, person, others int }{{3, 3, 0}, {12, 12, 0}, {14, 12, 1}}
for _, c := range cases {
svg := peopleSampleSVG("alpha", "", manyPeople(c.people), -1)
var every, person, others int
var order []string
for _, m := range peopleCardRe.FindAllStringSubmatch(svg, -1) {
switch m[1] {
case "everyone":
every++
case "person":
person++
order = append(order, m[2])
case "others":
others++
}
}
if every != 1 || person != c.person || others != c.others {
t.Fatalf("FAIL: %d people: everyone %d person %d others %d, want 1 %d %d", c.people, every, person, others, c.person, c.others)
}
// Most check-ins first, which is the author list's order here.
for i, a := range order {
if a != strconv.Itoa(i) {
t.Fatalf("FAIL: %d people: card %d is author %s", c.people, i, a)
}
}
if !strings.Contains(svg, `data-axis="people"`) || !strings.Contains(svg, `data-bucket="1"`) {
t.Fatalf("FAIL: root lacks the axis or bucket hook")
}
}
t.Log("ok")
}
func TestPeopleOthersCardSumsTheRest(t *testing.T) {
act := manyPeople(14)
svg := peopleSampleSVG("alpha", "", act, -1)
o := cardOf(t, svg, "others", "")
want := 0
for _, r := range act.Rows {
if r.Author >= 12 {
want++
}
}
head := svg[strings.Index(svg, `data-card="others"`):]
head = head[:strings.Index(head, ">")]
if !strings.Contains(head, fmt.Sprintf(`data-checkins="%d"`, want)) || !strings.Contains(head, `data-people="2"`) {
t.Fatalf("FAIL: others card %s, want %d check-ins by 2 people", head, want)
}
if !strings.Contains(o, `class="dot`) {
t.Fatal("FAIL: others card drew no marks")
}
t.Log("ok")
}
func TestPeopleSlotsLandOnTheirWeekdayRowAndHourColumn(t *testing.T) {
act := activityOut{Since: peopleSince, Until: "2026-08-12", Authors: peopleAuthors(1), Rows: []activityRow{
{Day: 0, Hour: 14, Files: 3}, // Wed
{Day: 3, Hour: 2, Files: 3}, // Sat
{Day: 4, Hour: 23, Files: 3}, // Sun
{Day: 5, Hour: 0, Files: 3}, // Mon
{Day: 5, Hour: 0, Files: 3}, // same slot: one mark
}}
svg := peopleSampleSVG("alpha", "", act, -1)
e := cardOf(t, svg, "everyone", "")
type slot struct{ d, h int }
want := map[slot]bool{{2, 14}: true, {5, 2}: true, {6, 23}: true, {0, 0}: true}
got := map[slot]bool{}
cx := map[slot]float64{}
cy := map[slot]float64{}
for _, m := range peopleMarkRe.FindAllStringSubmatch(e, -1) {
d, _ := strconv.Atoi(m[2])
h, _ := strconv.Atoi(m[3])
got[slot{d, h}] = true
cx[slot{d, h}], _ = strconv.ParseFloat(m[4], 64)
cy[slot{d, h}], _ = strconv.ParseFloat(m[5], 64)
}
if len(got) != len(want) || len(peopleMarkRe.FindAllString(e, -1)) != len(want) {
t.Fatalf("FAIL: slots %v, want %v", got, want)
}
for s := range want {
if !got[s] {
t.Fatalf("FAIL: no mark at weekday %d hour %d", s.d, s.h)
}
}
// Columns are hours and rows are weekdays, at the card's own cell.
if dx := cx[slot{6, 23}] - cx[slot{2, 14}]; dx != 9*peopleBigCell {
t.Fatalf("FAIL: nine hours apart are %.1f units, want %d", dx, 9*peopleBigCell)
}
if dy := cy[slot{6, 23}] - cy[slot{2, 14}]; dy != 4*peopleBigCell {
t.Fatalf("FAIL: four weekdays apart are %.1f units, want %d", dy, 4*peopleBigCell)
}
t.Log("ok")
}
func TestPeopleWeekendSlotsAreHollow(t *testing.T) {
var rows []activityRow
for d := 0; d < 7; d++ { // Wed..Tue: Sat is day 3, Sun day 4
rows = append(rows, activityRow{Day: d, Hour: 10, Files: 4})
}
act := activityOut{Since: peopleSince, Until: "2026-08-12", Authors: peopleAuthors(1), Rows: rows}
e := cardOf(t, peopleSampleSVG("alpha", "", act, -1), "everyone", "")
n := 0
for _, m := range peopleMarkRe.FindAllStringSubmatch(e, -1) {
d, _ := strconv.Atoi(m[2])
hollow := strings.Contains(m[1], "wk")
if hollow != (d >= 5) {
t.Fatalf("FAIL: weekday %d class %q", d, m[1])
}
n++
}
if n != 7 {
t.Fatalf("FAIL: %d marks, want 7", n)
}
t.Log("ok")
}
func TestPeopleOffScaleSlotGetsASecondRing(t *testing.T) {
// Twelve ordinary slots set the scale; one slot far above it.
var rows []activityRow
for h := 0; h < 12; h++ {
rows = append(rows, activityRow{Day: 0, Hour: h, Files: 10})
}
flat := activityOut{Since: peopleSince, Until: "2026-08-12", Authors: peopleAuthors(1), Rows: rows}
if c := cardOf(t, peopleSampleSVG("alpha", "", flat, -1), "everyone", ""); strings.Contains(c, `class="cap"`) {
t.Fatal("FAIL: a slot on the scale has a second ring")
}
big := flat
big.Rows = append(append([]activityRow{}, rows...), activityRow{Day: 1, Hour: 3, Files: 100000})
c := cardOf(t, peopleSampleSVG("alpha", "", big, -1), "everyone", "")
if n := strings.Count(c, `class="cap"`); n != 1 {
t.Fatalf("FAIL: %d second rings, want 1", n)
}
t.Log("ok")
}
func TestPeopleSizeScaleIsSharedByEveryCard(t *testing.T) {
// Twelve heavy slots make the scale gentle enough that a 100-file
// slot is under every card's cap; two people then have one such slot
// each, and the everyone card has both.
var rows []activityRow
for h := 0; h < 12; h++ {
rows = append(rows, activityRow{Day: 0, Hour: h, Author: 2, Files: 400})
}
rows = append(rows,
activityRow{Day: 1, Hour: 5, Author: 0, Files: 100},
activityRow{Day: 2, Hour: 9, Author: 1, Files: 100})
act := activityOut{Since: peopleSince, Until: "2026-08-12", Authors: peopleAuthors(3), Rows: rows}
svg := peopleSampleSVG("alpha", "", act, -1)
radius := func(card, author string, d, h int) string {
for _, m := range peopleMarkRe.FindAllStringSubmatch(cardOf(t, svg, card, author), -1) {
if m[2] == strconv.Itoa(d) && m[3] == strconv.Itoa(h) {
return m[6]
}
}
t.Fatalf("FAIL: no mark %s/%s at %d,%d", card, author, d, h)
return ""
}
a, b := radius("person", "0", 3, 5), radius("person", "1", 4, 9)
if a != b {
t.Fatalf("FAIL: the same files drew r=%s and r=%s", a, b)
}
if e := radius("everyone", "", 3, 5); e != a {
t.Fatalf("FAIL: the everyone card drew r=%s, a person card r=%s", e, a)
}
t.Log("ok")
}
func TestPeoplePeakRingAndWeekendShare(t *testing.T) {
// Busiest slot: Sat (weekday row 5) at hour 7, three check-ins; one
// more check-in on a Wednesday. Weekend share is 3 of 4.
act := activityOut{Since: peopleSince, Until: "2026-08-12", Authors: peopleAuthors(1), Rows: []activityRow{
{Day: 3, Hour: 7, Files: 1}, {Day: 3, Hour: 7, Files: 1}, {Day: 3, Hour: 7, Files: 1},
{Day: 0, Hour: 9, Files: 1},
}}
svg := peopleSampleSVG("alpha", "", act, -1)
e := cardOf(t, svg, "everyone", "")
if n := strings.Count(e, `class="peak"`); n != 1 {
t.Fatalf("FAIL: %d peak rings, want 1", n)
}
head := svg[strings.Index(svg, `data-card="everyone"`):]
head = head[:strings.Index(head, ">")]
for _, want := range []string{`data-peak-d="5"`, `data-peak-h="7"`, `data-weekend="75"`, `data-checkins="4"`, `data-files="4"`} {
if !strings.Contains(head, want) {
t.Fatalf("FAIL: card lacks %s: %s", want, head)
}
}
if !strings.Contains(e, `<path class="spark"`) {
t.Fatal("FAIL: no sparkline")
}
t.Log("ok")
}
func TestPeopleSparklineStaysBoundedOnALongWindow(t *testing.T) {
// Forty years is two thousand weeks; the line must not be.
act := activityOut{Since: "1986-01-01", Until: "2026-01-01", Authors: peopleAuthors(1)}
for d := 0; d < 14600; d += 3 {
act.Rows = append(act.Rows, activityRow{Day: d, Hour: d % 24, Files: 1})
}
svg := peopleSampleSVG("alpha", "all", act, -1)
m := regexp.MustCompile(`<path class="spark" d="([^"]*)"`).FindStringSubmatch(svg)
if m == nil {
t.Fatal("FAIL: no sparkline")
}
if n := strings.Count(m[1], "L") + 1; n > peopleSparkMax {
t.Fatalf("FAIL: sparkline has %d points, want at most %d", n, peopleSparkMax)
}
t.Log("ok")
}
func TestPeopleMarkCountIsBounded(t *testing.T) {
// Fourteen people who each worked every hour of every weekday: the
// most the picture can hold is 14 cards of 168 slots.
act := activityOut{Since: peopleSince, Until: "2026-08-25", Authors: peopleAuthors(14)}
for a := 0; a < 14; a++ {
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
act.Rows = append(act.Rows, activityRow{Day: d, Hour: h, Author: a, Files: 1 + a})
}
}
}
svg := peopleSampleSVG("alpha", "", act, -1)
marks := len(peopleMarkRe.FindAllString(svg, -1))
if marks != 14*168 {
t.Fatalf("FAIL: %d marks, want %d", marks, 14*168)
}
// Rings and the frame come on top, but never per check-in.
if n := strings.Count(svg, "<circle"); n > 2*marks+14 {
t.Fatalf("FAIL: %d circles for %d marks", n, marks)
}
t.Log("ok", len(svg), "bytes")
}
func TestPeopleNameLinksToThatPersonsClockView(t *testing.T) {
svg := peopleSampleSVG("alpha", "all", manyPeople(3), -1)
c := cardOf(t, svg, "person", "1")
want := `href="` + esc(activityURL("alpha", "clock", "p01", "all")) + `"`
if !strings.Contains(c, want) {
t.Fatalf("FAIL: card lacks %s", want)
}
t.Log("ok")
}
func TestPeopleFilterMarksTheChosenCard(t *testing.T) {
svg := peopleSampleSVG("alpha", "", manyPeople(4), 2)
if !strings.Contains(svg, `data-user="2"`) {
t.Fatal("FAIL: root lacks the chosen person")
}
mine := regexp.MustCompile(`<g class="card mine" data-card="person" data-author="(\d+)"`).FindAllStringSubmatch(svg, -1)
if len(mine) != 1 || mine[0][1] != "2" {
t.Fatalf("FAIL: chosen cards %v, want author 2 alone", mine)
}
// Nobody is hidden.
if n := len(regexp.MustCompile(`data-card="person"`).FindAllString(svg, -1)); n != 4 {
t.Fatalf("FAIL: %d person cards under a filter, want 4", n)
}
t.Log("ok")
}
func TestPeopleEmptyWindowDrawsAnEveryoneCardAndNothingElse(t *testing.T) {
svg := peopleSampleSVG("alpha", "", activityOut{Since: peopleSince, Until: peopleSince, Authors: peopleAuthors(2)}, -1)
if strings.Count(svg, `data-card=`) != 1 || strings.Contains(svg, `class="dot`) || strings.Contains(svg, "NaN") {
t.Fatalf("FAIL: empty window drew %d cards", strings.Count(svg, `data-card=`))
}
t.Log("ok")
}
func TestActivityPeopleAxisIsOnTheControlsAndRoundTrips(t *testing.T) {
srv := activityServer(t)
if u := activityURL("alpha", "people", "eve", "all"); u != "/r/alpha/activity?axis=people&user=eve&window=all" {
t.Fatalf("FAIL: people url %q", u)
}
code, body := getPage(t, srv, "/r/alpha/activity?axis=people")
if code != 200 {
t.Fatalf("FAIL: status %d", code)
}
nav := body[strings.Index(body, `<nav class="seg-box" aria-label="axis"`):]
nav = nav[:strings.Index(nav, "</nav>")]
if n := strings.Count(nav, "<a "); n != 3 {
t.Fatalf("FAIL: the axis control has %d links, want 3", n)
}
for _, axis := range []string{"clock", "tree", "people"} {
if !strings.Contains(nav, `href="`+esc(activityURL("alpha", axis, "", ""))+`"`) {
t.Fatalf("FAIL: no link for %s", axis)
}
}
if !strings.Contains(nav, `aria-current="true"`) || strings.Count(nav, `aria-current`) != 1 {
t.Fatal("FAIL: the current axis is not marked once")
}
// The window control keeps the axis.
if !strings.Contains(body, `href="/r/alpha/activity?axis=people&window=all"`) {
t.Fatal("FAIL: the window control dropped the axis")
}
a := attrs(body, `<svg class="act act-people"`)
if a["axis"] != "people" || a["window"] != "picked" {
t.Fatalf("FAIL: root attrs %v", a)
}
if !strings.Contains(body, `data-bucket="1"`) {
t.Fatal("FAIL: the bucket is not 1")
}
if !strings.Contains(body, `data-card="everyone"`) || strings.Count(body, `data-card="person"`) != 2 {
t.Fatal("FAIL: the fixture's two people do not have a card each")
}
t.Log("ok")
}
func TestActivityPeopleAxisHasNoSecondLegendButKeepsASummary(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?axis=people")
if strings.Contains(body, `<ol class="act-legend"`) {
t.Fatal("FAIL: the cards are the legend; a second one is printed")
}
if !strings.Contains(body, `data-summary="people"`) {
t.Fatal("FAIL: no accessible summary")
}
if !strings.Contains(body, `role="group" aria-label="`) {
t.Fatal("FAIL: the chart has no name")
}
// A filter offers the way back to everyone.
_, one := getPage(t, srv, "/r/alpha/activity?axis=people&user=eve")
if !strings.Contains(one, `class="act-clear"`) {
t.Fatal("FAIL: no way back to everyone")
}
if !strings.Contains(one, `<g class="card mine"`) {
t.Fatal("FAIL: the chosen person's card is not marked")
}
t.Log("ok")
}
func TestActivityPeopleHoverReadsTheMarksTitles(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?axis=people")
i := strings.Index(body, `data-on:pointerover=`)
if i < 0 {
t.Fatal("FAIL: no hover handler")
}
open := body[strings.LastIndex(body[:i], "<div"):]
open = open[:strings.Index(open, ">")]
if !strings.Contains(open, `closest('circle')`) || !strings.Contains(open, `.remove()`) {
t.Fatalf("FAIL: the hover does not lift a mark's title: %s", open)
}
if !strings.Contains(body, `<circle class="dot`) || !strings.Contains(body, `</title></circle>`) {
t.Fatal("FAIL: marks lost their title")
}
t.Log("ok")
}
func TestActivityPeopleFragmentIsAPatch(t *testing.T) {
srv := activityServer(t)
resp, err := http.Get(srv.URL + "/r/alpha/activity?axis=people&frag=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
got := string(b)
if resp.StatusCode != 200 || !strings.HasPrefix(got, "event: datastar-patch-elements\ndata: mode inner\ndata: selector #act-view\n") {
t.Fatalf("FAIL: frag %d %.100q", resp.StatusCode, got)
}
for _, want := range []string{`<svg class="act act-people`, `data-card="everyone"`, `data: elements <div class="act-controls">`} {
if !strings.Contains(got, want) {
t.Fatalf("FAIL: frag lacks %s", want)
}
}
for _, no := range []string{`<html`, `id="act-view"`} {
if strings.Contains(got, no) {
t.Fatalf("FAIL: frag carries %s", no)
}
}
t.Log("ok")
}