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