package web
import (
"fmt"
"io"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"testing"
)
// The clock axis is a grid of marks: time across in buckets, the hour of
// day (UTC, 00 at the top) down in 24 rows. A mark is one hour of one
// bucket, its area the files changed by the check-ins in it. These tests
// read the structure through data hooks and numbers, never the words.
var clockMarkRe = regexp.MustCompile(`<circle class="(dot[^"]*)" data-b="(\d+)" data-h="(\d+)" data-files="(\d+)" data-checkins="(\d+)" cx="([\d.]+)" cy="([\d.]+)" r="([\d.]+)"(?: data-c="(\d)")?(?: data-rank="(\d)")?`)
type drawnClock struct {
cls string
b, h, files, checkins int
cx, cy, r float64
rank, c int // -1 when absent
hollow, mine, coloured bool
}
func drawnClocks(svg string) []drawnClock {
var out []drawnClock
for _, m := range clockMarkRe.FindAllStringSubmatch(svg, -1) {
mk := drawnClock{cls: m[1], rank: -1, c: -1}
mk.b, _ = strconv.Atoi(m[2])
mk.h, _ = strconv.Atoi(m[3])
mk.files, _ = strconv.Atoi(m[4])
mk.checkins, _ = strconv.Atoi(m[5])
mk.cx, _ = strconv.ParseFloat(m[6], 64)
mk.cy, _ = strconv.ParseFloat(m[7], 64)
mk.r, _ = strconv.ParseFloat(m[8], 64)
if m[9] != "" {
mk.c, _ = strconv.Atoi(m[9])
mk.coloured = true
}
if m[10] != "" {
mk.rank, _ = strconv.Atoi(m[10])
}
for _, f := range strings.Fields(mk.cls) {
switch f {
case "hol":
mk.hollow = true
case "mine":
mk.mine = true
}
}
out = append(out, mk)
}
return out
}
func clockRows(act *activityOut, day, hour, author, files, n int) {
for i := 0; i < n; i++ {
act.Rows = append(act.Rows, activityRow{Day: day, Hour: hour, Author: author, Files: files})
}
}
func TestClockGrainIsTheSharedGrain(t *testing.T) {
for _, c := range []struct {
days int
want string
}{{30, "2day"}, {90, "week"}, {300, "2week"}, {900, "month"}} {
act := stripWindow(c.days)
clockRows(&act, 0, 5, 0, 1, 1)
svg := clockSampleSVG("alpha", act, -1)
if got := attrs(svg, `<svg class="act act-clock"`)["grain"]; got != c.want || got != stripGrainOf(c.days) {
t.Fatalf("FAIL: %d days drew at %q, want %q", c.days, got, c.want)
}
}
t.Log("ok")
}
func TestClockMarkLandsInItsBucketAndHourRow(t *testing.T) {
act := stripWindow(90)
clockRows(&act, 5, 14, 0, 2, 1) // Sunday: week 0
clockRows(&act, 6, 3, 1, 3, 1) // Monday: week 1
clockRows(&act, 6, 3, 1, 4, 1) // same cell: one mark of 7 files
svg := clockSampleSVG("alpha", act, -1)
sb := newStripBuckets(stripSince, 90)
marks := drawnClocks(svg)
if len(marks) != 2 {
t.Fatalf("FAIL: %d marks, want 2: %+v", len(marks), marks)
}
want := map[[2]int]int{{0, 14}: 2, {1, 3}: 7}
for _, m := range marks {
files, ok := want[[2]int{m.b, m.h}]
if !ok || files != m.files {
t.Fatalf("FAIL: mark %+v is not one of %v", m, want)
}
from, to := sb.bounds(m.b)
if lo, hi := stripsLeft+stripsPlotW*from/90, stripsLeft+stripsPlotW*to/90; m.cx < lo || m.cx > hi {
t.Fatalf("FAIL: mark %+v at x %v, bucket spans %v to %v", m, m.cx, lo, hi)
}
if row := clockTop + float64(m.h)*clockRowH + clockRowH/2; m.cy != row {
t.Fatalf("FAIL: mark %+v at y %v, hour row is at %v", m, m.cy, row)
}
}
// A later hour is lower: 00 is at the top.
if marks[0].h < marks[1].h && marks[0].cy > marks[1].cy {
t.Fatalf("FAIL: hours do not run down the page")
}
t.Log("ok")
}
func TestClockMarkAreaFollowsFiles(t *testing.T) {
act := stripWindow(90)
for b := 0; b < 12; b++ {
clockRows(&act, b*7, 9, 0, 4, 1)
}
clockRows(&act, 71, 10, 1, 1, 1)
clockRows(&act, 78, 10, 1, 4, 1)
clockRows(&act, 85, 10, 1, 16, 1)
svg := clockSampleSVG("alpha", act, -1)
r := map[int]float64{}
for _, m := range drawnClocks(svg) {
if m.h == 10 {
r[m.files] = m.r
} else if m.r != r4(drawnClocks(svg)) {
t.Fatalf("FAIL: two marks of 4 files have radii %v and %v", m.r, r4(drawnClocks(svg)))
}
}
if d := r[4]/r[1] - 2; d < -0.1 || d > 0.1 {
t.Fatalf("FAIL: 4 files has radius %v against %v for 1", r[4], r[1])
}
if d := r[16]/r[4] - 2; d < -0.1 || d > 0.1 {
t.Fatalf("FAIL: 16 files has radius %v against %v for 4", r[16], r[4])
}
t.Log("ok")
}
// r4 is the radius of a mark of four files.
func r4(marks []drawnClock) float64 {
for _, m := range marks {
if m.files == 4 {
return m.r
}
}
return 0
}
func TestClockMarkOverTheCapIsHollowWithASecondRing(t *testing.T) {
act := stripWindow(90)
act.Authors = stripNames(40)
for a := 0; a < 40; a++ {
clockRows(&act, a, a%24, a, 2, 1)
}
clockRows(&act, 30, 12, 0, 9000, 1) // an import
svg := clockSampleSVG("alpha", act, -1)
var big, small drawnClock
for _, m := range drawnClocks(svg) {
if m.files >= 9000 {
big = m
} else {
small = m
}
}
if !big.hollow || small.hollow {
t.Fatalf("FAIL: the import hollow=%v, an ordinary mark hollow=%v", big.hollow, small.hollow)
}
if big.r > 0.75*clockRowH+1e-6 {
t.Fatalf("FAIL: radius %v is over 75%% of the row %v", big.r, clockRowH)
}
if !strings.Contains(svg, fmt.Sprintf(`<circle class="cap" cx="%.1f" cy="%.1f" r="`, big.cx, big.cy)) || strings.Count(svg, `class="cap"`) != 1 {
t.Fatalf("FAIL: the capped mark does not carry exactly one second ring")
}
t.Log("ok")
}
func TestClockMarksAreDrawnLargestFirst(t *testing.T) {
act := stripWindow(90)
for a := 0; a < 8; a++ {
clockRows(&act, a*3, a, a, 1+a*5, 1)
}
prev := math.MaxInt
for _, m := range drawnClocks(clockSampleSVG("alpha", act, -1)) {
if m.files > prev {
t.Fatalf("FAIL: a mark of %d files after one of %d", m.files, prev)
}
prev = m.files
}
t.Log("ok")
}
// A mark is coloured when one of the five busiest made more than half of
// it; otherwise it is an outline.
func TestClockColourIsAMajorityOfOneOfTheFive(t *testing.T) {
act := stripWindow(90)
act.Authors = stripNames(8)
// Author i has 8-i check-ins elsewhere; with the cells below the five
// busiest by check-ins are authors 1, 0, 2, 3 and 4, in that order.
for a := 0; a < 8; a++ {
clockRows(&act, 40, 20, a, 0, 8-a)
}
clockRows(&act, 0, 1, 0, 10, 1) // author 0 alone: coloured, rank 0
clockRows(&act, 7, 2, 2, 6, 1) // author 2 has 6 of 10 files: coloured, rank 2
clockRows(&act, 7, 2, 3, 4, 1)
clockRows(&act, 14, 3, 1, 5, 1) // 5 of 10 is not more than half: outline
clockRows(&act, 14, 3, 3, 5, 1)
clockRows(&act, 21, 4, 7, 9, 1) // not one of the five: outline
clockRows(&act, 28, 5, 1, 0, 3) // merges only: three check-ins by 1 and one by 4
clockRows(&act, 28, 5, 4, 0, 1)
svg := clockSampleSVG("alpha", act, -1)
by := map[[2]int]drawnClock{}
for _, m := range drawnClocks(svg) {
by[[2]int{m.b, m.h}] = m
}
for cell, rank := range map[[2]int]int{{0, 1}: 1, {1, 2}: 2, {4, 5}: 0} {
if m, ok := by[cell]; !ok || m.rank != rank || !m.coloured {
t.Fatalf("FAIL: cell %v is %+v, want rank %d", cell, by[cell], rank)
}
}
for _, cell := range [][2]int{{2, 3}, {3, 4}} {
if m := by[cell]; m.coloured || m.rank != -1 {
t.Fatalf("FAIL: cell %v should be an outline: %+v", cell, m)
}
}
if strings.Contains(svg, `style="--c:`) {
t.Fatal("FAIL: a mark carries an identity colour")
}
t.Log("ok")
}
// A person has one rank on every axis that colours people.
func TestClockAndStripsGiveAPersonTheSameRank(t *testing.T) {
act := stripWindow(90)
act.Authors = stripNames(7)
act.Paths = []string{"a/x.c", "b/y.c"}
for a := 0; a < 7; a++ {
clockRows(&act, a*9, 6+a, a, 3, 7-a)
stripTouches(&act, a*9, a%2, a, 3)
clockRows(&act, a*9+1, 6+a, a, 3, 1)
}
clock := clockSampleSVG("alpha", act, -1)
strips, _ := stripsSampleSVG(act, -1)
onClock := map[int]int{}
for _, m := range drawnClocks(clock) {
if m.rank >= 0 {
// the mark's author is the one whose check-ins are at this hour
onClock[m.h-6] = m.rank
}
}
onStrips := map[int]int{}
for _, m := range stripMarks(strips) {
if m.rank >= 0 {
onStrips[m.author] = m.rank
}
}
if len(onClock) != 5 || len(onStrips) != 5 {
t.Fatalf("FAIL: %d ranked people on the clock, %d on the strips", len(onClock), len(onStrips))
}
for a, r := range onClock {
if onStrips[a] != r {
t.Fatalf("FAIL: author %d is rank %d on the clock and %d on the strips", a, r, onStrips[a])
}
}
t.Log("ok")
}
func TestClockPersonFilterDrawsTheirOwnMarksAndGreysTheRest(t *testing.T) {
act := stripWindow(90)
act.Authors = stripNames(8)
for a := 0; a < 8; a++ {
clockRows(&act, 40, 20, a, 1, 8-a)
clockRows(&act, a*7, 9, a, 5, 1)
clockRows(&act, a*7, 9, (a+1)%8, 3, 1)
}
all := drawnClocks(clockSampleSVG("alpha", act, -1))
svg := clockSampleSVG("alpha", act, 7) // not one of the five
marks := drawnClocks(svg)
if !strings.Contains(svg, ` data-user="7"`) || strings.Contains(clockSampleSVG("alpha", act, -1), ` data-user=`) {
t.Fatal("FAIL: the root does not say who is chosen")
}
var mine, others []drawnClock
for _, m := range marks {
if m.mine {
mine = append(mine, m)
} else {
others = append(others, m)
}
}
if len(others) != len(all) {
t.Fatalf("FAIL: the filter changed everyone else's marks: %d against %d", len(others), len(all))
}
// Author 7 worked in two cells at hour 9 and one at hour 20.
if len(mine) != 3 {
t.Fatalf("FAIL: %d marks of their own, want 3: %+v", len(mine), mine)
}
for _, m := range mine {
if !m.coloured || m.c != 0 || m.rank != -1 {
t.Fatalf("FAIL: a chosen person outside the five takes the first colour: %+v", m)
}
}
// Their own marks are drawn after everyone's, so they sit on top.
for i, m := range marks {
if !m.mine && i >= len(others) {
t.Fatalf("FAIL: a grey mark is drawn over one of theirs")
}
}
t.Log("ok")
}
func TestClockMarkCountIsBounded(t *testing.T) {
act := stripWindow(900)
act.Authors = stripNames(60)
for d := 0; d < 900; d++ {
for k := 0; k < 10; k++ {
clockRows(&act, d, (d*7+k*5)%24, (d+k*3)%60, 1+k, 1)
}
}
svg := clockSampleSVG("alpha", act, -1)
marks := drawnClocks(svg)
sb := newStripBuckets(stripSince, 900)
if len(marks) == 0 || len(marks) > sb.count()*24 {
t.Fatalf("FAIL: %d marks for %d buckets of 24 hours", len(marks), sb.count())
}
if !strings.Contains(svg, fmt.Sprintf(`data-marks="%d"`, len(marks))) {
t.Fatal("FAIL: data-marks does not count the marks")
}
if len(svg) > 1<<20 {
t.Fatalf("FAIL: %d bytes", len(svg))
}
t.Log("ok")
}
func TestClockMarginsHaveOnePointPerBucketAndTwentyFourPerDay(t *testing.T) {
act := stripWindow(90)
for d := 0; d < 90; d += 5 {
clockRows(&act, d, d%24, 0, 2, 2)
}
svg := clockSampleSVG("alpha", act, -1)
sb := newStripBuckets(stripSince, 90)
for margin, want := range map[string]int{"buckets": sb.count(), "hours": 24} {
i := strings.Index(svg, `data-margin="`+margin+`"`)
if i < 0 {
t.Fatalf("FAIL: no %s margin", margin)
}
g := svg[i : i+strings.Index(svg[i:], "</g>")]
if !strings.Contains(g, fmt.Sprintf(`data-points="%d"`, want)) {
t.Fatalf("FAIL: the %s margin does not say %d points: %.120s", margin, want, g)
}
d := regexp.MustCompile(` d="([^"]*)"`).FindStringSubmatch(g)
if d == nil || strings.Count(d[1], "M")+strings.Count(d[1], "L") != want {
t.Fatalf("FAIL: the %s margin's path does not have %d points: %v", margin, want, d)
}
}
// One dotted line at the quietest hour, and it says which as data.
if strings.Count(svg, `class="dt"`) != 1 || !regexp.MustCompile(`class="dt" data-quiet="\d+"`).MatchString(svg) {
t.Fatal("FAIL: the quietest hour is not marked once")
}
t.Log("ok")
}
func TestClockQuietestHourIsTheOneWithFewestCheckins(t *testing.T) {
act := stripWindow(90)
for h := 0; h < 24; h++ {
if h != 5 {
clockRows(&act, 3, h, 0, 1, 2)
}
}
svg := clockSampleSVG("alpha", act, -1)
if !strings.Contains(svg, `data-quiet="5"`) {
t.Fatalf("FAIL: the quietest hour is not 5")
}
t.Log("ok")
}
func TestClockAnnotationsNameRealMarksAndKeepClear(t *testing.T) {
act := stripWindow(900)
act.Authors = stripNames(10)
for d := 0; d < 900; d += 9 {
for h := 0; h < 24; h += 2 {
clockRows(&act, d, h, (d/9+h)%6, 1+(d+h)%30, 1)
}
}
svg := clockSampleSVG("alpha", act, -1)
marks := drawnClocks(svg)
notes := regexp.MustCompile(`<text class="an" x="([\d.]+)" y="([\d.]+)">([^<]*)</text>`).FindAllStringSubmatch(svg, -1)
if len(notes) == 0 || len(notes) > 3 {
t.Fatalf("FAIL: %d annotations", len(notes))
}
for _, n := range notes {
x, _ := strconv.ParseFloat(n[1], 64)
y, _ := strconv.ParseFloat(n[2], 64)
x0, x1, y0, y1 := x, x+float64(len([]rune(n[3])))*6.5+4, y-11, y+3
for _, m := range marks {
nx, ny := math.Max(x0, math.Min(m.cx, x1)), math.Max(y0, math.Min(m.cy, y1))
if math.Hypot(nx-m.cx, ny-m.cy) < m.r {
t.Fatalf("FAIL: the label %q touches a mark at %v,%v", n[3], m.cx, m.cy)
}
}
}
t.Log("ok")
}
func TestClockEmptyWindowStillDraws(t *testing.T) {
svg := clockSampleSVG("alpha", activityOut{Since: stripSince, Until: stripSince}, -1)
if !strings.HasPrefix(svg, `<svg class="act act-clock"`) || !strings.HasSuffix(svg, `</svg>`) || len(drawnClocks(svg)) != 0 {
t.Fatalf("FAIL: %.80q", svg)
}
t.Log("ok")
}
// Each mark goes to the timeline centred on the last day of its bucket:
// the timeline takes a date as c=, and has no range of its own.
func TestClockMarkLinksToTheTimelineAtItsBucket(t *testing.T) {
act := stripWindow(90)
clockRows(&act, 6, 3, 0, 2, 1) // week 1: Mon 2026-09-07 to Sun 2026-09-13
svg := clockSampleSVG("alpha", act, -1)
if !strings.Contains(svg, `<a href="/r/alpha/timeline?c=2026-09-13"><circle class="dot`) {
t.Fatalf("FAIL: the mark is not a link to the timeline at its bucket's last day: %.300s", svg[strings.Index(svg, "<a "):])
}
t.Log("ok")
}
func TestActivityClockPage(t *testing.T) {
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/activity")
if code != 200 {
t.Fatalf("FAIL: status %d", code)
}
a := attrs(body, `<svg class="act act-clock"`)
if a["axis"] != "clock" || a["rows"] != "4" || a["window"] != "picked" || a["grain"] == "" {
t.Fatalf("FAIL: root attrs %v", a)
}
if w := attrs(body, `<p class="act-window"`); w["grain"] != a["grain"] || w["bucket"] != "" {
t.Fatalf("FAIL: window line %v", w)
}
if strings.Contains(body, "-day resolution") {
t.Fatal("FAIL: the resolution note is still on the page")
}
checkins := 0
for _, m := range drawnClocks(body) {
checkins += m.checkins
}
if checkins != 4 {
t.Fatalf("FAIL: marks hold %d check-ins, want 4", checkins)
}
for _, want := range []string{`<table class="sr-only" data-summary="clock">`, `role="group" aria-label="4 check-ins by 2 people, `,
`href="/r/alpha/activity?user=eve" data-author=`, `class="act-strata"`, `closest('circle')`} {
if !strings.Contains(body, want) {
t.Fatalf("FAIL: the page lacks %s", want)
}
}
// The page carries no script of its own any more.
if strings.Contains(body, "/assets/activity") {
t.Fatal("FAIL: the page loads the canvas script")
}
_, one := getPage(t, srv, "/r/alpha/activity?user=eve")
if !strings.Contains(one, `<ol class="act-legend" data-user="1">`) || !strings.Contains(one, ` mine`) {
t.Fatal("FAIL: the person filter is not marked in the legend and the marks")
}
t.Log("ok")
}
func TestActivityClockFragmentIsAPatch(t *testing.T) {
srv := activityServer(t)
resp, err := http.Get(srv.URL + "/r/alpha/activity?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-clock`, `data-grain=`, `data: elements <div class="act-controls">`} {
if !strings.Contains(got, want) {
t.Fatalf("FAIL: frag lacks %s", want)
}
}
t.Log("ok")
}