Skip to content
quarry Artifact [db47697a2f]
Login

Artifact [db47697a2f]

Artifact db47697a2f59fc0a11bd17d693c45ca8c299130a47468713570c091579423507:


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")
	}
	wide := body[strings.Index(body, `data-orient="wide"`):strings.Index(body, `data-orient="tall"`)]
	checkins := 0
	for _, m := range drawnClocks(wide) {
		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")
}

// The page carries the clock twice, for a stylesheet to show one: wide
// (time across) and tall (the hours across, time down, newest at the top).
func TestClockEmitsBothOrientationsWithTheSameMarks(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, 6+a, a, 5+a, 2)
	}
	clockRows(&act, 0, 0, 0, 9000, 1) // an import: capped in both
	f := foldClock(act, -1)
	wide, tall := f.wide("alpha"), f.tall("alpha")
	sample, _ := clockSample("alpha", act, -1)
	html := string(sample)
	if strings.Count(html, `data-orient="wide"`) != 1 || strings.Count(html, `data-orient="tall"`) != 1 ||
		strings.Index(html, `data-orient="wide"`) > strings.Index(html, `data-orient="tall"`) {
		t.Fatal("FAIL: the sample does not carry one wide and one tall picture")
	}
	mw, mt := drawnClocks(wide), drawnClocks(tall)
	if len(mw) == 0 || len(mw) != len(mt) {
		t.Fatalf("FAIL: %d marks wide, %d tall", len(mw), len(mt))
	}
	byCell := map[[2]int]drawnClock{}
	for _, m := range mt {
		byCell[[2]int{m.b, m.h}] = m
	}
	sb := newStripBuckets(stripSince, 90)
	nb := sb.count()
	for _, m := range mw {
		o, ok := byCell[[2]int{m.b, m.h}]
		if !ok || o.files != m.files || o.rank != m.rank || o.hollow != m.hollow {
			t.Fatalf("FAIL: wide mark %+v is not the tall mark %+v", m, o)
		}
		// Hours run across and buckets down, newest at the top.
		if col := (o.cx - tallLeft) / tallCol; col < float64(m.h) || col > float64(m.h)+1 {
			t.Fatalf("FAIL: hour %d is in column %v", m.h, col)
		}
		if row := (o.cy - tallTop) / tallRowH; row < float64(nb-1-m.b) || row > float64(nb-m.b) {
			t.Fatalf("FAIL: bucket %d of %d is in row %v", m.b, nb, row)
		}
		if o.r > 0.75*tallCol+1e-6 || o.cy-o.r < tallTop+float64(nb-1-m.b)*tallRowH-tallRowH/2 {
			t.Fatalf("FAIL: tall mark %+v is over the cap or leaves its row", o)
		}
	}
	if !strings.Contains(tall, `data-margin="hours" data-points="24"`) || !strings.Contains(tall, fmt.Sprintf(`data-margin="buckets" data-points="%d"`, nb)) {
		t.Fatal("FAIL: the tall margins lack their points")
	}
	if !strings.Contains(tall, `class="dt" data-quiet=`) || strings.Contains(tall, `class="cg"`) {
		t.Fatal("FAIL: the tall clock lacks its quietest hour")
	}
	t.Log("ok")
}

// Nothing in the tall picture runs off its box, and the labels clear the marks.
func TestClockTallLabelsKeepClearOfMarks(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 := foldClock(act, -1).tall("alpha")
	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 labels", 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
		if x1 > tallW {
			t.Fatalf("FAIL: the label %q runs off the picture", n[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", n[3])
			}
		}
	}
	t.Log("ok")
}

// A tap on a coarse pointer shows the card, and the card is the link; a
// fine pointer keeps the plain link. The markup says so without a hit
// element per mark.
func TestClockTapShowsTheCardAndTheCardIsTheLink(t *testing.T) {
	srv := activityServer(t)
	_, body := getPage(t, srv, "/r/alpha/activity")
	i := strings.Index(body, `data-signals="{peopleTip: '', peopleHref:`)
	if i < 0 {
		t.Fatal("FAIL: the clock has no hover readout with a door")
	}
	open := body[strings.LastIndex(body[:i], "<div"):]
	open = open[:strings.Index(open, ">")]
	for _, want := range []string{`data-on:click=`, `pointer: coarse`, `preventDefault`, `pointerType !== 'touch'`} {
		if !strings.Contains(open, want) {
			t.Fatalf("FAIL: the readout lacks %s", want)
		}
	}
	if !strings.Contains(body, `<a class="act-tip slice-tip people-tip"`) || !strings.Contains(body, `data-attr:href="$peopleHref"`) {
		t.Fatal("FAIL: the card is not a link")
	}
	if strings.Contains(body, `class="hit"`) {
		t.Fatal("FAIL: a hit element per mark doubles the markup")
	}
	if !strings.Contains(sheetLiteral, `pointer: coarse`) {
		t.Fatal("FAIL: the sheet does not give the card a pointer on a coarse screen")
	}
	t.Log("ok")
}