Skip to content
quarry Artifact [e7dd2f6f0a]
Login

Artifact [e7dd2f6f0a]

Artifact e7dd2f6f0ad6da9ee01cee999c7d26cfb0722d4eca4b7bd3877acfa58f2d8efc:


package web

import (
	"fmt"
	"io"
	"math"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"testing"
)

// The tree axis is directory strips: a lane per directory, time across,
// one mark per person per lane per bucket. These tests read the
// structure the writer promises (lanes, the bucket a mark lands in, the
// radius, the classes that say hollow, coloured, remainder) through data
// hooks and numbers, never the words on the plot.

var (
	stripMarkRe = regexp.MustCompile(`<circle class="(dot[^"]*)" data-lane="(\d+)" data-b="(\d+)" data-author="(-?\d+)" data-files="(\d+)" cx="([\d.]+)" cy="([\d.]+)" r="([\d.]+)"(?: data-people="\d+")?(?: data-c="(\d)")?(?: data-rank="(\d)")?`)
	stripLaneRe = regexp.MustCompile(`<g class="lane" data-lane="(\d+)" data-dir="([^"]*)"`)
)

type drawnMark struct {
	cls                    string
	lane, b, author        int
	files                  int
	cx, cy, r              float64
	coloured, hollow, mine bool
	rank                   int // -1 when the mark carries none
	rest                   bool
}

func stripMarks(svg string) []drawnMark {
	var out []drawnMark
	for _, m := range stripMarkRe.FindAllStringSubmatch(svg, -1) {
		mk := drawnMark{cls: m[1]}
		mk.lane, _ = strconv.Atoi(m[2])
		mk.b, _ = strconv.Atoi(m[3])
		mk.author, _ = strconv.Atoi(m[4])
		mk.files, _ = 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)
		mk.rank = -1
		if m[10] != "" {
			mk.rank, _ = strconv.Atoi(m[10])
		}
		fields := strings.Fields(mk.cls)
		for _, f := range fields {
			switch f {
			case "c":
				mk.coloured = true
			case "hol":
				mk.hollow = true
			case "mine":
				mk.mine = true
			case "rest":
				mk.rest = true
			}
		}
		out = append(out, mk)
	}
	return out
}

func stripNames(authors int) []activityAuthor {
	out := make([]activityAuthor, authors)
	for i := range out {
		out[i] = activityAuthor{Name: fmt.Sprintf("p%02d", i), Hue: (i * 40) % 360, Chroma: 1}
	}
	return out
}

// stripTouches adds n touches by author a on path p on a day.
func stripTouches(act *activityOut, day, path, author, n int) {
	for i := 0; i < n; i++ {
		act.Touches = append(act.Touches, activityTouch{Day: day, Path: path, Author: author})
	}
}

// 2026-09-01 is a Tuesday, so a week bucket starts on the Monday before
// the window and day 6 (a Monday) opens the second one.
const stripSince = "2026-09-01"

func stripWindow(days int) activityOut {
	return activityOut{Since: stripSince, Until: dayAfter(stripSince, days-1), Authors: stripNames(8)}
}

func TestStripGrainFollowsTheWindow(t *testing.T) {
	for _, c := range []struct {
		days int
		want string
	}{{30, "2day"}, {60, "2day"}, {61, "week"}, {90, "week"}, {200, "week"}, {201, "2week"}, {300, "2week"}, {420, "2week"}, {421, "month"}, {900, "month"}} {
		if got := stripGrainOf(c.days); got != c.want {
			t.Fatalf("FAIL: %d days drew at %q, want %q", c.days, got, c.want)
		}
		// The buckets tile the window: first to last, no gap.
		sb := newStripBuckets(stripSince, c.days)
		if from, _ := sb.bounds(0); from != 0 {
			t.Fatalf("FAIL: %d days: the first bucket starts at %v", c.days, from)
		}
		if _, to := sb.bounds(sb.count() - 1); to != float64(c.days) {
			t.Fatalf("FAIL: %d days: the last bucket ends at %v", c.days, to)
		}
	}
	t.Log("ok")
}

func TestStripBucketsAreMondayAndCalendarAligned(t *testing.T) {
	wk := newStripBuckets(stripSince, 90)
	for day, want := range map[int]int{0: 0, 5: 0, 6: 1, 12: 1, 13: 2} {
		if got := wk.index(day); got != want {
			t.Fatalf("FAIL: week grain, day %d in bucket %d, want %d", day, got, want)
		}
	}
	mo := newStripBuckets("2024-01-15", 900)
	for day, want := range map[int]int{0: 0, 16: 0, 17: 1, 17 + 28: 1, 17 + 29: 2} {
		if got := mo.index(day); got != want {
			t.Fatalf("FAIL: month grain, day %d in bucket %d, want %d", day, got, want)
		}
	}
	// A leap February is one bucket of 29 days.
	if a, b := mo.bounds(1); b-a != 29 {
		t.Fatalf("FAIL: February 2024 spans %v days", b-a)
	}
	two := newStripBuckets(stripSince, 30)
	if two.index(0) != 0 || two.index(1) != 0 || two.index(2) != 1 {
		t.Fatal("FAIL: the 2-day grain does not pair days from the window's start")
	}
	t.Log("ok")
}

func TestStripLanesComeFromFoldColumnsAndAreAtMostSixteen(t *testing.T) {
	act := stripWindow(90)
	for d := 0; d < 40; d++ {
		for f := 0; f < 5; f++ {
			p := len(act.Paths)
			act.Paths = append(act.Paths, fmt.Sprintf("top%02d/sub/f%d.c", d, f))
			stripTouches(&act, d, p, d%3, 1)
		}
	}
	svg, lanes := stripsSampleSVG(act, -1)
	want, _ := foldColumns(act, 16)
	var got []string
	for _, m := range stripLaneRe.FindAllStringSubmatch(svg, -1) {
		got = append(got, m[2])
	}
	if len(got) > 16 || len(got) != len(want.Paths) || len(lanes) != len(got) {
		t.Fatalf("FAIL: %d lanes, %d summary rows, foldColumns says %d", len(got), len(lanes), len(want.Paths))
	}
	for i, n := range want.Paths {
		if got[i] != n {
			t.Fatalf("FAIL: lane %d is %q, foldColumns names it %q", i, got[i], n)
		}
	}
	// Folding must not lose the count: every touch is a file in a lane.
	files := 0
	for _, l := range lanes {
		files += l.N
	}
	if files != len(act.Touches) {
		t.Fatalf("FAIL: lanes hold %d files, there are %d touches", files, len(act.Touches))
	}
	t.Log("ok")
}

func TestStripMarkLandsInItsLaneAndBucket(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"doc/x.md", "src/a.c"}
	stripTouches(&act, 5, 0, 0, 2) // Sunday: week 0
	stripTouches(&act, 6, 1, 1, 3) // Monday: week 1
	stripTouches(&act, 6, 1, 0, 1) // inherited ones are nobody's work
	act.Touches[len(act.Touches)-1].Inherited = 1
	svg, _ := stripsSampleSVG(act, -1)
	sb := newStripBuckets(stripSince, 90)
	marks := stripMarks(svg)
	if len(marks) != 2 {
		t.Fatalf("FAIL: %d marks, want 2", len(marks))
	}
	type key struct{ lane, b, author, files int }
	got := map[key]drawnMark{}
	for _, m := range marks {
		got[key{m.lane, m.b, m.author, m.files}] = m
	}
	for _, k := range []key{{0, 0, 0, 2}, {1, 1, 1, 3}} {
		m, ok := got[k]
		if !ok {
			t.Fatalf("FAIL: no mark for %+v in %+v", k, marks)
		}
		from, to := sb.bounds(k.b)
		lo := stripsLeft + stripsPlotW*from/90
		hi := stripsLeft + stripsPlotW*to/90
		if m.cx < lo-stripsJitter || m.cx > hi+stripsJitter {
			t.Fatalf("FAIL: mark %+v at x %v, bucket spans %v to %v", k, m.cx, lo, hi)
		}
		top := stripsTop + float64(k.lane)*stripH
		if m.cy-m.r < top || m.cy+m.r > top+stripH {
			t.Fatalf("FAIL: mark %+v at y %v r %v leaves strip %v..%v", k, m.cy, m.r, top, top+stripH)
		}
	}
	t.Log("ok")
}

// Within a bucket the largest mark sits on the strip's line and the
// others step outward, above and below by turns; the remainder is last.
func TestStripBucketMarksStepOutwardFromTheLine(t *testing.T) {
	act := stripWindow(90)
	act.Authors = stripNames(10)
	act.Paths = []string{"src/a.c"}
	for a, n := range []int{50, 40, 30, 20, 10, 9, 8, 7} {
		stripTouches(&act, 0, 0, a, n)
	}
	svg, _ := stripsSampleSVG(act, -1)
	centre := stripsTop + stripH/2.0
	by := map[int]drawnMark{}
	var rest drawnMark
	for _, m := range stripMarks(svg) {
		if m.rest {
			rest = m
		} else {
			by[m.author] = m
		}
	}
	if len(by) != 6 || rest.files == 0 {
		t.Fatalf("FAIL: %d marks and remainder %+v", len(by), rest)
	}
	if by[0].cy != centre {
		t.Fatalf("FAIL: the largest mark is at y %v, the line is at %v", by[0].cy, centre)
	}
	if !(by[1].cy < centre) || !(by[2].cy > centre) {
		t.Fatalf("FAIL: the next two are at %v and %v about the line %v", by[1].cy, by[2].cy, centre)
	}
	if !(by[3].cy < by[1].cy) || !(by[4].cy > by[2].cy) {
		t.Fatalf("FAIL: the fourth and fifth do not step further out: %v", by)
	}
	for _, m := range by {
		if math.Abs(rest.cy-centre)+rest.r < math.Abs(m.cy-centre)+m.r-1 {
			t.Fatalf("FAIL: the remainder (y %v) is not the outermost", rest.cy)
		}
	}
	for _, m := range stripMarks(svg) {
		if m.cy-m.r < stripsTop || m.cy+m.r > stripsTop+stripH {
			t.Fatalf("FAIL: %+v leaves its strip", m)
		}
	}
	t.Log("ok")
}

// Nothing in the picture, its tooltips or its key speaks of an hour.
func TestStripSaysNothingOfHours(t *testing.T) {
	srv := activityServer(t)
	_, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
	i := strings.Index(body, `<svg class="act act-strips"`)
	svg := body[i : i+strings.Index(body[i:], "</svg>")]
	key := body[strings.Index(body, `<p class="act-key">`):]
	key = key[:strings.Index(key, "</p>")]
	for _, w := range []string{"hour", "UTC", ":00", "quiet", "usually"} {
		if strings.Contains(svg, w) || strings.Contains(key, w) {
			t.Fatalf("FAIL: the picture or its key mentions %q", w)
		}
	}
	if strings.Contains(svg, `class="cg"`) {
		t.Fatal("FAIL: the centre-of-gravity line is still drawn")
	}
	if !strings.Contains(svg, `class="base"`) {
		t.Fatal("FAIL: no baseline through the strips")
	}
	t.Log("ok")
}

func TestStripMarkAreaFollowsFiles(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"src/a.c"}
	// Marks of 4 files in many buckets keep the 95th percentile at 4.
	for b := 0; b < 12; b++ {
		stripTouches(&act, b*7, 0, 0, 4)
	}
	stripTouches(&act, 71, 0, 1, 1)
	stripTouches(&act, 78, 0, 1, 4)
	stripTouches(&act, 85, 0, 1, 16)
	svg, _ := stripsSampleSVG(act, -1)
	r := map[int]float64{}
	var four []float64
	for _, m := range stripMarks(svg) {
		if m.author == 1 {
			r[m.files] = m.r
		} else {
			four = append(four, m.r)
		}
	}
	for _, v := range four {
		if v != r[4] {
			t.Fatalf("FAIL: two marks of 4 files have radii %v and %v", v, r[4])
		}
	}
	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: not twice", r[4], r[1])
	}
	if r[16] <= r[4] || r[16] > stripsCap {
		t.Fatalf("FAIL: radius for 16 files %v, for 4 %v, cap %v", r[16], r[4], stripsCap)
	}
	if d := r[16]/r[4] - 2; (d < -0.1 || d > 0.1) && r[16] < stripsCap {
		t.Fatalf("FAIL: 16 files below the cap is %v against %v for 4", r[16], r[4])
	}
	t.Log("ok")
}

func TestStripMarkOverTheCapIsHollowWithASecondRing(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"src/a.c", "src/b.c"}
	// Many small marks, as in a real repository, and one import.
	act.Authors = stripNames(40)
	for a := 0; a < 40; a++ {
		stripTouches(&act, a, 0, a, 2)
	}
	stripTouches(&act, 30, 1, 0, 5000)
	svg, _ := stripsSampleSVG(act, -1)
	var big, small drawnMark
	for _, m := range stripMarks(svg) {
		if m.files == 5000 {
			big = m
		} else {
			small = m
		}
	}
	if big.files == 0 || small.files == 0 {
		t.Fatalf("FAIL: marks %+v", stripMarks(svg))
	}
	if !big.hollow {
		t.Fatalf("FAIL: the import is not hollow: %q", big.cls)
	}
	if small.hollow {
		t.Fatalf("FAIL: an ordinary mark is hollow: %q", small.cls)
	}
	if big.r > 0.42*stripH+1e-6 {
		t.Fatalf("FAIL: radius %v is over 42%% of the strip %v", big.r, stripH)
	}
	ring := fmt.Sprintf(`<circle class="cap" cx="%.1f" cy="%.1f" r="`, big.cx, big.cy)
	if !strings.Contains(svg, ring) {
		t.Fatalf("FAIL: no second ring for the capped mark at %v,%v", big.cx, big.cy)
	}
	if strings.Count(svg, `class="cap"`) != 1 {
		t.Fatalf("FAIL: %d rings, want one", strings.Count(svg, `class="cap"`))
	}
	t.Log("ok")
}

// An import across many lanes must never leave its strip, and the
// big marks are drawn first so the small ones sit on top.
func TestStripMarksStayInTheirStripAndLargestComeFirst(t *testing.T) {
	act := stripWindow(90)
	for l := 0; l < 12; l++ {
		act.Paths = append(act.Paths, fmt.Sprintf("d%02d/f.c", l))
	}
	for l := 0; l < 12; l++ {
		stripTouches(&act, 30, l, 0, 100000)
		for b := 0; b < 10; b++ {
			stripTouches(&act, b*7, l, 1+b%3, 1+b)
		}
	}
	svg, _ := stripsSampleSVG(act, -1)
	marks := stripMarks(svg)
	if len(marks) == 0 {
		t.Fatal("FAIL: no marks")
	}
	prev := 1e9
	for _, m := range marks {
		top := stripsTop + float64(m.lane)*stripH
		if m.cy-m.r < top || m.cy+m.r > top+stripH {
			t.Fatalf("FAIL: lane %d mark y %v r %v leaves its strip %v..%v", m.lane, m.cy, m.r, top, top+stripH)
		}
		if m.files > 0 && float64(m.files) > prev {
			t.Fatalf("FAIL: a mark of %d files drawn after a smaller one", m.files)
		}
		prev = float64(m.files)
	}
	t.Log("ok")
}

func TestStripSixLargestPlusARemainder(t *testing.T) {
	act := stripWindow(90)
	act.Authors = stripNames(10)
	act.Paths = []string{"src/a.c"}
	// Nine people in one bucket, 9..1 files.
	for a := 0; a < 9; a++ {
		stripTouches(&act, 0, 0, a, 9-a)
	}
	svg, _ := stripsSampleSVG(act, -1)
	var people, rest []drawnMark
	for _, m := range stripMarks(svg) {
		if m.rest {
			rest = append(rest, m)
		} else {
			people = append(people, m)
		}
	}
	if len(people) != 6 || len(rest) != 1 {
		t.Fatalf("FAIL: %d people marks and %d remainder, want 6 and 1", len(people), len(rest))
	}
	got := map[int]bool{}
	for _, m := range people {
		got[m.author] = true
	}
	for a := 0; a < 6; a++ {
		if !got[a] {
			t.Fatalf("FAIL: author %d is one of the six largest and has no mark", a)
		}
	}
	// 3+2+1 files in the three left over.
	if rest[0].files != 6 || rest[0].author != -1 {
		t.Fatalf("FAIL: remainder %+v, want 6 files and no author", rest[0])
	}
	if !strings.Contains(svg, `data-people="3"`) {
		t.Fatal("FAIL: the remainder does not say how many people it folds")
	}
	t.Log("ok")
}

func TestStripOnlyTheFiveBusiestCarryARank(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"a/x.c", "b/y.c"}
	// Author i has 8-i check-ins; author 7 has one.
	for a := 0; a < 8; a++ {
		for k := 0; k < 8-a; k++ {
			act.Rows = append(act.Rows, activityRow{Day: k, Hour: 9, Author: a, Files: 1})
		}
		for lane := 0; lane < 2; lane++ {
			stripTouches(&act, a*7+lane, lane, a, 2)
		}
	}
	svg, _ := stripsSampleSVG(act, -1)
	rankOf := map[int]int{}
	for _, m := range stripMarks(svg) {
		if m.rank < 0 {
			if m.coloured {
				t.Fatalf("FAIL: author %d is coloured without a rank", m.author)
			}
			continue
		}
		if !m.coloured {
			t.Fatalf("FAIL: author %d has a rank and no colour hook", m.author)
		}
		if r, seen := rankOf[m.author]; seen && r != m.rank {
			t.Fatalf("FAIL: author %d is rank %d and rank %d", m.author, r, m.rank)
		}
		rankOf[m.author] = m.rank
	}
	if len(rankOf) != 5 {
		t.Fatalf("FAIL: %d ranked people, want 5: %v", len(rankOf), rankOf)
	}
	for a := 0; a < 5; a++ {
		if r, ok := rankOf[a]; !ok || r != a {
			t.Fatalf("FAIL: author %d is the %dth busiest and has rank %v", a, a, rankOf[a])
		}
	}
	// The right-edge name carries the same rank as the person's marks.
	for a := 0; a < 5; a++ {
		if !strings.Contains(svg, fmt.Sprintf(`data-person="%d" data-first=`, a)) {
			continue
		}
		i := strings.Index(svg, fmt.Sprintf(`data-person="%d" data-first=`, a))
		tag := svg[i : i+strings.Index(svg[i:], ">")]
		if !strings.Contains(tag, fmt.Sprintf(`data-rank="%d"`, a)) {
			t.Fatalf("FAIL: author %d's label is not rank %d: %s", a, a, tag)
		}
	}
	// The forge's identity colour is not used on this view.
	if strings.Contains(svg, `style="--c:`) {
		t.Fatal("FAIL: a mark carries an identity colour")
	}
	t.Log("ok")
}

// markOf is the whole circle element of the first mark by an author.
func markOf(svg string, author int) string {
	i := strings.Index(svg, fmt.Sprintf(`data-author="%d" data-files=`, author))
	if i < 0 {
		return ""
	}
	i = strings.LastIndex(svg[:i], "<circle")
	return svg[i : i+strings.Index(svg[i:], "</circle>")]
}

func TestStripPersonFilterMarksTheChosenAndNothingIsHidden(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"src/a.c"}
	for a := 0; a < 8; a++ {
		for k := 0; k < 8-a; k++ {
			act.Rows = append(act.Rows, activityRow{Day: k, Hour: 9, Author: a, Files: 1})
		}
		stripTouches(&act, a*7, 0, a, 2)
	}
	all, _ := stripsSampleSVG(act, -1)
	// Author 7 is not among the five busiest: chosen, they still get
	// their colour.
	one, _ := stripsSampleSVG(act, 7)
	if len(stripMarks(one)) != len(stripMarks(all)) {
		t.Fatal("FAIL: the filter subtracted marks")
	}
	if !strings.Contains(one, ` data-user="7"`) {
		t.Fatal("FAIL: the root does not carry the chosen person")
	}
	for _, m := range stripMarks(one) {
		if m.mine != (m.author == 7) {
			t.Fatalf("FAIL: author %d mine=%v", m.author, m.mine)
		}
		if m.author == 7 && (!m.coloured || m.rank != -1) {
			t.Fatal("FAIL: the chosen person has no colour, or has taken a rank")
		}
	}
	if strings.Contains(all, ` data-user=`) {
		t.Fatal("FAIL: no one is chosen and the root says so")
	}
	t.Log("ok")
}

func TestStripMarkCountIsBounded(t *testing.T) {
	act := stripWindow(900)
	act.Authors = stripNames(60)
	for p := 0; p < 400; p++ {
		act.Paths = append(act.Paths, fmt.Sprintf("a%02d/b%02d/f%d.c", p%37, p%11, p))
	}
	for d := 0; d < 900; d++ {
		for k := 0; k < 12; k++ {
			stripTouches(&act, d, (d*7+k*31)%400, (d+k*5)%60, 1)
		}
	}
	svg, lanes := stripsSampleSVG(act, -1)
	marks := stripMarks(svg)
	bound := stripsMaxLanes * 60 * (stripsPerBucket + 1)
	if len(marks) == 0 || len(marks) > bound {
		t.Fatalf("FAIL: %d marks, bound %d", len(marks), bound)
	}
	if len(lanes) > stripsMaxLanes {
		t.Fatalf("FAIL: %d lanes", len(lanes))
	}
	if len(svg) > 1<<20 {
		t.Fatalf("FAIL: %d bytes of svg", len(svg))
	}
	if !strings.Contains(svg, fmt.Sprintf(`data-marks="%d"`, len(marks))) {
		t.Fatal("FAIL: data-marks does not count the marks")
	}
	t.Log("ok")
}

func TestStripAnnotationsNameRealMarksAtMostThree(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"src/a.c", "doc/x.md", "lib/y.c", "net/z.c"}
	for l := 0; l < 4; l++ {
		stripTouches(&act, 10+l*9, l, l, 400+l)
	}
	for b := 0; b < 15; b++ {
		stripTouches(&act, b*6, b%4, 5, 3)
	}
	svg, _ := stripsSampleSVG(act, -1)
	n := strings.Count(svg, `class="an"`)
	if n < 1 || n > 3 {
		t.Fatalf("FAIL: %d annotations", n)
	}
	// Each leader line belongs to an annotation.
	if l := strings.Count(svg, `class="ld"`); l > n {
		t.Fatalf("FAIL: %d leader lines for %d annotations", l, n)
	}
	t.Log("ok")
}

func TestStripLeadersTieTheFiveToTheirLanes(t *testing.T) {
	act := stripWindow(90)
	act.Paths = []string{"a/f", "b/f", "c/f"}
	for a := 0; a < 6; a++ {
		for k := 0; k < 6-a; k++ {
			act.Rows = append(act.Rows, activityRow{Day: k, Hour: 9, Author: a, Files: 1})
		}
	}
	// Author 0 leads lanes 0 and 1; author 1 is second in both and leads 2.
	stripTouches(&act, 0, 0, 0, 9)
	stripTouches(&act, 0, 0, 1, 4)
	stripTouches(&act, 7, 1, 0, 9)
	stripTouches(&act, 7, 1, 1, 4)
	stripTouches(&act, 14, 2, 1, 9)
	svg, _ := stripsSampleSVG(act, -1)
	if n := strings.Count(svg, `class="fl`); n != 5 {
		t.Fatalf("FAIL: %d leader curves, want 5", n)
	}
	h := strings.Index(svg, `data-person="0"`)
	if h < 0 {
		t.Fatal("FAIL: no label for the busiest person")
	}
	tag := svg[h : h+strings.Index(svg[h:], ">")]
	if !strings.Contains(tag, `data-first="2"`) || !strings.Contains(tag, `data-second="0"`) {
		t.Fatalf("FAIL: author 0 leads 2 lanes and is second in none: %s", tag)
	}
	t.Log("ok")
}

func TestStripEmptyWindowStillDraws(t *testing.T) {
	svg, lanes := stripsSampleSVG(activityOut{Since: stripSince, Until: stripSince}, -1)
	if !strings.HasPrefix(svg, `<svg class="act act-strips"`) || !strings.HasSuffix(svg, `</svg>`) {
		t.Fatalf("FAIL: %.80q", svg)
	}
	if len(lanes) != 0 || len(stripMarks(svg)) != 0 {
		t.Fatal("FAIL: an empty window drew lanes or marks")
	}
	t.Log("ok")
}

// The tree axis is chosen by URL, keeps its name and its place in the
// switch, and draws strips instead of a column per file.
func TestActivityTreeAxisIsStrips(t *testing.T) {
	srv := activityServer(t)
	code, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
	if code != 200 {
		t.Fatalf("FAIL: status %d", code)
	}
	a := attrs(body, `<svg class="act act-strips"`)
	if a["axis"] != "tree" || a["bucket"] != "1" || a["grain"] == "" || a["window"] != "picked" {
		t.Fatalf("FAIL: root attrs %v", a)
	}
	// The fixture's authored touches: dan 1+1+1 (a.c, b.c, x.md on day 0,
	// x.md on day 2), eve 2; the merge's inherited ones are nobody's.
	marks := stripMarks(body)
	files := 0
	for _, m := range marks {
		files += m.files
	}
	if files != 6 {
		t.Fatalf("FAIL: marks hold %d files, want the 6 authored touches", files)
	}
	if !strings.Contains(body, `data-summary="tree"`) || !strings.Contains(body, `role="group" aria-label="`) {
		t.Fatal("FAIL: no accessible summary or chart name")
	}
	if !strings.Contains(body, `href="/r/alpha/activity?axis=tree&amp;user=dan"`) {
		t.Fatal("FAIL: a legend door lost the axis")
	}
	nav := body[strings.Index(body, `<nav class="seg-box" aria-label="axis"`):]
	nav = nav[:strings.Index(nav, "</nav>")]
	if strings.Count(nav, "<a ") != 3 || !strings.Contains(nav, `>tree</a>`) {
		t.Fatal("FAIL: the axis control changed")
	}
	// The old picture is gone.
	for _, no := range []string{`act-tree`, `data-marginal="paths"`, `class="bands"`} {
		if strings.Contains(body, no) {
			t.Fatalf("FAIL: the old picture's %s is still drawn", no)
		}
	}
	t.Log("ok")
}

func TestActivityTreeAxisPersonFilter(t *testing.T) {
	srv := activityServer(t)
	_, all := getPage(t, srv, "/r/alpha/activity?axis=tree")
	_, one := getPage(t, srv, "/r/alpha/activity?axis=tree&user=eve")
	if len(stripMarks(one)) != len(stripMarks(all)) {
		t.Fatal("FAIL: the filter hid marks")
	}
	mine := 0
	for _, m := range stripMarks(one) {
		if m.mine {
			mine++
			if m.author != 1 {
				t.Fatalf("FAIL: author %d marked mine", m.author)
			}
		}
	}
	if mine == 0 {
		t.Fatal("FAIL: the chosen person has no marked mark")
	}
	if !strings.Contains(one, `class="act-clear"`) {
		t.Fatal("FAIL: no way back to everyone")
	}
	t.Log("ok")
}

func TestActivityTreeHoverReadsTheMarksTitles(t *testing.T) {
	srv := activityServer(t)
	_, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
	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, `class="act-sample"`) || !strings.Contains(body, `</title></circle>`) {
		t.Fatal("FAIL: marks lost their title")
	}
	t.Log("ok")
}

func TestActivityTreeFragmentIsAPatch(t *testing.T) {
	srv := activityServer(t)
	resp, err := http.Get(srv.URL + "/r/alpha/activity?axis=tree&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-strips`, `data-grain=`, `data: elements <div class="act-controls">`} {
		if !strings.Contains(got, want) {
			t.Fatalf("FAIL: frag lacks %s", want)
		}
	}
	t.Log("ok")
}

// A label keeps clear of every mark, and when the plot is solid with
// marks it goes in the band below.
func TestStripAnnotationsKeepClearOfMarks(t *testing.T) {
	act := stripWindow(900)
	act.Authors = stripNames(10)
	act.Paths = []string{"src/a.c"}
	// Six people in every month, all the same size: the one strip is solid.
	for d := 0; d < 900; d += 15 {
		for a := 0; a < 6; a++ {
			stripTouches(&act, d, 0, a, 20+a)
		}
	}
	svg, _ := stripsSampleSVG(act, -1)
	marks := stripMarks(svg)
	bot := float64(stripsTop + stripH)
	noteRe := regexp.MustCompile(`<text class="an" x="([\d.]+)" y="([\d.]+)">([^<]*)</text>`)
	notes := noteRe.FindAllStringSubmatch(svg, -1)
	if len(notes) == 0 {
		t.Fatal("FAIL: no annotations")
	}
	for _, n := range notes {
		x, _ := strconv.ParseFloat(n[1], 64)
		y, _ := strconv.ParseFloat(n[2], 64)
		w := float64(len([]rune(n[3])))*6.5 + 4
		x0, x1, y0, y1 := x, x+w, y-11, y+3
		if y0 < bot {
			t.Fatalf("FAIL: a label at y %v is in the solid plot, not the band below it", y)
		}
		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")
}

// A phone cannot see the right edge, so the five ranked people are listed
// under the picture, each with their strip colour and their lanes.
func TestStripsRankedLegendListsExactlyTheFive(t *testing.T) {
	act := stripWindow(90)
	act.Authors = stripNames(8)
	act.Paths = []string{"a/x.c", "b/y.c", "c/z.c"}
	for a := 0; a < 8; a++ {
		for k := 0; k < 8-a; k++ {
			act.Rows = append(act.Rows, activityRow{Day: k, Hour: 9, Author: a, Files: 1})
		}
		stripTouches(&act, a*7, a%3, a, 10-a)
	}
	p := stripsDraw(act, -1)
	ranks := p.ranks
	if n := strings.Count(ranks, `<li class="rk`); n > 5 || n == 0 {
		t.Fatalf("FAIL: %d ranked entries", n)
	}
	re := regexp.MustCompile(`data-rank="(\d)" data-c="(\d)" data-person="(\d+)" data-first="(\d+)" data-second="(\d+)"`)
	got := map[int]int{}
	for _, m := range re.FindAllStringSubmatch(ranks, -1) {
		r, _ := strconv.Atoi(m[1])
		a, _ := strconv.Atoi(m[3])
		if m[1] != m[2] {
			t.Fatalf("FAIL: rank %s and colour %s differ", m[1], m[2])
		}
		got[a] = r
	}
	// The same ranked people, with the same ranks, as the picture's marks.
	want := map[int]int{}
	for _, m := range stripMarks(p.svg) {
		if m.rank >= 0 {
			want[m.author] = m.rank
		}
	}
	for a, r := range want {
		if got[a] != r {
			t.Fatalf("FAIL: author %d is rank %d in the picture and %d in the legend", a, r, got[a])
		}
	}
	for a := range got {
		if _, ok := want[a]; !ok {
			t.Fatalf("FAIL: the legend lists author %d, who has no ranked mark", a)
		}
	}
	// And the lane labels for a phone to hold in view are drawn beside the picture.
	sample, key, _, _ := stripsSample(act, -1)
	if !strings.HasPrefix(string(sample), `<div class="act-sticky"><svg class="act-lanes"`) || strings.Count(string(sample), `<g class="lane" `) != strings.Count(p.svg, `<g class="lane" `) {
		t.Fatal("FAIL: no lane labels beside the picture")
	}
	if strings.Count(p.labels, `<text class="sc"`) != strings.Count(p.svg, `<text class="sc"`) {
		t.Fatal("FAIL: the held labels are not the picture's labels")
	}
	if strings.Count(string(key), `<svg class="act act-strips act-keysvg act-key-`) != 2 {
		t.Fatal("FAIL: the key is not drawn wide and narrow")
	}
	if strings.Contains(p.svg, `class="key"`) {
		t.Fatal("FAIL: the picture still carries its key")
	}
	t.Log("ok")
}