package web
import (
"fmt"
"html/template"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// activityIndex is a small history with the shapes the two pictures
// draw: two people, a branch, a merge (whose inherited touches must
// not become dots), and file changes in two directories.
func activityIndex(t *testing.T) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
h := func(b string) string { return strings.Repeat(b, 20) }
batch := []index.Parsed{
{Hash: h("00"), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-01T09:00:00", User: "dan", Comment: "base"}},
{Hash: h("11"), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-02T14:00:00", User: "eve", Comment: "on a branch",
Parents: []string{h("00")},
Tags: []artifact.Tag{{Name: "*branch", Target: "*", Value: "feat"},
{Name: "*sym-feat", Target: "*"}}}},
{Hash: h("22"), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-03T10:00:00", User: "dan", Comment: "trunk work",
Parents: []string{h("00")}}},
{Hash: h("33"), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-04T16:00:00", User: "dan", Comment: "merge feat",
Parents: []string{h("22"), h("11")}}},
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
if err := ix.FoldChangesBulk([]index.BulkChanges{
{Hash: h("00"), Art: batch[0].Art, Changes: []string{"src/a.c", "src/b.c", "doc/x.md"}},
{Hash: h("11"), Art: batch[1].Art, Changes: []string{"src/a.c", "src/c.c"}},
{Hash: h("22"), Art: batch[2].Art, Changes: []string{"doc/x.md"}},
{Hash: h("33"), Art: batch[3].Art, Changes: []string{"src/a.c", "src/c.c"}},
}); err != nil {
t.Fatal(err)
}
return ix
}
func activityServer(t *testing.T, opts ...Option) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: activityIndex(t)}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, opts))
t.Cleanup(srv.Close)
return srv
}
func getPage(t *testing.T, srv *httptest.Server, path string) (int, string) {
t.Helper()
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
var (
dotRe = regexp.MustCompile(`<circle class="dot[^"]*"`)
mergeRe = regexp.MustCompile(`<circle class="dot merge"`)
attrRe = regexp.MustCompile(`data-([a-z]+)="([^"]*)"`)
)
// attrs is every data-* on the first element matching the prefix.
func attrs(body, open string) map[string]string {
i := strings.Index(body, open)
if i < 0 {
return nil
}
end := strings.Index(body[i:], ">")
if end < 0 {
return nil
}
tag := body[i : i+end]
out := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
out[m[1]] = m[2]
}
return out
}
func TestActivityClockAxisIsOneDotPerCheckin(t *testing.T) {
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/activity")
if code != 200 {
t.Fatalf("status %d", code)
}
a := attrs(body, `<svg class="act act-clock"`)
if a == nil || a["axis"] != "clock" || a["rows"] != "4" || a["window"] != "picked" {
t.Fatalf("sample attrs %v", a)
}
if n := len(dotRe.FindAllString(body, -1)); n != 4 {
t.Fatalf("dots %d, want 4", n)
}
if n := len(mergeRe.FindAllString(body, -1)); n != 1 {
t.Fatalf("merge dots %d, want 1", n)
}
if !strings.Contains(body, `href="/r/alpha/checkin/`+strings.Repeat("33", 20)+`"`) {
t.Fatal("a dot is not a link to its check-in")
}
// Two people, both in the legend, each a door to their own window.
if !strings.Contains(body, `<g class="au" data-author="0"`) || !strings.Contains(body, `<g class="au" data-author="1"`) {
t.Fatal("dots are not grouped by author")
}
if !strings.Contains(body, `href="/r/alpha/activity?user=eve" data-author=`) {
t.Fatal("panel row is not a door")
}
if !strings.Contains(body, `<table class="sr-only" data-summary="clock">`) {
t.Fatal("no hidden summary table")
}
if !strings.Contains(body, `role="group" aria-label="4 check-ins by 2 people, `) {
t.Fatal("no aria sentence")
}
}
func TestActivityTreeAxisDrawsAuthoredTouchesOnly(t *testing.T) {
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
if code != 200 {
t.Fatalf("status %d", code)
}
if attrs(body, `<svg class="act act-tree"`)["axis"] != "tree" {
t.Fatal("no tree sample")
}
// 3 + 2 + 1 authored touches; the merge's two inherited ones are
// not dots (§3).
if n := len(dotRe.FindAllString(body, -1)); n != 6 {
t.Fatalf("dots %d, want 6", n)
}
if !strings.Contains(body, `data-marginal="paths"`) || !strings.Contains(body, `data-summary="tree"`) {
t.Fatal("tree marginal or summary missing")
}
// The axis control keeps the axis in the person door.
if !strings.Contains(body, `href="/r/alpha/activity?axis=tree&user=dan"`) {
t.Fatal("panel door lost the axis")
}
}
// The person filter singles out; it does not subtract (§7). Everyone's
// rows still come back, the chosen one is marked and in colour, and the
// rest go grey — in the sample, in its marginals and in the slice.
func TestActivityPersonIsSingledOutNotSubtracted(t *testing.T) {
srv := activityServer(t)
_, all := getPage(t, srv, "/r/alpha/activity")
_, body := getPage(t, srv, "/r/alpha/activity?user=eve")
if n, m := len(dotRe.FindAllString(body, -1)), len(dotRe.FindAllString(all, -1)); n != m {
t.Fatalf("?user=eve drew %d dots, want all %d", n, m)
}
a := attrs(body, `<svg class="act act-clock"`)
if a["user"] != "1" {
t.Fatalf("root does not carry the chosen person: %v", a)
}
if !strings.Contains(body, `<g class="au mine" data-author="1"`) {
t.Fatal("the chosen person's group is not marked")
}
if !strings.Contains(body, `<g class="au" data-author="0"`) {
t.Fatal("everyone else was subtracted")
}
for _, want := range []string{`<g class="mx mine" data-marginal="days"`, `<g class="my mine" data-marginal="hours"`} {
if !strings.Contains(body, want) {
t.Fatalf("marginal lacks the person's own bars: %s", want)
}
}
// The slice empties the days that were not theirs: the grid keeps
// every cell, only fewer of them are coloured.
lit, everyone := strings.Count(body, `<rect class="c" `), strings.Count(all, `<rect class="c" `)
if lit >= everyone || lit == 0 {
t.Fatalf("coloured cells under a filter: %d, want fewer than %d and more than none", lit, everyone)
}
if strings.Count(body, `<rect class="c empty" `) != everyone-lit+strings.Count(all, `<rect class="c empty" `) {
t.Fatal("the days that were not theirs did not become empty cells")
}
// The legend still names everyone, with the chosen one marked and
// a way back.
legend := body[strings.Index(body, `<ol class="act-legend"`):]
legend = legend[:strings.Index(legend, "</ol>")]
for _, want := range []string{`data-author="0"`, `data-author="1"`} {
if !strings.Contains(legend, want) {
t.Fatalf("the legend dropped %s", want)
}
}
if !strings.Contains(legend, `href="/r/alpha/activity?user=eve" data-author="1" aria-current="true"`) {
t.Fatal("chosen person is not current in the legend")
}
// The legend greys with the plot: the hook is on the list, so the
// sheet mutes every dot but the current entry's — no per-entry
// inline colour decides it.
if !strings.Contains(body, `<ol class="act-legend" data-user="1">`) {
t.Fatal("the legend does not carry the chosen person")
}
for _, want := range []string{`.act-legend[data-user] .sw {`, `.act-legend[data-user] a[aria-current] .sw {`} {
if !strings.Contains(sheetLiteral, want) {
t.Fatalf("the sheet has no %s rule", want)
}
}
if !strings.Contains(legend, `class="act-clear" href="/r/alpha/activity"`) {
t.Fatal("no way back to everyone")
}
}
// The legend is the chooser (§7): everyone in the window, most
// check-ins first, and nothing but people in it.
func TestActivityLegendIsTheChooser(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
legend := body[strings.Index(body, `<ol class="act-legend"`):]
legend = legend[:strings.Index(legend, "</ol>")]
if n := strings.Count(legend, `data-author="`); n != 2 {
t.Fatalf("legend entries %d, want the fixture's 2", n)
}
if n := strings.Count(legend, `<span class="act-n" title="check-ins">`); n != 2 {
t.Fatalf("%d of 2 entries carry a check-in count", n)
}
// With nobody chosen every dot keeps its colour, so the hook the
// greying rule hangs on is absent, and so is the way back.
if strings.Contains(body, `<ol class="act-legend" data-user=`) {
t.Fatal("the legend greys with nobody chosen")
}
if strings.Contains(legend, "act-clear") {
t.Fatal("a way back to everyone with nobody chosen")
}
// The cap, without a twenty-five-person fixture.
many := make([]activityAuthor, activityLegendMax+3)
for i := range many {
many[i] = activityAuthor{Name: fmt.Sprintf("p%02d", i), Checkins: len(many) - i}
}
got := activityLegendOf("alpha", "clock", "recent", "p03", many)
if len(got) != activityLegendMax {
t.Fatalf("legend named %d people, want at most %d", len(got), activityLegendMax)
}
if !got[3].Current || got[0].Current {
t.Fatal("the chosen entry is not the one marked")
}
if got[0].URL != "/r/alpha/activity?user=p00" {
t.Fatalf("entry URL %q", got[0].URL)
}
if u := activityLegendOf("alpha", "tree", "all", "", many)[0].URL; u != "/r/alpha/activity?axis=tree&user=p00&window=all" {
t.Fatalf("the entry dropped the axis or the window: %q", u)
}
}
func TestActivityRejectsBadInput(t *testing.T) {
srv := activityServer(t)
for _, p := range []string{"/r/alpha/activity?since=yesterday", "/r/alpha/activity?axis=people"} {
if code, _ := getPage(t, srv, p); code != 400 {
t.Errorf("%s: status %d, want 400", p, code)
}
}
if code, _ := getPage(t, srv, "/r/beta/activity"); code != 404 {
t.Errorf("unknown repo: status %d, want 404", code)
}
}
func TestActivitySliceIsOneCellPerActiveDay(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
a := attrs(body, `<svg class="slice"`)
if a["days"] != "4" || a["spans"] != "1" {
t.Fatalf("slice attrs %v", a)
}
if n := strings.Count(body, `<rect class="c" `); n != 4 {
t.Fatalf("coloured cells %d, want 4", n)
}
// Every other day of the year is drawn too, so the grid is whole.
if n := strings.Count(body, `<rect class="c empty" `); n != 365-4 {
t.Fatalf("empty cells %d, want %d", n, 365-4)
}
// Nothing is drawn inside a cell any more.
if strings.Contains(body, `<rect class="s" `) {
t.Fatal("second-author stripes are still drawn")
}
row := attrs(body, `<g class="row"`)
if row["span"] != "2026" || row["checkins"] != "4" || row["days"] != "4" {
t.Fatalf("row attrs %v", row)
}
if !strings.Contains(body, `<g class="own" data-led="2">`) {
t.Fatal("ownership bar missing")
}
if !strings.Contains(body, `data-summary="years"`) || !strings.Contains(body, `<th scope="row">2026</th><td>4</td><td>4</td><td>1</td>`) {
t.Fatal("no hidden per-year table")
}
}
// ?window=all is the whole history, and the byte budget is met by
// drawing it coarser rather than shorter (§7).
func TestActivityWindowAll(t *testing.T) {
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/activity?window=all")
if code != 200 {
t.Fatalf("status %d", code)
}
a := attrs(body, `<svg class="act act-clock"`)
if a["since"] != "2026-08-01" || a["until"] != "2026-08-04" {
t.Fatalf("the all window is not the repository's own: %v", a)
}
if a["bucket"] != "1" {
t.Fatalf("a small history should draw at full resolution: %v", a)
}
if !strings.Contains(body, `<nav class="seg-box act-win"`) ||
!strings.Contains(body, `href="/r/alpha/activity?window=all"`) {
t.Fatal("no window control")
}
old := activityByteBudget
activityByteBudget = 1
t.Cleanup(func() { activityByteBudget = old })
for _, p := range []string{"/r/alpha/activity?window=all", "/r/alpha/activity?axis=tree&window=all"} {
code, body := getPage(t, srv, p)
if code != 200 {
t.Fatalf("%s: status %d", p, code)
}
root := `<svg class="act act-clock"`
if strings.Contains(p, "tree") {
root = `<svg class="act act-tree"`
}
a := attrs(body, root)
k, err := strconv.Atoi(a["bucket"])
if err != nil || k <= 1 {
t.Fatalf("%s: over budget and still at %v-day resolution", p, a["bucket"])
}
// The window is not shortened to fit; only the resolution goes.
if a["since"] != "2026-08-01" {
t.Fatalf("%s: the window was shortened to %s", p, a["since"])
}
if w := attrs(body, `<p class="act-window"`); w["bucket"] != a["bucket"] {
t.Fatalf("%s: the page does not say the resolution: %v", p, w)
}
}
}
// Axis, window, reset zoom. The branch chooser and the merges toggle
// are gone, and a stray branch= in a URL is ignored (§7).
func TestActivityControlsAreAxisAndWindow(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
for _, no := range []string{"act-branches", "act-merges", "merges=1", "branch=", `data-merges="0"`} {
if strings.Contains(body, no) {
t.Fatalf("the page still carries %s", no)
}
}
win := body[strings.Index(body, `<nav class="seg-box act-win"`):]
win = win[:strings.Index(win, "</nav>")]
if n := strings.Count(win, "<a href="); n != 2 {
t.Fatalf("the window control has %d links, want recent and all", n)
}
_, ignored := getPage(t, srv, "/r/alpha/activity?axis=tree&branch=feat")
if n, m := len(dotRe.FindAllString(ignored, -1)), len(dotRe.FindAllString(body, -1)); n != m {
t.Fatalf("?branch=feat changed the picture: %d dots, want %d", n, m)
}
}
// Nothing is written on the plot: the bands, the rail and the axis keep
// their shapes and the hover carries the names and the dates (§3).
func TestActivityPlotCarriesNoText(t *testing.T) {
srv := activityServer(t)
for _, p := range []string{"/r/alpha/activity", "/r/alpha/activity?axis=tree"} {
_, body := getPage(t, srv, p)
i := strings.Index(body, `<svg class="act `)
if i < 0 {
t.Fatalf("%s: no sample", p)
}
svg := body[i : i+strings.Index(body[i:], "</svg>")]
if strings.Contains(svg, "<text") {
t.Fatalf("%s: the plot carries text", p)
}
// The shapes the labels hung on stay, and so do their hooks:
// the clock axis keeps its hour ticks, the tree axis its
// directory bands and its date rail.
if !strings.Contains(svg, `<g class="mx" data-marginal=`) {
t.Fatalf("%s: the plot lost its marginal", p)
}
want := []string{`<g class="ax">`}
if strings.Contains(p, "tree") {
want = []string{`<g class="bands">`, `<g class="rail">`}
}
for _, w := range want {
if !strings.Contains(svg, w) {
t.Fatalf("%s: the plot lost %s", p, w)
}
}
}
// The slice's month and year labels went the same way; its numbers
// column stays and the row keeps its hook.
_, body := getPage(t, srv, "/r/alpha/activity")
for _, no := range []string{`<text class="mo"`, `<text class="yr"`} {
if strings.Contains(body, no) {
t.Fatalf("the slice carries %s", no)
}
}
if !strings.Contains(body, `<g class="row" data-span="2026"`) || !strings.Contains(body, `<text class="n"`) {
t.Fatal("the slice lost its row hook or its numbers")
}
}
func TestActivityDoors(t *testing.T) {
// The home page needs a checkout behind tree.list, which no test
// server has; the template alone shows the doors it carries.
var home strings.Builder
if err := treePage.Execute(&home, pageData{Repo: "alpha", Title: "alpha",
HomeSlice: template.HTML(`<svg class="slice" viewBox="0 0 1 1"></svg>`)}); err != nil {
t.Fatal(err)
}
h := home.String()
if !strings.Contains(h, `id="home-slice" href="/r/alpha/activity"`) || !strings.Contains(h, `<svg class="slice"`) {
t.Fatal("home has no slice strip")
}
if !strings.Contains(h, `<a href="/r/alpha/activity"><svg class="ic" aria-hidden="true"><use href="#i-commit"/>`) {
t.Fatal("the check-in count is not a door")
}
nm := strings.Index(h, `id="navmore"`)
if nm < 0 || !strings.Contains(h[nm:nm+strings.Index(h[nm:], "</div>")], `href="/r/alpha/activity"`) {
t.Fatal("navmore has no activity entry")
}
var sub strings.Builder
if err := treePage.Execute(&sub, pageData{Repo: "alpha", Title: "alpha"}); err != nil {
t.Fatal(err)
}
if strings.Contains(sub.String(), `id="home-slice"`) {
t.Fatal("the slice strip is drawn with nothing to draw")
}
// The last year's strip, from the op.
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
if !strings.Contains(body, `<svg class="slice"`) {
t.Fatal("no slice on the page")
}
plane := &memCaps{stored: map[string]string{"dan": "a", "eve": oltp.Write.Caps()}}
adm := activityServer(t, WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}), WithCaps(plane))
body = asDan(t, adm, "/r/alpha/people/eve")
if !strings.Contains(body, `href="/r/alpha/activity?user=eve"`) {
t.Fatal("person editor has no activity door")
}
}
// Phase 3: the canvas script rides only on the activity page and is
// served immutable under its hashed name.
func TestActivityPageLoadsTheCanvasScript(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
tag := `<script src="` + activityJSURL + `" defer nonce="`
if strings.Count(body, tag) != 1 {
t.Fatalf("activity page: want one %q tag", tag)
}
resp, err := http.Get(srv.URL + activityJSURL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/javascript") ||
!strings.Contains(resp.Header.Get("Cache-Control"), "immutable") || string(got) != string(activityJS) {
t.Fatalf("script: %d %q %q, %d bytes", resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Cache-Control"), len(got))
}
_, home := getPage(t, srv, "/r/alpha/timeline")
if strings.Contains(home, activityJSURL) {
t.Fatal("the script is on a page that has no sample")
}
}
// The script reads its window off the SVG root and asks the API for
// the same one; that reply carries the inherited touches the SVG never
// draws and the canvas ghosts.
func TestActivityScriptReadsWhatTheSVGCarries(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
a := attrs(body, `<svg class="act act-tree`)
for _, k := range []string{"axis", "since", "until", "window"} {
if a[k] == "" {
t.Fatalf("svg root lacks data-%s: %v", k, a)
}
}
// The API is its own router, mounted beside the web handler.
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: activityIndex(t)}})}); err != nil {
t.Fatal(err)
}
h, err := api.Router(r, "v")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/alpha/activity?since="+a["since"]+"&until="+a["until"], nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"inherited":1`) {
t.Fatalf("api for the svg's window: %d, inherited touches absent: %.200s", rec.Code, rec.Body.String())
}
}
// A cell's opacity is the day's volume on a log scale (§7): the busiest
// day in a small fixture reads full, a one-check-in day does not, and
// every cell carries the attribute so the picture has a "how much".
func TestActivitySliceCellsCarryVolume(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
cells := regexp.MustCompile(`<rect class="c" [^>]*fill-opacity="([0-9.]+)"`).FindAllStringSubmatch(body, -1)
if len(cells) != strings.Count(body, `<rect class="c" `) {
t.Fatalf("%d of %d cells carry an opacity", len(cells), strings.Count(body, `<rect class="c" `))
}
seen := map[string]bool{}
for _, c := range cells {
v, err := strconv.ParseFloat(c[1], 64)
if err != nil || v < 0.25 || v > 1 {
t.Fatalf("opacity %q outside [0.25, 1]", c[1])
}
seen[c[1]] = true
}
if len(seen) < 1 {
t.Fatal("no cells")
}
// The weight has to be visible, not a shade apart: a one-check-in
// day is faint and the span's busy days are solid.
d := activityDaysOut{First: "2026-01-01", Last: "2026-12-31",
Authors: []activityAuthor{{Name: "a"}}, Days: map[string]activityDay{}}
for i, n := range []int{1, 2, 4, 12, 40} {
d.Days[fmt.Sprintf("2026-02-%02d", i+1)] = activityDay{N: n, Top: [][]int{{0, n}}, Path: -1}
}
svg, _ := coreSliceSVG(d, yearSpans(d), false, -1)
var got []float64
for _, m := range regexp.MustCompile(`<rect class="c" [^>]*fill-opacity="([0-9.]+)"`).FindAllStringSubmatch(svg, -1) {
v, _ := strconv.ParseFloat(m[1], 64)
got = append(got, v)
}
if len(got) != 5 {
t.Fatalf("weighted cells %d, want 5", len(got))
}
sort.Float64s(got)
if got[0] > 0.26 || got[4] < 0.99 {
t.Fatalf("the weight does not run the whole way: %v", got)
}
// The middle of the range is not pinned against either end.
if got[2] < 0.4 || got[2] > 0.85 {
t.Fatalf("a middling day reads as %.2f; the ramp is not carrying it: %v", got[2], got)
}
}
// The slice's hover is Datastar on the container, reading each cell's
// own <title>; the card is one element per slice, not one per cell.
func TestActivitySliceHoverIsDeclarative(t *testing.T) {
srv := activityServer(t)
_, page := getPage(t, srv, "/r/alpha/activity")
// The home page needs a checkout behind tree.list, which no test
// server has; render its template with a slice in hand.
var home strings.Builder
if err := treePage.Execute(&home, pageData{Repo: "alpha", Title: "alpha",
HomeSlice: template.HTML(`<svg class="slice" viewBox="0 0 1 1"><g class="au"><rect class="c" x="0" y="0" width="1" height="1"><title>x</title></rect></g></svg>`)}); err != nil {
t.Fatal(err)
}
for path, body := range map[string]string{"/r/alpha/activity": page, "/r/alpha": home.String()} {
i := strings.Index(body, `data-signals="{sliceTip`)
if i < 0 || strings.Count(body, `data-signals="{sliceTip`) != 1 {
t.Fatalf("%s: want one hover container", path)
}
open := body[strings.LastIndex(body[:i], "<") : i+strings.Index(body[i:], ">")]
for _, want := range []string{`data-on:pointerover="`, `data-on:pointermove="`, `data-on:pointerout="`} {
if !strings.Contains(open, want) {
t.Fatalf("%s: the hover container lacks %s: %s", path, want, open)
}
}
// The container holds the strip and the card; the strip's
// scroll box is a separate element inside it, or the card
// would scroll the strip out of view.
rest := body[i:]
svg, tip := strings.Index(rest, `<svg class="slice"`), strings.Index(rest, `class="act-tip slice-tip"`)
if svg < 0 || tip < 0 || tip < svg {
t.Fatalf("%s: strip then card inside the container, got svg %d tip %d", path, svg, tip)
}
if strings.Contains(open, `act-slice"`) {
t.Fatalf("%s: the hover container is the scroll box: %s", path, open)
}
if strings.Count(body, `class="act-tip slice-tip"`) != 1 || !strings.Contains(body, `data-text="$sliceTip"`) || strings.Contains(body, `data-show=`) {
t.Fatalf("%s: want exactly one declarative tip card", path)
}
if !strings.Contains(body, `<rect class="c" `) || !strings.Contains(body, `</title></rect>`) {
t.Fatalf("%s: cells must keep their <title> — it is what the tip reads", path)
}
}
}
// Choosing a person or an axis morphs the view region in place: the
// links keep their cold-loadable href, carry the morph handler, and the
// same URL with ?frag=1 answers with one patch aimed at #act-view.
func TestActivityViewMorphsInPlace(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?user=eve")
if !strings.Contains(body, `<div id="act-view" data-on:popstate__window="@get(`) {
t.Fatal("no morph region")
}
legend := body[strings.Index(body, `<ol class="act-legend"`):]
legend = legend[:strings.Index(legend, "</ol>")]
if n := strings.Count(legend, `data-on:click__prevent="history.pushState(`); n == 0 || n != strings.Count(legend, "<a ") {
t.Fatalf("legend links: %d morph of %d", n, strings.Count(legend, "<a "))
}
if !strings.Contains(legend, `@get('`) || !strings.Contains(legend, `frag`) {
t.Fatal("legend links do not ask for the fragment")
}
for _, want := range []string{`>clock</a>`, `>tree</a>`, `>recent</a>`, `>all</a>`} {
i := strings.Index(body, want)
if i < 0 {
t.Fatalf("no %s link", want)
}
open := body[strings.LastIndex(body[:i], "<a "):i]
if !strings.Contains(open, `data-on:click__prevent=`) {
t.Fatalf("%s is not a morph link: %s", want, open)
}
}
resp, err := http.Get(srv.URL + "/r/alpha/activity?user=eve&frag=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
frag, _ := io.ReadAll(resp.Body)
got := string(frag)
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("frag: %d %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
if !strings.HasPrefix(got, "event: datastar-patch-elements\ndata: mode inner\ndata: selector #act-view\n") {
t.Fatalf("frag envelope: %.120q", got)
}
for _, want := range []string{`data: elements <div class="act-controls">`, `<svg class="act act-clock`, `<ol class="act-legend"`, `data-author="`} {
if !strings.Contains(got, want) {
t.Fatalf("frag lacks %s", want)
}
}
for _, no := range []string{`<html`, `<svg class="slice"`, `id="act-view"`} {
if strings.Contains(got, no) {
t.Fatalf("frag carries %s", no)
}
}
if !strings.Contains(got, `data-author="1" aria-current="true"`) {
t.Fatal("the chosen person is not current in the fragment's legend")
}
}
// The slice is a week grid: a column per week, a row per weekday, one
// block per calendar year, and the page shows the last sliceYears of
// them unless asked for all.
func TestActivitySliceIsAWeekGridPerYear(t *testing.T) {
d := activityDaysOut{
First: "2016-03-04",
Last: "2026-01-01",
Authors: []activityAuthor{{Name: "a", Checkins: 2}},
Days: map[string]activityDay{"2016-03-04": {N: 1, Top: [][]int{{0, 1}}}, "2026-01-01": {N: 1, Top: [][]int{{0, 1}}}},
Releases: map[string]string{"2026-01-01": "v1"},
}
spans := yearSpans(d)
if len(spans) != 11 || spans[0].Label != "2026" || spans[10].Label != "2016" {
t.Fatalf("spans %+v", spans)
}
svg, years := coreSliceSVG(d, spans, false, -1)
a := attrs(svg, `<svg class="slice"`)
if a["spans"] != "11" || a["days"] != "2" || (a["cols"] != "53" && a["cols"] != "54") {
t.Fatalf("grid attrs %v", a)
}
// Nothing is written on the grid (§3): the year names itself in
// the numbers line, and the row keeps the hook the tests read.
for _, no := range []string{`<text class="mo"`, `<text class="yr"`} {
if strings.Contains(svg, no) {
t.Fatalf("the grid carries %s", no)
}
}
if n := strings.Count(svg, `<g class="row" data-span="`); n != 11 {
t.Fatalf("row hooks %d, want 11", n)
}
if !strings.Contains(svg, `>2026 · 1 check-in · 1 active day</text>`) {
t.Fatal("the numbers line does not name its year")
}
// The release is a marker in the gutter above the grid, never a
// shape inside a cell.
rel := regexp.MustCompile(`<path class="rel" d="M[\d.]+ ([\d.]+)L`).FindAllStringSubmatch(svg, -1)
if len(rel) != 1 || strings.Contains(svg, `<rect class="rel"`) {
t.Fatalf("release markers %d, and a notch rect is %v", len(rel), strings.Contains(svg, `<rect class="rel"`))
}
if apex, err := strconv.ParseFloat(rel[0][1], 64); err != nil || apex > float64(1+sliceTop) {
t.Fatalf("the release marker at %s reaches into the cell band below %d", rel[0][1], 1+sliceTop)
}
// 2026-01-01 is a Thursday: column 0, row 4 of the top block (which
// starts 1 px down).
cell := fmt.Sprintf(`<rect class="c" x="%d" y="%d" `, sliceLabelW, 1+sliceTop+4*sliceStep)
if !strings.Contains(svg, cell) {
t.Fatalf("no cell at %q", cell)
}
if len(years) != 11 || years[0].Label != "2026" || years[0].Checkins != 1 || years[0].Lead != "a" {
t.Fatalf("years %+v", years[:1])
}
if len(spans) <= sliceYears && sliceYears != 8 {
t.Fatal("sliceYears should trim this history")
}
// The home page's strip is one row: no labels, no right column,
// a viewBox one cell per day high, stretched to its box.
home, _ := coreSliceSVG(d, lastYearSpan(d), true, -1)
for _, part := range []string{`class="yr"`, `class="own"`, `class="mo"`} {
if strings.Contains(home, part) {
t.Fatalf("fit slice carries %s", part)
}
}
box := fmt.Sprintf(`viewBox="0 0 %d %d"`, 365*sliceCell, sliceStripH)
if !strings.Contains(home, box) || !strings.Contains(home, `preserveAspectRatio="none"`) {
t.Fatalf("strip is not one stretched row: %s", home[:120])
}
if !strings.Contains(home, fmt.Sprintf(` y="0" width="%d" height="%d" `, sliceCell, sliceStripH)) {
t.Fatal("strip cells are not full-height")
}
}
// The years door: the page shows sliceYears blocks and links to all of
// them; ?years=all shows every block and links back.
func TestActivityYearsDoor(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity")
// One year of history: nothing to link.
if strings.Contains(body, `class="act-years"`) {
t.Fatal("years link with nothing to show")
}
_, body = getPage(t, srv, "/r/alpha/activity?years=all")
if attrs(body, `<svg class="slice"`)["spans"] != "1" {
t.Fatal("all years should still be the one year")
}
}
// The canvas has a visible way out of a zoom besides Escape.
func TestActivityCanvasHasAResetControl(t *testing.T) {
js := string(activityJS)
if !strings.Contains(js, `"act-reset"`) || !strings.Contains(js, `reset.addEventListener("click"`) {
t.Fatal("no reset control in the script")
}
if !strings.Contains(sheetLiteral, `.act-reset {`) {
t.Fatal("reset control unstyled")
}
}
var (
treeDotRe = regexp.MustCompile(`<circle class="dot" cx="([\d.]+)" cy="([\d.]+)" r="[\d.]+"><title>[^ ]+ (\d{4}-\d\d-\d\d) `)
viewBoxRe = regexp.MustCompile(`<svg class="act act-tree" viewBox="0 0 \d+ (\d+)"`)
)
func treeViewBoxH(t *testing.T, body string) int {
t.Helper()
m := viewBoxRe.FindStringSubmatch(body)
if m == nil {
t.Fatal("no tree viewBox")
}
h, err := strconv.Atoi(m[1])
if err != nil {
t.Fatal(err)
}
return h
}
// The tree axis runs time down the page and files across (§7): one
// column per path, one row per day with the newest at the top, in a
// block whose height does not grow with the repository.
func TestActivityTreeAxisRunsTimeDown(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
a := attrs(body, `<svg class="act act-tree"`)
if a["paths"] != "4" || a["span"] != "4" {
t.Fatalf("root does not carry the tree's shape: %v", a)
}
if h := treeViewBoxH(t, body); h != treeH {
t.Fatalf("viewBox height %d, want the fixed %d", h, treeH)
}
// A window with fewer paths is the same height.
_, narrow := getPage(t, srv, "/r/alpha/activity?axis=tree&since=2026-08-03&until=2026-08-04")
if n := attrs(narrow, `<svg class="act act-tree"`)["paths"]; n == a["paths"] {
t.Fatalf("the two fixtures have the same path count (%s); nothing is being compared", n)
}
if h := treeViewBoxH(t, narrow); h != treeH {
t.Fatalf("a different repository shape gave height %d, want %d", h, treeH)
}
// The marginals swapped with the axes: files across the top, days
// down the right.
if !strings.Contains(body, `<g class="mx" data-marginal="paths">`) ||
!strings.Contains(body, `<g class="my" data-marginal="days">`) {
t.Fatal("marginals did not swap with the axes")
}
// Every dot sits on the grid the constants describe, and the
// oldest day is the lowest row.
span, paths := 4, 4
colW := float64(sampleW-sampleLeft-sampleRight) / float64(paths)
rowH := float64(treePlotH) / float64(span)
cols := map[string]bool{}
for p := 0; p < paths; p++ {
cols[fmt.Sprintf("%.1f", sampleLeft+(float64(p)+0.5)*colW)] = true
}
rows := map[string]bool{}
for d := 0; d < span; d++ {
rows[fmt.Sprintf("%.1f", treeTop+(float64(span-1-d)+0.5)*rowH)] = true
}
dots := treeDotRe.FindAllStringSubmatch(body, -1)
if len(dots) != 6 {
t.Fatalf("dots %d, want 6", len(dots))
}
byDate := map[string]float64{}
for _, d := range dots {
if !cols[d[1]] {
t.Fatalf("cx %s is not a path column", d[1])
}
if !rows[d[2]] {
t.Fatalf("cy %s is not a day row", d[2])
}
y, err := strconv.ParseFloat(d[2], 64)
if err != nil {
t.Fatal(err)
}
if seen, ok := byDate[d[3]]; ok && seen != y {
t.Fatalf("%s is on two rows, %v and %v", d[3], seen, y)
}
byDate[d[3]] = y
}
if byDate["2026-08-01"] <= byDate["2026-08-03"] {
t.Fatalf("time does not run down the page: %v", byDate)
}
}
// The script mirrors the filter and no longer knows about merges: the
// toggle is gone, so the ghost path went with it.
func TestActivityCanvasReadsTheChosenPerson(t *testing.T) {
js := string(activityJS)
if !strings.Contains(js, "dataset.user") {
t.Fatal("the script does not read data-user")
}
for _, no := range []string{"dataset.merges", "ghostByDay", "fillText"} {
if strings.Contains(js, no) {
t.Fatalf("the script still carries %s", no)
}
}
}