package web
import (
"html/template"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"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("legend entry 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("legend door lost the axis")
}
}
func TestActivityFiltersNarrowTheWindow(t *testing.T) {
srv := activityServer(t)
_, body := getPage(t, srv, "/r/alpha/activity?branch=feat")
if n := len(dotRe.FindAllString(body, -1)); n != 1 {
t.Fatalf("branch=feat dots %d, want 1", n)
}
if !strings.Contains(body, `href="/r/alpha/activity?branch=feat"`) || !strings.Contains(body, `href="/r/alpha/activity?axis=tree&branch=feat"`) {
t.Fatal("axis links do not keep the branch")
}
_, body = getPage(t, srv, "/r/alpha/activity?user=eve")
if n := len(dotRe.FindAllString(body, -1)); n != 1 {
t.Fatalf("user=eve dots %d, want 1", n)
}
if !strings.Contains(body, `href="/r/alpha/activity?user=eve" data-author="0" aria-current="true"`) {
t.Fatal("chosen person is not current in the legend")
}
if !strings.Contains(body, `class="act-clear" href="/r/alpha/activity"`) {
t.Fatal("no way back to everyone")
}
}
func TestActivityRejectsBadInput(t *testing.T) {
srv := activityServer(t)
for _, p := range []string{"/r/alpha/activity?branch=nope", "/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("cells %d, want 4", n)
}
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")
}
}
func TestActivityTreeHalvesTheWindowOverBudget(t *testing.T) {
old := activityByteBudget
activityByteBudget = 1
t.Cleanup(func() { activityByteBudget = old })
srv := activityServer(t)
code, body := getPage(t, srv, "/r/alpha/activity?axis=tree")
if code != 200 {
t.Fatalf("status %d", code)
}
w := attrs(body, `<p class="act-window"`)
if w["halved"] == "0" || w["halved"] == "" {
t.Fatalf("window attrs %v", w)
}
if attrs(body, `<svg class="act act-tree"`)["since"] == "2026-08-01" {
t.Fatal("window was not shortened")
}
// The clock axis has no budget.
_, body = getPage(t, srv, "/r/alpha/activity")
if attrs(body, `<p class="act-window"`)["halved"] != "0" {
t.Fatal("clock axis was halved")
}
}
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")
}
}