Skip to content
quarry Artifact [72d82b4ef5]
Login

Artifact [72d82b4ef5]

Artifact 72d82b4ef5c5bd8bc597fc969085fb22af461bda0a08b826ce157338c3b316f8:


package web

import (
	"html/template"
	"io"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"regexp"
	"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("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&amp;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&amp;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")
	}
}

// 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.3 || v > 1 {
			t.Fatalf("opacity %q outside [0.3, 1]", c[1])
		}
		seen[c[1]] = true
	}
	if len(seen) < 1 {
		t.Fatal("no cells")
	}
}

// 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 href="`) {
		t.Fatalf("legend links: %d morph of %d", n, strings.Count(legend, `<a href="`))
	}
	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>`, `>everyone</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)
		}
	}
	// Branch links reload the page: the year slice depends on them.
	if i := strings.Index(body, `<details class="act-branches">`); i >= 0 {
		if strings.Contains(body[i:i+strings.Index(body[i:], "</details>")], `data-on:click`) {
			t.Fatal("branch links must stay whole-page loads")
		}
	}

	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="0" aria-current="true"`) {
		t.Fatal("the chosen person is not current in the fragment's legend")
	}
}