Skip to content
quarry Artifact [adfc33f6d0]
Login

Artifact [adfc33f6d0]

Artifact adfc33f6d03a8f66e096ff2cb9e827611a494f80b6d425226c637594890df176:


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"

// compactCard is the everyone card drawn small for a phone: a second copy
// of the same card, which a count of cards or marks must not take twice.
var compactCardRe = regexp.MustCompile(`(?s)<svg class="act act-people pc pc-every pc-compact".*?</svg>`)

func noCompact(svg string) string { return compactCardRe.ReplaceAllString(svg, "") }

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 := noCompact(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 := noCompact(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 := noCompact(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&amp;window=all"`) {
		t.Fatal("FAIL: the window control dropped the axis")
	}
	a := attrs(body, `<div class="act-cards"`)
	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")
}

// Each card is its own SVG inside a grid container, so a phone can lay
// the cards out at the size they were drawn for; the hooks and counts are
// the same as when they shared one SVG.
func TestPeopleEachCardIsItsOwnSVGInAGrid(t *testing.T) {
	act := manyPeople(14)
	html := peopleSampleSVG("alpha", "", act, -1)
	if !strings.HasPrefix(html, `<div class="act-cards" role="group" aria-label="`) || !strings.HasSuffix(html, `</svg></div>`) {
		t.Fatalf("FAIL: %.100q", html)
	}
	for _, want := range []string{`data-axis="people"`, `data-since=`, `data-window=`, `data-marks=`} {
		if !strings.Contains(html[:strings.Index(html, "<svg")], want) {
			t.Fatalf("FAIL: the container lacks %s", want)
		}
	}
	// Every card is a whole svg of its own, with a name; nothing nests.
	cards := strings.Count(html, `<svg class="act act-people pc `)
	if cards != strings.Count(html, "</svg>") || cards != strings.Count(html, "<svg") {
		t.Fatalf("FAIL: %d card svgs, %d closed", cards, strings.Count(html, "</svg>"))
	}
	// everyone, its compact twin for a phone, the others, and twelve people.
	if cards != 1+1+1+12 {
		t.Fatalf("FAIL: %d svgs, want 15", cards)
	}
	for _, want := range []string{`pc pc-every"`, `pc pc-every pc-compact"`, `pc pc-others"`, `pc pc-person pc-first"`} {
		if strings.Count(html, want) != 1 {
			t.Fatalf("FAIL: %d of %s", strings.Count(html, want), want)
		}
	}
	if strings.Count(html, `role="group" aria-label=`) != cards+1 {
		t.Fatal("FAIL: a card has no name")
	}
	// The compact card is the same card drawn small: marked, and its marks
	// are not counted twice.
	if !strings.Contains(compactCardRe.FindString(html), `data-variant="compact"`) {
		t.Fatal("FAIL: the compact card is not marked")
	}
	if n := len(peopleMarkRe.FindAllString(noCompact(html), -1)); !strings.Contains(html, fmt.Sprintf(`data-marks="%d"`, n)) {
		t.Fatalf("FAIL: data-marks does not count the %d marks once", n)
	}
	// The filter's hook is on every card's svg, so the stylesheet greys
	// the others whichever card they are on.
	one := peopleSampleSVG("alpha", "", act, 3)
	if strings.Count(one, `<svg class="act act-people`) != strings.Count(one, ` data-user="3">`) {
		t.Fatal("FAIL: a card svg does not carry the chosen person")
	}
	t.Log("ok")
}